skmtc-lang-kotlin
The Kotlin target-language layer for Skmtc generators (@skmtc/lang-kotlin): base factories, KtSnippet, the seven entity kinds, packages-from-paths imports, the head+value render model, KtAnnotation and the composition classes, sanitization and @SerialName placement, plus the curr
Install
npx skills add https://github.com/skmtc/skmtc/tree/main/deno/docs/skills/skmtc-lang-kotlin
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install skmtc-skmtc@llmmart
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
The Kotlin layer (@skmtc/lang-kotlin)
Read skmtc-generator first.
Drift warning. The API of record is the workspace
skmtc/deno/lang-kotlinand its tests. The shippedgen-kotlin-*generators predate the 0.9.11 flattening: they callnew KtAnnotation('Name', [args])positionally and importisKtAnnotated/isKtSupertyped(no longer exported; supertype clauses now render inline in the value). Clone their structure only; take call shapes from THIS skill's example (§8), which is pinned byte-for-byte against the engine bylang-kotlin/src/skill-example.test.ts.
1. Declaring the language
Same pattern as TypeScript — the import graph declares it. Two
factories: toKtModelProjectionBase, toKtOasOperationProjectionBase;
snippets extend KtSnippet.
export const KtModelBase = toKtModelProjectionBase<EnrichmentSchema>({
id: denoJson.name,
toEnrichmentSchema,
toIdentifierName({ refName, enrichments }) {
return enrichments?.subject?.name ?? capitalize(camelCase(refName))
},
// Kotlin's identifier KIND depends on schema shape → may read context
// (runs only on cache-miss; the NAME stays pure):
toIdentifierType(refName, context) {
return { type: toShape(context, peekSchema(context, refName)) }
},
toExportPath({ refName, enrichments }) {
const name = enrichments?.subject?.name ?? capitalize(camelCase(refName))
return join('@', ...enrichments.generator.basePackage.split('.'), `${name}.generated.kt`)
}
})
The export path's directory segments ARE the Kotlin package (§4). Make
basePackage a required generator-scope enrichment with no default;
validate segments with isKtIdentifierName + ktHardKeywords. Put the
shape dispatch (object+props → data-class; string+enums →
enum-class; qualifying discriminated union → sealed-interface; else
typealias) in ONE deterministic function read by both
toIdentifierType and the constructor, so kind and value can't disagree.
2. Register shapes — Kotlin differences
Same three shapes as TS (projection own-file / registerInto / snippet
with required destinationPath), plus defineAndRegister (no cache
check; no noExport — visibility is the identifier's fact: pass
exported: false to the factory). Compile-time differences: no
reExports field (Kotlin has none) and no type tag on imports
(no type-only imports). custom renders above the package directive.
3. Identifier kinds
Kotlin output has seven entity kinds (KtEntityType): class,
data-class, enum-class, interface, sealed-interface,
typealias, val —
factories createClass, createDataClass, createEnumClass,
createInterface, createSealedInterface, createTypeAlias,
createValue (only createValue takes typeName; exported: false
renders private ). Deferred kinds (object, fun, var) make
toKtEntityType throw — deliberately loud. Kind does NOT affect import
form. The engine's type is an opaque string: isKtEntityType narrows
it to the vocabulary above, and isKtIdentifier narrows a neutral
IdentifierBase back to KtIdentifier.
4. Emitted-import rules
- Packages from paths:
@/com/example/api/User.generated.kt→package com.example.api. Segments are validated, never sanitized — a keyword or invalid segment throws (fix the path policy). - One
import pkg.Nameper symbol (no brace grouping),asaliases, rendered sorted (determinism, not style). - Same-package suppression is central: register imports
unconditionally;
KtFiledrops same-package ones at render. - Importing from the default package throws (root-level artifact referenced from a packaged one = path-policy bug).
5. Render model: head + value
Assignment kinds (typealias, val): <head> = <value>. Declaration
kinds: <head><value> — the value renders everything after the name:
parameter list (parens included), inline : Parent clauses, { … }
bodies; an empty value yields the bodyless idiom
(sealed interface Animal).
Two things ride on value-carried protocols (the neutral Lang signature
has no slot for them): KtAnnotated (annotations: KtAnnotation[],
strict — string look-alikes are silently dropped) and KtDocumented
(description, guard isKtDocumented, rendered as KDoc above the
annotations). The mirroring gotcha: the Driver wraps the
PROJECTION as the definition's value, so mirror both onto the projection
— canon is reference assignment in the constructor
(this.annotations = this.value.annotations — one array, two names;
never copy) — or class-level annotations and KDoc silently vanish.
6. Composition classes (current API)
KtParameterList(parameters)— parens included; each{ name, type: Stringable, nullable?, defaultValue?, annotations?, visibility? }renders as an indentedval, annotations one per line.KtPrimaryConstructor({ parameters, modifiers? })— modifiers force the explicitconstructorkeyword.KtFunctionSignature({ name, parameters, returnType?, annotations?, body? })— abstract by default, expression body only.KtAnnotation({ context, name, args?, target?, packageName?, destinationPath })— a registering leaf: withpackageNameit registers its own import (register unconditionally; suppression handles same-package).argsare pre-quoted (['"user_id"'],['Foo::class']).targetis the use-site target (KtAnnotationTarget:field/get/set/…) rendered as@field:JsonAnySetter— the imported symbol stays the barename. Needed on a constructorval, which is parameter/property/field/ getter at once: Jackson's catch-all pair is@field:JsonAnySetter+@get:JsonAnyGetter, and without targets both annotations land on the parameter, where Jackson never looks. (Shipped in lang-kotlin 0.10.0, 2026-08-04 — pre-targetversions cannot express use-site targets at all.)withDescription(value, { description })— KDoc.
7. Sanitization and @SerialName
sanitizePropertyName(name): plain → unchanged; hard keyword or invalid
→ backticked; JVM-unescapable characters → throws ("rename +
@SerialName"). Renames are NOT its job — serialization annotations
handle wire-name mismatches, and the two compose: decide the annotation
by comparing the unescaped chosen name with the wire key
(`object` needs no @SerialName; user_id→userId does). Only the
28 hard keywords escape; soft/modifier keywords (value, data,
sealed) are legal identifiers. Canonical pairing:
sanitizePropertyName(camelCase(key)).
8. Worked example — kotlinx data class (current API, engine-pinned)
Per-property loop inside the data-class value snippet:
const propertyName = sanitizePropertyName(camelCase(key))
const annotations: KtAnnotation[] = []
if (propertyName.replaceAll('`', '') !== key) {
annotations.push(new KtAnnotation({
context, destinationPath,
name: 'SerialName', packageName: 'kotlinx.serialization', args: [`"${key}"`]
}))
}
parameters.push({
name: propertyName,
type: value, // the SNIPPET — never `${value}`
defaultValue: isRequired ? undefined : 'null',
annotations
})
// this.parameterList = new KtParameterList(parameters)
// class-level: this.annotations = [new KtAnnotation({ context,
// destinationPath, name: 'Serializable', packageName: 'kotlinx.serialization' })]
// projection mirrors by REFERENCE: this.annotations = this.value.annotations
Renders (verified byte-for-byte through the engine):
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
)
The type expression is the single owner of ?; the parameter layer
only adds = null. Passing `${value}` instead of the snippet
strands its registered imports and synthesized siblings — the file
breaks far from the cause. Serialization flavor is confined to the value
files (data class / enum entries / sealed interface): a Jackson/Moshi
sibling generator swaps annotation construction there only.
8b. Normalized models — KNOWN ENGINE GAP (verified 2026-08-03)
The head+value model means a Kotlin value renders differently in TYPE
position (Map<String, Any?>) and DECLARATION position (a parameter
list). Core's generic insertNormalizedModel glues the identifier
head to the value's type-position toString() — which for an inline
OBJECT schema renders invalid Kotlin: data class XMap<String, Any?>.
The engine gap is real, but the SOLUTION does not wait for it —
every mature Kotlin generator solves inline objects the same way:
- Named
$refschemas are unaffected —insertModeland the ref path work correctly. - An inline NON-object schema normalizes fine as a
typealias-shaped value. - An inline object is SYNTHESIZED as a named sibling declaration
and referenced by name — the retired gen-kotlin-kotlinx pattern
(
KtObjectValue, skmtc-generators history at2c24a65) rebuilt WITHOUT its naming-hint threading: the name derives from the schema's ownstackTrail(toSynthesizedName.tsin gen-kotlin-jackson — anchor on thecomponents/pathslandmark frames, never absolute indices; classification is POSITIONAL:propertiesconsumes the following frame as a literal key, so a property namedproperties/schema/itemscan never be mistaken for trail structure), so every construction path — including peers arriving throughinsertNormalizedModel— lands on the same name with NO parameter added to the router contract. Names are NOT collision-free: claim via the document-wide registry (claimSynthesizedName, gen-kotlin-jacksonsynthesizedNames.ts) BEFORE declaring — it throws per-item when the name collides with a component-derived class name (Kotlin's redeclaration scope is the PACKAGE, not the file) or with a different position's claim (camelCase-convergent keys), and returns reuse for a same-position re-walk. On'declare',defineAndRegisterthe sibling and render only the NAME. Type position then always holds a name or a map — never property structure. This is also how OpenAPI Generator solves it (inline schemas hoisted to named components before generation). Widening a known shape toMap<String, Any?>is capitulation, not a solution — it discards the type the schema gave you. Inline string enums synthesize the same way (enum classsibling). - Never fabricate a refName or drive the peer's identity statics to force a declaration into existence — that is the two-doors rule (skmtc-generator §4), and the result couples you to the peer's private snippet shape.
8c. Discriminated unions — sealed interfaces (shipped 2026-08-04)
Kotlin has no union type; a QUALIFYING discriminated union becomes a
sealed interface (gen-kotlin-jackson is the worked example; ancestry:
the retired kotlinx machinery at skmtc-generators 2c24a65, stale call
shapes). Predicate (shape.ts isSealedUnion, part of the shape
dispatch): discriminated, ≥2 members, every member a $ref to an
object-with-properties, and every member keeps ≥1 parameter AFTER
discriminator omission. Everything else renders the honest wire type
(JsonNode for Jackson), never Any.
- The inversion scan. OpenAPI points parent → member; Kotlin
declares member → parent (
data class Dog(...) : Pet). Memoization makes build order arbitrary, so membership must be known BEFORE any construction: one document-wide scan overcomponents.schemas, memoized per document viaWeakMap, mapping member refName → claims. Claims store the parent's realRefName; the consumer derives the display name viacontext.toModelContentSettings— never a copy of the naming policy, never a fabricated refName. - Parent side: an empty-body value (
toString()returns''→ the bodyless idiom) carrying@JsonTypeInfo(use = NAME, include = PROPERTY, property = "<discriminator>")+@JsonSubTypes(Type(value = Dog::class, name = "dog"), …)via theKtAnnotatedprotocol — mirrorannotationsANDdescriptionon the projection by reference. Each subtype entry holds the walked member ref SNIPPET, so member models build and imports stitch through the normal chain. Tags:discriminator.mappingkey pointing at the member, else the member's refName (the OpenAPI default). - Member side: inline
: Petsupertype clause rendered by the parameter-list value (after the parens), and the discriminator property OMITTED — filtered BEFORE the property walk, or its enum schema synthesizes a spurious sibling. Same package by the export-path policy satisfies Kotlin's sealed same-package rule. - Jackson vs kotlinx flavor: tags are parent-side
(
@JsonSubTypes), so members carry no tag annotation and one member may hold different tags under different parents (the kotlinx one-@SerialName-per-class conflict rule does not apply). - Runtime gotcha (probed): a raw
writeValueAsString(list)erases the element type and silently DROPS the tags; concrete roots,writerFor(type), and full-generic types all write them — Spring MVC uses the typed path, so real consumers are fine. Test round-trips with a typed writer. allOf-composed members (the spec's canonical idiom: shared fields on a base, members compose viaallOf) qualify WITHOUT special handling — core resolvesallOfat parse time (mergeIntersection), so the member peeks as a flat object and the base's fields flatten into each data class (verified through the pipeline 2026-08-04). Flattening is the right Kotlin target: the sealed interface is the polymorphism seam, not class inheritance.- Inline unions (stage 2, shipped): a qualifying union ANYWHERE —
component property, operation body/response/header/parameter —
synthesizes its sealed parent under its stackTrail name (combinator
frames
oneOf/anyOf/allOfare structural and elided; aparameters/<index>position resolves to the parameter NAME via a WeakMap document scan — the trail itself cannot carry names, it doubles as a JSON Pointer whereparametersis an array) into the MODELS package (toModelExportPath— ONE placement policy for EVERY synthesized declaration; caller's-file placement breaks'reuse'-across-files for cross-package peers). The scan deep-walks components AND operations AND webhooks (headers and thecontentalternative included); synthesized claims carry the union NODE soensureSealedParentlets WHOEVER needs the name first declare it via the claim registry. Derivability is ONE shared non-throwing probe (toSynthesizedNameOrNull) across scan/render/members — underivable roots degrade consistently to pre-synthesis behavior; the object/enum sites deliberately keep the THROWING derivation (no honest fallback exists for structure). One member may implement several sealed parents (parent-side tags). - Not yet built: undiscriminated unions (stage 3 —
enrichment-asserted hints / Jackson
Id.DEDUCTION) and the INVERTED swagger-style pattern (discriminator on the base, nooneOf, membership implied byallOfback-references — no union node exists, so no sealed interface).
9. Kotlin pitfalls
| Symptom | Fix |
|---|---|
@Serializable/KDoc missing |
Mirror annotations/description getters on the projection |
| Annotation silently dropped | Real KtAnnotation instances, not strings |
segment 'x' is not a valid package name part |
Fix the export-path policy — packages validate, never sanitize |
| Import mid-file / duplicated | register / annotation packageName, never templates |
String?? |
Type expression owns the single ? |
Unknown Kotlin entity type |
Use the seven Kotlin factories, not TS kinds |
Empty data class throws |
Shape dispatch must route empty objects to typealias |
| TDZ crash at module load | Break base↔router↔projection cycles with a leaf module (peekSchema pattern) |
| Nondeterministic output | No module state; config via enrichments; memoize document scans in WeakMap |
data class X glued to Map<String, Any?> |
The normalized-insert type/declaration gap — §8b, don't hack around it |
Union renders Any/JsonNode where a sealed type was expected |
Qualifying predicate failed — check discriminator presence, all-ref members, per-member surviving parameters (§8c) |
Member missing : Parent / spurious discriminator enum sibling |
Membership scan not consulted before construction, or omission applied after the property walk (§8c) |
| Sealed round-trip loses the wire tag at runtime | Jackson root-list type erasure — serialize via a typed writer; generated code is correct (§8c) |
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 — 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.
Files (skmtc)
-
appendix.md 37.6 KB
# Appendix — generated API reference > Generated from framework source by > `deno run --allow-read --allow-write --allow-env --allow-run=deno,git .scripts/generate-skill-api-appendix.ts` > (from `deno/`). **Authoritative** for signatures, fields, and doc > comments — trust it instead of re-reading package source. JSDoc > `@example` blocks are stripped at generation. For a symbol not > listed here, `deno doc <file> <Symbol>` against the framework > source beats grepping it. ### `@skmtc/lang-kotlin` — the full exported surface Every export of the package, with exact constructor/argument shapes. The prose sections above explain how the pieces compose; this is the complete signature-level truth. ### `lang-kotlin/mod.ts` ```text @module @skmtc/lang-kotlin The Kotlin target-language layer for SKMTC generators. Status: production (Phase D + the Kotlin milestone arc complete). The full register/write path on the frozen language seam: the `kotlin` {@link Lang} object, `KtSnippet` (static `lang`, keyless registers), the register family (`register`/`defineAndRegister` + `KtRegisterArgs` — deliberately no `reExports` field), the projection-base veneers (model + OAS operation), `KtFile` (path-derived `package` directive, sorted imports, same-package suppression), `KtImport` (symbol-level, `as` aliases), `KtDefinition` (head + value rendering — the identifier renders its declaration head, the value renders everything after it via `KtParameterList` / `KtPrimaryConstructor` plus inline supertype clauses and ` {\n…\n}` bodies; the `KtAnnotated` / `KtDocumented` value protocols cover what renders above the declaration), the function-signature grammar (`KtFunctionSignature` / `KtFunctionParameter` — interface/class methods incl. KDoc, expression bodies, and parameter defaults), the identifier factories, `sanitizePropertyName` (hard keywords + backticks), and `toPackageName` (segment-validated). Grammar only: serialization flavor (kotlinx annotations) is generator policy — `@skmtc/gen-kotlin` is the proving generator. Architecture spec: `notes/lang/19-kotlin-architecture.md`. Template: `@skmtc/lang-typescript`. Defined in deno/lang-kotlin/src/createIdentifier.ts:84:14 function createClass(name: string, args: CreateKtIdentifierArgs): KtIdentifier Creates a concrete `class` identifier. Defined in deno/lang-kotlin/src/createIdentifier.ts:97:14 function createDataClass(name: string, args: CreateKtIdentifierArgs): KtIdentifier Creates a `data class` identifier. Defined in deno/lang-kotlin/src/createIdentifier.ts:110:14 function createEnumClass(name: string, args: CreateKtIdentifierArgs): KtIdentifier Creates an `enum class` identifier. Defined in deno/lang-kotlin/src/createIdentifier.ts:123:14 function createInterface(name: string, args: CreateKtIdentifierArgs): KtIdentifier Creates an `interface` identifier. Defined in deno/lang-kotlin/src/createIdentifier.ts:136:14 function createSealedInterface(name: string, args: CreateKtIdentifierArgs): KtIdentifier Creates a `sealed interface` identifier. Defined in deno/lang-kotlin/src/createIdentifier.ts:152:14 function createTypeAlias(name: string, args: CreateKtIdentifierArgs): KtIdentifier Creates a `typealias` identifier. Defined in deno/lang-kotlin/src/createIdentifier.ts:172:14 function createValue(name: string, args: CreateValueArgs): KtIdentifier Creates a top-level `val` identifier — Kotlin's distinctive file-scope value. Defined in deno/lang-kotlin/src/register.ts:102:14 function defineAndRegister<Value extends GeneratedValue>(context: GenerateContextType, {identifier, value, destinationPath, description}: KtDefineAndRegisterArgs<Value>): KtDefinition<Value> Build a {@link KtDefinition} from `value` and register it at `destinationPath`. The transform-level counterpart of `this.defineAndRegister` — a transform (a closure with no class) imports this directly; the language comes from the import, like everything else. No cache check — callers wrap with `context.findDefinition` first where dedup is wanted (the gen-msw accumulator pattern). Defined in deno/lang-kotlin/src/KtDocumented.ts:19:14 function isKtDocumented(value: unknown): value is KtDocumented Type guard for the {@link KtDocumented} protocol — narrows without casts. Defined in deno/lang-kotlin/src/createIdentifier.ts:53:14 function isKtEntityType(type: string): type is KtEntityType Type guard — whether an opaque `type` string is one this language knows. Defined in deno/lang-kotlin/src/KtIdentifier.ts:83:14 function isKtIdentifier(identifier: IdentifierBase): identifier is KtIdentifier Type guard narrowing a neutral {@link IdentifierBase} to a {@link KtIdentifier} — the cast-free way the renderer reads `type`. Defined in deno/lang-kotlin/src/hardKeywords.ts:54:14 function isKtIdentifierName(name: string): boolean Whether `name` is a plain (unescaped) Kotlin identifier: a letter or underscore followed by letters, digits, or underscores. Deliberately ASCII-conservative — Kotlin permits unicode letters, but anything outside ASCII gets the backtick treatment from {@link import('./sanitizePropertyName.ts').sanitizePropertyName}, which is always safe. Note this is a SYNTAX check only — a hard keyword like `object` matches the regex but still needs escaping. Callers check {@link ktHardKeywords} separately. Defined in deno/lang-kotlin/src/register.ts:49:14 function register(context: GenerateContextType, args: KtRegisterArgs & { destinationPath: string; }): void Kotlin's register function — the single implementation behind {@link KtSnippet.register} and the projection-base veneers. Converts the concise import form into {@link KtImport} objects, creates the destination {@link KtFile} on first write (caller-side creation — the language is right here), and hands pure data to the neutral `context.register`. No `generatorId`, no `Lang` object: the language is this module. Throws when the destination file exists but was created by another language — a cross-language collision is a misconfiguration, refused loudly rather than mixing Kotlin content into a foreign file. Defined in deno/lang-kotlin/src/sanitizePropertyName.ts:27:14 function sanitizePropertyName(propertyName: string): string Makes a property name safe as a Kotlin declaration name. - A plain identifier that is not a hard keyword → returned as-is. - A hard keyword (`object`, `val`, …) or a syntactically invalid name (`user name`, `1st`) → backtick-escaped (``object``). - A name that backticks cannot save (contains `.`, `;`, `:`, `/`, `\`, `[`, `]`, `<`, `>`, a backtick, or a newline — illegal on the JVM even escaped) → throws. Generators camelCase wire names before calling this, so reaching the throw means a naming policy bug, not a schema problem. Renames are deliberately NOT this function's job: wire-name mismatches are handled gen-side via serialization annotations (`@SerialName`); this function only guarantees the chosen name parses. The two compose — a backticked keyword (``object``) still equals its wire name, so it needs no annotation. Returns a plain `string` (unlike the TypeScript version's key-value fallback — Kotlin has no quoted-property syntax to fall back to). Defined in deno/lang-kotlin/src/KtAnnotation.ts:149:14 function toKtAnnotations(value: unknown): KtAnnotations Collect a value's {@link KtAnnotated} protocol field into a {@link KtAnnotations} block — empty when the value carries none, so the caller renders it without a guard. Defined in deno/lang-kotlin/src/createIdentifier.ts:188:14 function toKtEntityType(type: string): KtEntityType Narrow the engine's opaque `type: string` (from `Lang.toIdentifier`'s neutral args) to this language's {@link KtEntityType} — cast-free, via {@link isKtEntityType}. Throws on a type outside the vocabulary, a loud signal that an identifier built for another language (or with a typo'd type) reached the Kotlin renderer. (Unlike TypeScript there is no keyword map here — the declaration keywords live on {@link import('./KtIdentifier.ts').KtIdentifier}'s declaration-head render, the only place they are used.) Defined in deno/lang-kotlin/src/toKtModelProjectionBase.ts:32:14 function toKtModelProjectionBase<EnrichmentType = undefined>(config: ModelProjectionBaseConfig<EnrichmentType, KtIdentifierType>) Build a Kotlin model projection base class. Thin veneer over core's `toModelProjectionBase`: passes `KtSnippet` as the base (the hierarchy is language-bound at its root) and adds the register ergonomics core deliberately doesn't define — typed with Kotlin's concise vocabulary, which core can't name: - `register(args)` — own-file: `destinationPath` is always this projection's `settings.exportPath` (the foundation rule; never a fallback). - `registerInto(destinationPath, args)` — the explicit cross-file path. Both delegate to this package's register function — never `super.register` (lang-base members are type-erased on core's factory result). The config is core's `ModelProjectionBaseConfig` parameterized over {@link KtIdentifierType} (so `toIdentifierType` returns the `type` bound to `KtEntityType`). The base is the factory's first argument, not a config field. The companion operation veneer {@link toKtOasOperationProjectionBase} has arrived (the OAS veneer now exists, driven by gen-kotlin-sdk's Response models). Defined in deno/lang-kotlin/src/toKtOasOperationProjectionBase.ts:31:14 function toKtOasOperationProjectionBase<EnrichmentType = undefined>(config: OasOperationProjectionBaseConfig<EnrichmentType, KtIdentifierType>) Build a Kotlin OAS operation projection base class — the first operation-keyed Kotlin projection family (demanded by gen-kotlin-sdk's Response models, arc note `32` §C4; earlier operation generators were accumulator-style and didn't need one). Thin veneer over core's `toOasOperationProjectionBase`: passes `KtSnippet` as the base (the hierarchy is language-bound at its root) and adds the register ergonomics core deliberately doesn't define — typed with Kotlin's concise vocabulary, which core can't name: - `register(args)` — own-file: `destinationPath` is always this projection's `settings.exportPath` (the foundation rule; never a fallback). - `registerInto(destinationPath, args)` — the explicit cross-file path. Both delegate to this package's register function — never `super.register` (lang-base members are type-erased on core's factory result). The config is core's `OasOperationProjectionBaseConfig` parameterized over {@link KtIdentifierType} (so `toIdentifierType` returns the `type` bound to `KtEntityType`). The base is the factory's first argument, not a config field. Defined in deno/lang-kotlin/src/toPackageName.ts:27:14 function toPackageName(path: string, packages?: ModulePackage[]): string Derives the `package` directive from a Kotlin file's export path — the segments after the `@/` root ARE the package directories (Kotlin's package-=-folder convention; `client.json#settings.basePath` points at the Gradle source root, e.g. `./app/src/main/kotlin`). - `@/com/example/api/User.generated.kt` → `'com.example.api'` - `@/User.kt` → `''` (the default package — legal, discouraged; {@link import('./KtFile.ts').KtFile} renders no `package` line) 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 directories are the segments after the OWNING package's `rootPath` — pass `packages` and the longest matching `rootPath` prefix is stripped before derivation. Each `rootPath` is that module's Gradle source root, exactly as `basePath` is in single-package mode. Throws when any directory segment is not a plain Kotlin identifier or is a hard keyword — a generator authored a path that cannot be a package (`@/my-models/User.kt`). Loud beats backticked package names. This is Kotlin's `validateDestinationPath`. Defined in deno/lang-kotlin/src/withDescription.ts:20:14 function withDescription(value: Stringable, {description}: WithDescriptionArgs): string Wraps a value with a KDoc comment when a description is provided — Kotlin's block-comment syntax is identical to JSDoc, so this mirrors the lang-typescript helper. A multi-line description renders as a block with `*` margins — the inline form would leave continuation lines without a comment margin, so a formatter eats a content-leading `*` as decoration and intra-line indentation is lost. Defined in deno/lang-kotlin/mod.ts:34:14 const fileExtensions: ".kt"[] File extensions this language package renders. Defined in deno/lang-kotlin/src/KtLang.ts:16:14 const kotlin: Lang The Kotlin {@link Lang} — carried as the static `lang` on {@link import('./KtSnippet.ts').KtSnippet} and inherited by every class built on it. Its only consumers are the engine's Drivers, which read it off the projection class (`projection.lang`) ephemerally at each use site. The engine reaches Kotlin only through these neutral factories; it never names `KtFile` / `KtDefinition` / `KtImport` itself. Defined in deno/lang-kotlin/src/hardKeywords.ts:9:14 const ktHardKeywords: ReadonlySet<string> Kotlin's hard keywords — names that can never be used as identifiers without backtick escaping. Soft keywords (`value`, `data`, `field`, `import`, …) and modifier keywords (`sealed`, `internal`, …) are NOT in this set: they are legal identifiers in Kotlin and need no escape. Source: the Kotlin language spec's "hard keywords" list (pinned in `notes/lang/19-kotlin-architecture.md`). Defined in deno/lang-kotlin/mod.ts:31:14 const langId: "kotlin" The language id this package targets. Defined in deno/lang-kotlin/src/KtAnnotation.ts:72:1 class KtAnnotation Renders a Kotlin annotation: `@Serializable`, `@SerialName("user_id")`. A registering LEAF entity (the `TsHeritage` precedent): given a `packageName` it registers its own class's import into `destinationPath`, so the annotation and its import are one statement that cannot drift apart. It registers unconditionally — a same-package annotation's import is dropped centrally by `KtFile`'s render-time suppression, so callers need no such check. Container renderers ({@link KtAnnotations}, `KtParameterList`, `KtFunctionSignature`) stay pure and just interpolate. NOT a `KtSnippet` subclass: `KtDefinition` imports {@link toKtAnnotations} from this module, so extending `KtSnippet` would close a load-time module cycle (`KtSnippet → KtLang → KtDefinition → KtAnnotation → KtSnippet`). It calls this package's {@link register} function directly instead — the same write path `KtSnippet.register` delegates to. Generic grammar only — args are {@link Stringable} and pre-quoted by the caller. WHICH annotation to emit is generator policy (the serialization seam lives in `gen-kotlin`); this package only renders what it is handed. constructor({context, name, args, target, packageName, destinationPath}: KtAnnotationArgs) name: string args: Stringable[] target: KtAnnotationTarget | undefined toString(): string Defined in deno/lang-kotlin/src/KtAnnotation.ts:132:1 class KtAnnotations A class-level annotation block — zero or more {@link KtAnnotation}s, rendered one per line above a declaration head. Empty renders the empty string, so it interpolates unconditionally (`${annotations}${head}${value}`). constructor(annotations: KtAnnotation[]) annotations: KtAnnotation[] toString(): string Defined in deno/lang-kotlin/src/KtDefinition.ts:57:1 class KtDefinition<Value extends GeneratedValue = GeneratedValue> extends DefinitionBase<Value> Kotlin's concrete {@link DefinitionBase}: renders the identifier's declaration head and the value, each rendering itself. - Assignment kinds (`typealias`, `val`) — `${head} = ${value}`; the value is the right-hand-side expression. - Declaration kinds (`class`, `data-class`, `enum-class`, `interface`, `sealed-interface`) — `${head}${value}`; the value renders everything after the head: a {@link import('./KtParameterList.ts').KtParameterList} (parentheses included), a {@link import('./KtPrimaryConstructor.ts').KtPrimaryConstructor} (modifiers + the explicit `constructor` keyword), plus inline ` : A, B` supertype clauses and ` {\n…\n}` braced bodies — plain Kotlin syntax carries no grammar rule worth a class. A value that renders nothing yields the bodyless idiom (`sealed interface Animal`, `class Marker`) — the value decides its own form; the definition never inspects it. (Raw whole-file content — static template files — is a FILE fact, not a definition: it flows through the register vocabulary's `custom` field onto `FileBase.custom`, with no identifier involved.) Two protocols remain on the value because they render OUTSIDE the head+value line: class-level annotations ({@link import('./KtAnnotation.ts').KtAnnotated}, one per line above the declaration — the neutral `Lang.toDefinition` signature has no annotations slot) and KDoc ({@link import('./KtDocumented.ts').KtDocumented}, above the annotations; an explicit constructor `description` wins). (A foreign identifier is refused earlier, at the `Lang.toDefinition` boundary in `KtLang`; the constructor only accepts a {@link KtIdentifier}.) Visibility is the identifier's fact, rendered in its head (`private data class …` — see {@link KtIdentifier.toString}). The neutral `noExport` flag the Drivers pass is folded into a restricted identifier copy at the `KtLang.toDefinition` boundary, so this class never sees it. constructor({context, identifier, value, description}: KtDefinitionArgs<Value>) identifier: KtIdentifier Narrows the inherited neutral `identifier` to the concrete Kotlin subclass (the constructor only accepts a {@link KtIdentifier}). description: string | undefined override toString(): string Defined in deno/lang-kotlin/src/KtFile.ts:43:1 class KtFile extends CodeFileBase Kotlin's concrete code file. Owns the definition + import collections and their merge policy (the neutral {@link CodeFileBase} declares the contract) and adds the Kotlin-specific pieces: - the `package` directive, DERIVED from the file's own path via {@link toPackageName} — the export path encodes the package (`@/com/example/api/User.generated.kt` → `package com.example.api`); `client.json#settings.basePath` points at the Gradle source root. - same-package import suppression: any import whose resolved package equals this file's package is omitted at render (same-package symbols need no import in Kotlin — the structural analog of TsFile's intra-package `@/` normalization). In particular the Driver's cross-file peer imports vanish when peers share the package. - the rendering arrangement: the neutral `custom` slot ({@link FileBase.custom}) first — leading content above the `package` directive (e.g. a generated-file attribution banner; only comments may precede `package`), the same placement `TsFile` gives it — then the package directive, imports (one statement per symbol, sorted alphabetically — not style, which is the consumer's formatter's job, but registration-order independence: the rendered bytes are what snapshot tests and byte-identical regression gates compare), then definitions joined by blank lines. `reExports` cannot arrive by construction — Kotlin's concise register vocabulary has no `reExports` field and the Driver never registers them — so rendering ignores the (always empty) neutral map. constructor({path, settings}: KtFileArgs) packageName: string The `package` this file declares — derived from `path`, with the owning package's `rootPath` stripped first in multi-package mode (`settings.packages`). settings: ClientSettings | undefined Threaded into package derivation and same-package suppression. definitions: Map<string, DefinitionBase> Definitions keyed by identifier name (first write wins; Kotlin has no declaration merging). imports: Map<string, ImportBase> Imports keyed by {@link ImportBase.mergeKey}. reExports: Map<string, ReExportBase> Re-exports keyed by {@link ReExportBase.mergeKey} — Kotlin registers none; kept for the neutral contract. override addDefinition(definition: DefinitionBase): void override addImports(incoming: ImportBase[]): void override addReExports(incoming: ReExportBase[]): void override findDefinitions(query?: { name?: string; type?: KtEntityType; }): DefinitionBase[] | undefined override toString(): string Defined in deno/lang-kotlin/src/KtFunctionSignature.ts:30:1 class KtFunctionParameter Renders a Kotlin function parameter: `@PathVariable("id") id: String`, `verbose: Boolean?`. Grammar only — WHICH annotations to attach (`@PathVariable`, `@RequestParam`, `@RequestBody`) is generator policy riding {@link import('./KtAnnotation.ts').KtAnnotation}. Distinct from {@link import('./KtParameterList.ts').KtParameterArgs} (primary-constructor parameters, `val` prefix + defaults) — the two are different productions. constructor({name, type, nullable, defaultValue, annotations}: KtFunctionParameterArgs) name: string type: Stringable nullable: boolean | undefined defaultValue: Stringable | undefined annotations: KtAnnotation[] | undefined toString(): string Defined in deno/lang-kotlin/src/KtFunctionSignature.ts:93:1 class KtFunctionSignature Renders a Kotlin method signature — the building block of an `interface` or `class` body: ```kotlin @GetMapping("/users/{id}") fun getUsersId(@PathVariable("id") id: String, @RequestParam("verbose") verbose: Boolean?): User ``` Indented one level (it lives inside a declaration body); parameters on one line (formatting is the consumer's formatter's job). Abstract by default; an expression `body` renders the delegation form (` = …` — block bodies deliberately unsupported). Optional KDoc `description` above the annotations and per-parameter `= default`. Grammar only — no `suspend`; the mapping annotations are generator policy. constructor({name, parameters, returnType, annotations, description, body}: KtFunctionSignatureArgs) name: string parameters: KtFunctionParameter[] returnType: Stringable | undefined annotations: KtAnnotation[] | undefined description: string | undefined body: Stringable | undefined toString(): string Defined in deno/lang-kotlin/src/KtIdentifier.ts:50:1 class KtIdentifier extends IdentifierBase Kotlin's concrete {@link IdentifierBase}: adds the typed `type` ({@link KtEntityType}) and owns the rendering of its own declaration head — `data class User`, `enum class Status`, `val timeout: Long` — via {@link toString}. {@link import('./KtDefinition.ts').KtDefinition} interpolates the head and adds only the kind's arrangement (parameter parens, supertype clause, braced body); the keyword itself lives here, next to the identifier that determines it. The engine holds it as the neutral `IdentifierBase` (reading only `.name`); `KtDefinition` narrows back to `KtIdentifier` via {@link isKtIdentifier} to read `type`. constructor({name, typeName, exported, type}: KtIdentifierArgs) type: KtEntityType Per-language declaration type — drives the declaration head and shell. override toString(): string The declaration head: `[private ]<keyword> <name>[: <typeName>]`. Overrides the neutral base's bare-name render — in Kotlin the keyword belongs to the identifier's kind, so the identifier renders it, and visibility is the identifier's own `exported` fact (the pattern core's `IdentifierBase.exported` doc anticipates: each language renders it its own way — Go via name casing, Kotlin via this prefix). Kotlin defaults to `public`, so `exported` renders as nothing when true and `private ` (file-local) when false — keyword only to restrict. Generators splicing a name into generated code should keep using `.name` / `Inserted.toName()`, which this override does not touch. Defined in deno/lang-kotlin/src/KtImport.ts:46:1 class KtImport extends ImportBase Kotlin's concrete {@link ImportBase}: one module's worth of imported symbols. The `module` takes two forms, distinguished by shape: - a dotted package (`'kotlinx.serialization'`) — external libraries, generator-registered; - an `@/`-export-path (`'@/com/example/api/User.generated.kt'`) — project files; this is what the Driver passes for cross-file peer imports. The path form resolves to its package via {@link toPackageName} at render time ({@link resolvedPackage}); {@link import('./KtFile.ts').KtFile} uses the same resolution to suppress same-package imports (same-package symbols need no import in Kotlin). Rendering is one statement per symbol — Kotlin has no brace grouping: `import kotlinx.serialization.Serializable`. constructor(module: string, specifiers: KtImportSpecifier[]) module: string specifiers: KtImportSpecifier[] static fromConcise(module: string, names: KtImportNameArg[]): KtImport Build from the concise `{ module: KtImportNameArg[] }` form a generator passes. static fromIdentifier(module: string, identifier: IdentifierBase): KtImport Build the import of a single {@link IdentifierBase} from `module` — the cross-file import a Driver registers when a generator references a peer's Definition. The identifier's `type` is ignored: every Kotlin import has the same form, so the neutral `IdentifierBase` (which the engine holds) is all that's needed — no narrowing. resolvedPackage(packages?: ModulePackage[]): string The package this import's symbols come from: a path-form module (contains `/`) derives via {@link toPackageName}; a dotted package passes through. In multi-package output the owning {@link import('./KtFile.ts').KtFile} passes its `settings.packages` so a path under another module's `rootPath` resolves to that module's real dotted package — Kotlin imports are always packages; `moduleName` has no Kotlin meaning. override mergeKey(): string override merge(other: ImportBase): ImportBase toLines(packages?: ModulePackage[]): string[] One `import pkg.Name[ as Alias]` line per specifier. override toString(): string The packages-less fallback render — correct for dotted-package modules and for path-form modules in single-package projects. The canonical render path is {@link import('./KtFile.ts').KtFile}'s `toString`, which calls {@link toLines} with the project's `settings.packages` so a path-form module under another module's `rootPath` resolves to that module's real package; the neutral `ImportBase` signature gives this override no way to receive them, so multi-package resolution is only correct through `KtFile`. Defined in deno/lang-kotlin/src/KtParameterList.ts:38:1 class KtParameterList Renders a Kotlin primary-constructor parameter list, parentheses included — `(\n val id: String\n)`. The value owns its delimiters: a data-class value interpolates this directly (`${parameters}${supertypeClause}`), and the definition renders only `${head}${value}`. Each parameter is a `val` property (public by default). No trailing comma after the last parameter — a cosmetic non-decision: trailing commas are the consumer's formatter's territory (ktfmt adds them, ktlint can enforce either way) and SKMTC renders unformatted by design. constructor(parameters: KtParameterArgs[]) parameters: KtParameterArgs[] toString(): string Defined in deno/lang-kotlin/src/KtPrimaryConstructor.ts:32:1 class KtPrimaryConstructor A primary constructor — the clause after the class name, owned by the VALUE: a class value composes `${primaryConstructor}${supertypeClause}${body}` and the definition renders `${head}${value}`. Without modifiers this is just the parameter list; the class exists for the modifier + explicit `constructor`-keyword grammar rule. constructor({parameters, modifiers}: KtPrimaryConstructorArgs) parameters: Stringable modifiers: Stringable | undefined toString(): string Defined in deno/lang-kotlin/src/KtSnippet.ts:29:1 class KtSnippet extends SnippetBase The Kotlin snippet base — where the Kotlin language enters the SKMTC DSL class hierarchy. `@skmtc/core`'s {@link SnippetBase} is language-blind and needs no `generatorKey` to register — the key stays an optional constructor arg used for attribution (gen-maps) only. `KtSnippet` extends it and carries the Kotlin {@link Lang} as a static only: Drivers read it off the projection class (`projection.lang`), pre-construction, inherited through every class built on this base (including projection classes from this package's `toKtModelProjectionBase`). No instance slot — the register methods delegate to this package's register functions, which name the Kotlin classes directly. `destinationPath` is always explicit on snippets: a snippet has no file or settings of its own, so the parent passes the target path through the constructor. Own-file defaulting exists only on projections, in the projection-base veneers. static lang: Lang The language every class built on this base renders into — the neutral {@link Lang}. Drivers read it off the projection class (`projection.lang`) pre-construction; a projection-base veneer carries the Kotlin identifier tightening ({@link import('./KtIdentifier.ts').KtIdentifierType}) through its config's `toIdentifierType` rather than through this static. register(args: KtRegisterArgs & { destinationPath: string; }): void Register imports / definitions into the file at `destinationPath`, typed by Kotlin's concise vocabulary — keyless: no `generatorId` resolution, no `generatorKey` requirement. defineAndRegister(args: KtDefineAndRegisterArgs<Value>): KtDefinition<Value> Build a {@link KtDefinition} from `value` and register it at `destinationPath`. Defined in deno/lang-kotlin/src/createIdentifier.ts:59:1 type CreateKtIdentifierArgs = { exported?: boolean; } Options shared by the identifier factories — every field optional, so the common case stays `createDataClass(name)`. Defined in deno/lang-kotlin/src/createIdentifier.ts:68:1 type CreateValueArgs = { typeName?: string; exported?: boolean; } Options for {@link createValue} — the only factory with a `typeName` slot (the `val x: T = …` annotation). Defined in deno/lang-kotlin/src/KtAnnotation.ts:107:1 type KtAnnotated = { annotations: KtAnnotation[]; } The protocol by which a Definition's VALUE supplies class-level annotations to {@link import('./KtDefinition.ts').KtDefinition}. `Lang.toDefinition`'s neutral signature has no annotations slot, so annotations ride on the value: a generator's projection sets an `annotations` field, and `KtDefinition.toString()` collects it via {@link toKtAnnotations} and renders the annotations above the declaration head. Defined in deno/lang-kotlin/src/KtAnnotation.ts:23:1 type KtAnnotationArgs = { context: GenerateContextType; name: string; args?: Stringable[]; target?: KtAnnotationTarget; packageName?: string; destinationPath: string; } Constructor arguments for {@link KtAnnotation}. Defined in deno/lang-kotlin/src/KtAnnotation.ts:8:1 type KtAnnotationTarget = "field" | "get" | "set" | "param" | "property" | "receiver" | "setparam" | "delegate" | "file" | "all" Kotlin's annotation use-site targets — the `field:` in `@field:JsonAnySetter`. Grammar-level, so the set is closed and owned here; WHICH target a generator picks is its policy. Defined in deno/lang-kotlin/src/register.ts:77:1 type KtDefineAndRegisterArgs<Value extends GeneratedValue> = { identifier: KtIdentifier; value: Value; destinationPath: string; description?: string; } Arguments for {@link defineAndRegister}. Defined in deno/lang-kotlin/src/KtDefinition.ts:10:1 type KtDefinitionArgs<Value extends GeneratedValue> = { context: GenerateContextType; identifier: KtIdentifier; value: Value; description?: string; } Constructor arguments for {@link KtDefinition}. Defined in deno/lang-kotlin/src/KtDocumented.ts:12:1 type KtDocumented = { description?: string; } The protocol by which a Definition's VALUE supplies a KDoc description to {@link import('./KtDefinition.ts').KtDefinition} — a value-carried protocol (like `KtAnnotated`) because it renders ABOVE the head+value line and the neutral `Lang.toDefinition` call the Drivers make carries no description; threading it through core would change every language's output at once. The lang renders the KDoc; WHAT the text is (a schema `description`, an operation `summary`) is generator policy. An explicit `description` passed to `KtDefinition`'s constructor wins over the protocol. Defined in deno/lang-kotlin/src/createIdentifier.ts:32:1 type KtEntityType = (typeof ktEntityTypes)[number] Kotlin's declaration-type vocabulary — the typed `type` this package writes onto its {@link KtIdentifier} and the discriminator its renderers narrow against. - `'class'` — a concrete `class Name(…) { … }` declaration (the generated-controller idiom; the value composes its `KtPrimaryConstructor` and braced body). - `'data-class'` — a `data class Name(…)` DTO container. - `'enum-class'` — an `enum class Name { … }` declaration. - `'interface'` — an `interface Name { … }` declaration (the Spring "interfaceOnly" idiom — abstract method signatures the consumer implements). - `'sealed-interface'` — a `sealed interface Name` (the `oneOf` idiom). - `'typealias'` — a `typealias Name = …` declaration. - `'val'` — a top-level `val Name = …` assignment (Kotlin's distinctive file-scope value, illegal in C#/PHP/Java). Every kind names a REAL declaration — an identifier that never appears in code is a contradiction. Raw whole-file content (static template files) is a FILE fact, not a definition: it goes through the register vocabulary's `custom` field (`FileBase.custom`), with no identifier involved. Unlike TypeScript, the type does NOT drive import form — every Kotlin import is `import pkg.Name`. It drives only the declaration shell. Deferred kinds (`object`, `fun`, `var`, `const-val`) arrive with the milestones that need them; {@link toKtEntityType} throwing on them is the desired behavior until then. Defined in deno/lang-kotlin/src/KtFile.ts:10:1 type KtFileArgs = { path: string; settings: ClientSettings | undefined; } Constructor arguments for {@link KtFile} — the `Lang.createFile` shape. Defined in deno/lang-kotlin/src/KtFunctionSignature.ts:4:1 type KtFunctionParameterArgs = { name: string; type: Stringable; nullable?: boolean; defaultValue?: Stringable; annotations?: KtAnnotation[]; } A single parameter of a Kotlin function signature. Defined in deno/lang-kotlin/src/KtFunctionSignature.ts:59:1 type KtFunctionSignatureArgs = { name: string; parameters: KtFunctionParameterArgs[]; returnType?: Stringable; annotations?: KtAnnotation[]; description?: string; body?: Stringable; } Constructor arguments for {@link KtFunctionSignature}. Defined in deno/lang-kotlin/src/KtIdentifier.ts:18:1 type KtIdentifierArgs = IdentifierBaseArgs & { type: KtEntityType; } Constructor arguments for {@link KtIdentifier} — the neutral {@link IdentifierBaseArgs} plus this language's typed `type`. Defined in deno/lang-kotlin/src/KtIdentifier.ts:12:1 type KtIdentifierType = IdentifierType & { type: KtEntityType; } The non-`name` parts of a Kotlin identifier — the tightened `IdentifierType` a Kotlin projection's `toIdentifierType` returns. Core's neutral {@link IdentifierType} carries an opaque `type: string`; this alias narrows it to {@link KtEntityType}, the named form generators annotate with. The engine spreads it into `lang.toIdentifier({ name, ...identifierType })`. Defined in deno/lang-kotlin/src/KtImport.ts:11:1 type KtImportNameArg = string | { name: string; alias?: string; } The concise import form a Kotlin generator passes to `register` — `'Serializable'` or `{ name: 'User', alias: 'UserModel' }` (Kotlin supports symbol-level aliases via `as`, unlike Java). Owned by this package: the concise vocabulary is language-specific; the neutral engine never sees it. No `type` tag — Kotlin has no type-only imports. Defined in deno/lang-kotlin/src/KtImport.ts:14:1 type KtImportSpecifier = { name: string; alias?: string; } A single imported symbol on a {@link KtImport}. Defined in deno/lang-kotlin/src/KtParameterList.ts:4:1 type KtParameterArgs = { name: string; type: Stringable; nullable?: boolean; defaultValue?: Stringable; annotations?: KtAnnotation[]; visibility?: "private" | "protected" | "internal"; } A single primary-constructor parameter of a Kotlin class. Defined in deno/lang-kotlin/src/KtPrimaryConstructor.ts:5:1 type KtPrimaryConstructorArgs = { parameters: Stringable; modifiers?: Stringable; } Arguments for {@link KtPrimaryConstructor}. Defined in deno/lang-kotlin/src/register.ts:17:1 type KtRegisterArgs = { imports?: Record<string, KtImportNameArg[]>; definitions?: (DefinitionBase | undefined)[]; custom?: Stringable; } Kotlin's concise register vocabulary — the generator-facing form. Owned by this package: each language defines its own concise args type exposing only what the language supports. Kotlin has no re-exports, so there is deliberately no `reExports` field — a generator trying to register one is a compile-time error, not a runtime no-op (the note-16 Go example, realized). Defined in deno/lang-kotlin/src/withDescription.ts:5:1 type WithDescriptionArgs = { description?: string; } Arguments for {@link withDescription}. ``` -
SKILL.md 17.8 KB
--- name: skmtc-lang-kotlin version: 0.11.0 description: > The Kotlin target-language layer for Skmtc generators (@skmtc/lang-kotlin): base factories, KtSnippet, the seven entity kinds, packages-from-paths imports, the head+value render model, KtAnnotation and the composition classes, sanitization and @SerialName placement, plus the current-API worked example (the shipped gen-kotlin-* packages are API-stale — do not copy their call shapes). Use ALONGSIDE skmtc-generator whenever a generator emits Kotlin. Headings mirror skmtc-lang-typescript. metadata: internal: true --- # The Kotlin layer (@skmtc/lang-kotlin) Read `skmtc-generator` first. > **Drift warning.** The API of record is the workspace > `skmtc/deno/lang-kotlin` and its tests. The shipped `gen-kotlin-*` > generators predate the 0.9.11 flattening: they call > `new KtAnnotation('Name', [args])` positionally and import > `isKtAnnotated`/`isKtSupertyped` (no longer exported; supertype > clauses now render inline in the value). Clone their **structure** > only; take call shapes from THIS skill's example (§8), which is pinned > byte-for-byte against the engine by > `lang-kotlin/src/skill-example.test.ts`. ## 1. Declaring the language Same pattern as TypeScript — the import graph declares it. Two factories: `toKtModelProjectionBase`, `toKtOasOperationProjectionBase`; snippets extend `KtSnippet`. ```ts export const KtModelBase = toKtModelProjectionBase<EnrichmentSchema>({ id: denoJson.name, toEnrichmentSchema, toIdentifierName({ refName, enrichments }) { return enrichments?.subject?.name ?? capitalize(camelCase(refName)) }, // Kotlin's identifier KIND depends on schema shape → may read context // (runs only on cache-miss; the NAME stays pure): toIdentifierType(refName, context) { return { type: toShape(context, peekSchema(context, refName)) } }, toExportPath({ refName, enrichments }) { const name = enrichments?.subject?.name ?? capitalize(camelCase(refName)) return join('@', ...enrichments.generator.basePackage.split('.'), `${name}.generated.kt`) } }) ``` The export path's directory segments ARE the Kotlin package (§4). Make `basePackage` a **required generator-scope enrichment** with no default; validate segments with `isKtIdentifierName` + `ktHardKeywords`. Put the shape dispatch (object+props → `data-class`; string+enums → `enum-class`; qualifying discriminated union → `sealed-interface`; else `typealias`) in ONE deterministic function read by both `toIdentifierType` and the constructor, so kind and value can't disagree. ## 2. Register shapes — Kotlin differences Same three shapes as TS (projection own-file / `registerInto` / snippet with required `destinationPath`), plus `defineAndRegister` (no cache check; no `noExport` — visibility is the identifier's fact: pass `exported: false` to the factory). Compile-time differences: **no `reExports` field** (Kotlin has none) and **no `type` tag on imports** (no type-only imports). `custom` renders above the `package` directive. ## 3. Identifier kinds Kotlin output has seven entity kinds (`KtEntityType`): `class`, `data-class`, `enum-class`, `interface`, `sealed-interface`, `typealias`, `val` — factories `createClass`, `createDataClass`, `createEnumClass`, `createInterface`, `createSealedInterface`, `createTypeAlias`, `createValue` (only `createValue` takes `typeName`; `exported: false` renders `private `). Deferred kinds (`object`, `fun`, `var`) make `toKtEntityType` throw — deliberately loud. Kind does NOT affect import form. The engine's `type` is an opaque string: `isKtEntityType` narrows it to the vocabulary above, and `isKtIdentifier` narrows a neutral `IdentifierBase` back to `KtIdentifier`. ## 4. Emitted-import rules - **Packages from paths**: `@/com/example/api/User.generated.kt` → `package com.example.api`. Segments are validated, never sanitized — a keyword or invalid segment **throws** (fix the path policy). - One `import pkg.Name` per symbol (no brace grouping), `as` aliases, rendered **sorted** (determinism, not style). - **Same-package suppression is central**: register imports unconditionally; `KtFile` drops same-package ones at render. - Importing from the default package throws (root-level artifact referenced from a packaged one = path-policy bug). ## 5. Render model: head + value Assignment kinds (`typealias`, `val`): `<head> = <value>`. Declaration kinds: `<head><value>` — the value renders everything after the name: parameter list (parens included), inline ` : Parent` clauses, ` { … }` bodies; an empty value yields the bodyless idiom (`sealed interface Animal`). Two things ride on value-carried protocols (the neutral Lang signature has no slot for them): `KtAnnotated` (`annotations: KtAnnotation[]`, strict — string look-alikes are silently dropped) and `KtDocumented` (`description`, guard `isKtDocumented`, rendered as KDoc above the annotations). **The mirroring gotcha**: the Driver wraps the PROJECTION as the definition's value, so mirror both onto the projection — canon is **reference assignment in the constructor** (`this.annotations = this.value.annotations` — one array, two names; never copy) — or class-level annotations and KDoc silently vanish. ## 6. Composition classes (current API) - `KtParameterList(parameters)` — parens included; each `{ name, type: Stringable, nullable?, defaultValue?, annotations?, visibility? }` renders as an indented `val`, annotations one per line. - `KtPrimaryConstructor({ parameters, modifiers? })` — modifiers force the explicit `constructor` keyword. - `KtFunctionSignature({ name, parameters, returnType?, annotations?, body? })` — abstract by default, expression body only. - `KtAnnotation({ context, name, args?, target?, packageName?, destinationPath })` — a **registering leaf**: with `packageName` it registers its own import (register unconditionally; suppression handles same-package). `args` are pre-quoted (`['"user_id"']`, `['Foo::class']`). `target` is the use-site target (`KtAnnotationTarget`: `field`/`get`/`set`/…) rendered as `@field:JsonAnySetter` — the imported symbol stays the bare `name`. Needed on a constructor `val`, which is parameter/property/field/ getter at once: Jackson's catch-all pair is `@field:JsonAnySetter` + `@get:JsonAnyGetter`, and without targets both annotations land on the parameter, where Jackson never looks. (Shipped in lang-kotlin 0.10.0, 2026-08-04 — pre-`target` versions cannot express use-site targets at all.) - `withDescription(value, { description })` — KDoc. ## 7. Sanitization and @SerialName `sanitizePropertyName(name)`: plain → unchanged; hard keyword or invalid → **backticked**; JVM-unescapable characters → **throws** ("rename + @SerialName"). Renames are NOT its job — serialization annotations handle wire-name mismatches, and the two compose: decide the annotation by comparing the *unescaped* chosen name with the wire key (`` `object` `` needs no @SerialName; `user_id`→`userId` does). Only the 28 hard keywords escape; soft/modifier keywords (`value`, `data`, `sealed`) are legal identifiers. Canonical pairing: `sanitizePropertyName(camelCase(key))`. ## 8. Worked example — kotlinx data class (current API, engine-pinned) Per-property loop inside the data-class value snippet: ```ts const propertyName = sanitizePropertyName(camelCase(key)) const annotations: KtAnnotation[] = [] if (propertyName.replaceAll('`', '') !== key) { annotations.push(new KtAnnotation({ context, destinationPath, name: 'SerialName', packageName: 'kotlinx.serialization', args: [`"${key}"`] })) } parameters.push({ name: propertyName, type: value, // the SNIPPET — never `${value}` defaultValue: isRequired ? undefined : 'null', annotations }) // this.parameterList = new KtParameterList(parameters) // class-level: this.annotations = [new KtAnnotation({ context, // destinationPath, name: 'Serializable', packageName: 'kotlinx.serialization' })] // projection mirrors by REFERENCE: this.annotations = this.value.annotations ``` Renders (verified byte-for-byte through the engine): ```kotlin 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 ) ``` The type expression is the **single owner** of `?`; the parameter layer only adds `= null`. Passing `` `${value}` `` instead of the snippet strands its registered imports and synthesized siblings — the file breaks far from the cause. Serialization flavor is confined to the value files (data class / enum entries / sealed interface): a Jackson/Moshi sibling generator swaps annotation construction there only. ## 8b. Normalized models — KNOWN ENGINE GAP (verified 2026-08-03) The head+value model means a Kotlin value renders differently in TYPE position (`Map<String, Any?>`) and DECLARATION position (a parameter list). Core's generic `insertNormalizedModel` glues the identifier head to the value's type-position `toString()` — which for an inline OBJECT schema renders invalid Kotlin: `data class XMap<String, Any?>`. The engine gap is real, but the SOLUTION does not wait for it — every mature Kotlin generator solves inline objects the same way: - **Named `$ref` schemas are unaffected** — `insertModel` and the ref path work correctly. - An inline NON-object schema normalizes fine as a `typealias`-shaped value. - **An inline object is SYNTHESIZED as a named sibling declaration** and referenced by name — the retired gen-kotlin-kotlinx pattern (`KtObjectValue`, skmtc-generators history at `2c24a65`) rebuilt WITHOUT its naming-hint threading: the name derives from the schema's own `stackTrail` (`toSynthesizedName.ts` in gen-kotlin-jackson — anchor on the `components`/`paths` landmark frames, never absolute indices; classification is POSITIONAL: `properties` consumes the following frame as a literal key, so a property named `properties`/`schema`/`items` can never be mistaken for trail structure), so every construction path — including peers arriving through `insertNormalizedModel` — lands on the same name with NO parameter added to the router contract. Names are NOT collision-free: claim via the document-wide registry (`claimSynthesizedName`, gen-kotlin-jackson `synthesizedNames.ts`) BEFORE declaring — it throws per-item when the name collides with a component-derived class name (Kotlin's redeclaration scope is the PACKAGE, not the file) or with a different position's claim (camelCase-convergent keys), and returns reuse for a same-position re-walk. On `'declare'`, `defineAndRegister` the sibling and render only the NAME. Type position then always holds a name or a map — never property structure. This is also how OpenAPI Generator solves it (inline schemas hoisted to named components before generation). Widening a known shape to `Map<String, Any?>` is capitulation, not a solution — it discards the type the schema gave you. Inline string enums synthesize the same way (`enum class` sibling). - Never fabricate a refName or drive the peer's identity statics to force a declaration into existence — that is the two-doors rule (skmtc-generator §4), and the result couples you to the peer's private snippet shape. ## 8c. Discriminated unions — sealed interfaces (shipped 2026-08-04) Kotlin has no union type; a QUALIFYING discriminated union becomes a `sealed interface` (gen-kotlin-jackson is the worked example; ancestry: the retired kotlinx machinery at skmtc-generators `2c24a65`, stale call shapes). Predicate (`shape.ts isSealedUnion`, part of the shape dispatch): discriminated, ≥2 members, every member a `$ref` to an object-with-properties, and every member keeps ≥1 parameter AFTER discriminator omission. Everything else renders the honest wire type (`JsonNode` for Jackson), never `Any`. - **The inversion scan.** OpenAPI points parent → member; Kotlin declares member → parent (`data class Dog(...) : Pet`). Memoization makes build order arbitrary, so membership must be known BEFORE any construction: one document-wide scan over `components.schemas`, memoized per document via `WeakMap`, mapping member refName → claims. Claims store the parent's real `RefName`; the consumer derives the display name via `context.toModelContentSettings` — never a copy of the naming policy, never a fabricated refName. - **Parent side**: an empty-body value (`toString()` returns `''` → the bodyless idiom) carrying `@JsonTypeInfo(use = NAME, include = PROPERTY, property = "<discriminator>")` + `@JsonSubTypes(Type(value = Dog::class, name = "dog"), …)` via the `KtAnnotated` protocol — mirror `annotations` AND `description` on the projection by reference. Each subtype entry holds the walked member ref SNIPPET, so member models build and imports stitch through the normal chain. Tags: `discriminator.mapping` key pointing at the member, else the member's refName (the OpenAPI default). - **Member side**: inline ` : Pet` supertype clause rendered by the parameter-list value (after the parens), and the discriminator property OMITTED — filtered BEFORE the property walk, or its enum schema synthesizes a spurious sibling. Same package by the export-path policy satisfies Kotlin's sealed same-package rule. - **Jackson vs kotlinx flavor**: tags are parent-side (`@JsonSubTypes`), so members carry no tag annotation and one member may hold different tags under different parents (the kotlinx one-`@SerialName`-per-class conflict rule does not apply). - **Runtime gotcha (probed)**: a raw `writeValueAsString(list)` erases the element type and silently DROPS the tags; concrete roots, `writerFor(type)`, and full-generic types all write them — Spring MVC uses the typed path, so real consumers are fine. Test round-trips with a typed writer. - `allOf`-composed members (the spec's canonical idiom: shared fields on a base, members compose via `allOf`) qualify WITHOUT special handling — core resolves `allOf` at parse time (`mergeIntersection`), so the member peeks as a flat object and the base's fields flatten into each data class (verified through the pipeline 2026-08-04). Flattening is the right Kotlin target: the sealed interface is the polymorphism seam, not class inheritance. - **Inline unions (stage 2, shipped)**: a qualifying union ANYWHERE — component property, operation body/response/header/parameter — synthesizes its sealed parent under its stackTrail name (combinator frames `oneOf`/`anyOf`/`allOf` are structural and elided; a `parameters/<index>` position resolves to the parameter NAME via a WeakMap document scan — the trail itself cannot carry names, it doubles as a JSON Pointer where `parameters` is an array) into the MODELS package (`toModelExportPath` — ONE placement policy for EVERY synthesized declaration; caller's-file placement breaks `'reuse'`-across-files for cross-package peers). The scan deep-walks components AND operations AND webhooks (headers and the `content` alternative included); synthesized claims carry the union NODE so `ensureSealedParent` lets WHOEVER needs the name first declare it via the claim registry. Derivability is ONE shared non-throwing probe (`toSynthesizedNameOrNull`) across scan/render/members — underivable roots degrade consistently to pre-synthesis behavior; the object/enum sites deliberately keep the THROWING derivation (no honest fallback exists for structure). One member may implement several sealed parents (parent-side tags). - Not yet built: undiscriminated unions (stage 3 — enrichment-asserted hints / Jackson `Id.DEDUCTION`) and the INVERTED swagger-style pattern (discriminator on the base, no `oneOf`, membership implied by `allOf` back-references — no union node exists, so no sealed interface). ## 9. Kotlin pitfalls | Symptom | Fix | |---|---| | `@Serializable`/KDoc missing | Mirror `annotations`/`description` getters on the projection | | Annotation silently dropped | Real `KtAnnotation` instances, not strings | | `segment 'x' is not a valid package name part` | Fix the export-path policy — packages validate, never sanitize | | Import mid-file / duplicated | `register` / annotation `packageName`, never templates | | `String??` | Type expression owns the single `?` | | `Unknown Kotlin entity type` | Use the seven Kotlin factories, not TS kinds | | Empty `data class` throws | Shape dispatch must route empty objects to `typealias` | | TDZ crash at module load | Break base↔router↔projection cycles with a leaf module (`peekSchema` pattern) | | Nondeterministic output | No module state; config via enrichments; memoize document scans in `WeakMap` | | `data class X` glued to `Map<String, Any?>` | The normalized-insert type/declaration gap — §8b, don't hack around it | | Union renders `Any`/`JsonNode` where a sealed type was expected | Qualifying predicate failed — check discriminator presence, all-ref members, per-member surviving parameters (§8c) | | Member missing ` : Parent` / spurious discriminator enum sibling | Membership scan not consulted before construction, or omission applied after the property walk (§8c) | | Sealed round-trip loses the wire tag at runtime | Jackson root-list type erasure — serialize via a typed writer; generated code is correct (§8c) | <!-- api-appendix:begin — GENERATED, do not edit by hand --> ## Appendix — generated API reference The full `deno doc` surface for the packages this skill covers lives in [`appendix.md`](appendix.md), in this skill's directory — generated from framework source — 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. <!-- api-appendix:end -->
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.