{"slug":"skmtc-model","title":"skmtc-model","summary":"The model-generator shape for Skmtc: one definition per component schema, built by copying the shipped SKELETON package and filling its SLOT markers with the target library's syntax. Covers the edge cases every model generator must survive — refs, recursion, optional vs nullable,","platform":"Claude","tags":[],"authorName":"LLM Mart","authorSlug":"llm-mart","score":0,"source":"github","price":null,"verified":false,"createdAt":"2026-09-18T13:27:36.369351Z","repo":{"url":"https://github.com/skmtc/skmtc","stars":19,"forks":0,"license":"Apache-2.0","updatedAt":"2026-09-18T16:24:15Z"},"bodyHtml":"<hr>\n<h2>name: skmtc-model\nversion: 0.1.4\ndescription: &gt;\nThe model-generator shape for Skmtc: one definition per component\nschema, built by copying the shipped SKELETON package and filling its\nSLOT markers with the target library's syntax. Covers the edge cases\nevery model generator must survive — refs, recursion, optional vs\nnullable, additionalProperties, enums, readOnly/writeOnly. Use when\nauthoring or editing a generator that maps schemas to a\nvalidator/schema/type library (\"write a gen-</h2>\n<h1>Model generators: fill the skeleton</h1>\n<p>A <strong>model generator</strong> turns each component schema (<code>refName</code>) into one\ndefinition in one file: entry → projection → schema-type router → one\nsnippet class per schema type. That structure is invariant across\ntarget libraries — only naming policy and per-type syntax vary. So do\nnot write the structure: copy it.</p>\n<h2>1. The method</h2>\n<p>The <code>skeleton/</code> directory next to this file is a complete, compiling,\nengine-tested model generator that renders a placeholder syntax\n(<code>m.object({...})</code>). Author by transplant, not from scratch:</p>\n<ol>\n<li><strong>Copy</strong> <code>skeleton/</code> to your package location; run\n<code>deno test --allow-env --allow-sys --allow-read</code> — 6 green tests\nprove the machinery before you touch anything.</li>\n<li><strong>Rename</strong>: <code>name</code> in <code>deno.json</code>; <code>MyLib</code> → <code>YourLib</code> in class\nnames and filenames; <code>myLibEntry</code> → <code>yourLibEntry</code>; then\n<code>src/lib.ts</code> — <code>LIB_MODULE</code> (emitted module specifier) and <code>LIB</code>\n(imported symbol).</li>\n<li><strong>Fill the slots</strong> (§2), smallest first: scalars → string/enum →\narray/object → union → lazy/recursion annotation.</li>\n<li><strong>Re-pin the test</strong>: update the pinned strings in <code>mod.test.ts</code> to\nyour target syntax. The structural assertions (files exist, shared\nrefs dedup to ONE definition, import headers stitched, recursion\nannotated) must pass UNCHANGED — if one breaks, you broke machinery,\nnot syntax.</li>\n</ol>\n<p>Every slot is a <code>// SLOT(name):</code> comment. Everything outside a SLOT is\nengine machinery — modifying it is almost always a mistake.</p>\n<h2>2. The slots</h2>\n<table>\n<thead>\n<tr>\n<th>Slot</th>\n<th>File</th>\n<th>Decision</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code>library</code></td>\n<td><code>src/lib.ts</code></td>\n<td>emitted module + symbol, single point</td>\n</tr>\n<tr>\n<td><code>naming</code>, <code>identifier-kind</code>, <code>export-path</code></td>\n<td><code>src/base.ts</code></td>\n<td>identity policy (from <code>refName</code> ONLY)</td>\n</tr>\n<tr>\n<td><code>string</code>, <code>string-constraints</code></td>\n<td><code>src/MyLibString.ts</code></td>\n<td>string / enum / literal syntax; formats, min/maxLength</td>\n</tr>\n<tr>\n<td><code>number</code>, <code>integer</code>, <code>boolean</code>, <code>unknown</code>, <code>void</code></td>\n<td><code>src/MyLibScalars.ts</code></td>\n<td>scalar syntax; numeric constraints</td>\n</tr>\n<tr>\n<td><code>array</code></td>\n<td><code>src/MyLibArray.ts</code></td>\n<td>list syntax</td>\n</tr>\n<tr>\n<td><code>object-properties</code>, <code>object-intersection</code>, <code>object-empty</code>, <code>visibility</code></td>\n<td><code>src/MyLibObject.ts</code></td>\n<td>object syntax; properties+record composition; readOnly/writeOnly policy</td>\n</tr>\n<tr>\n<td><code>record</code></td>\n<td><code>src/MyLibObject.ts</code></td>\n<td>additionalProperties map syntax</td>\n</tr>\n<tr>\n<td><code>union</code></td>\n<td><code>src/MyLibUnion.ts</code></td>\n<td>oneOf/anyOf; discriminated form</td>\n</tr>\n<tr>\n<td><code>lazy</code></td>\n<td><code>src/MyLibRef.ts</code></td>\n<td>deferred-reference form for cycles</td>\n</tr>\n<tr>\n<td><code>recursion-annotation</code></td>\n<td><code>src/MyLibProjection.ts</code></td>\n<td>type annotation breaking circular inference</td>\n</tr>\n<tr>\n<td><code>modifiers</code></td>\n<td><code>src/modifiers.ts</code></td>\n<td>optional/nullable syntax and wrap order</td>\n</tr>\n<tr>\n<td><code>enrichments</code></td>\n<td><code>src/enrichments.ts</code></td>\n<td>config seam (default: opt-out)</td>\n</tr>\n</tbody>\n</table>\n<h2>3. Edge cases the skeleton already handles — keep them working</h2>\n<ul>\n<li><strong>Refs are names, never expansions.</strong> <code>MyLibRef</code> puts only the peer's\nNAME in the value tree; the <code>ModelDriver</code> resolves the definition\n(cache hit → reuse, miss → construct) and stitches the cross-file\nimport. Inline-expanding a ref, or hand-writing its import, is how\nshared models duplicate.</li>\n<li><strong>Recursion is a protocol, not a special case.</strong> A back-reference to\na model still open on the build stack (<code>context.modelDepth</code> &gt; 0)\nrenders via SLOT(lazy) and bumps the depth; the projection then sees\n<code>&gt; 1</code> and sets <code>settings.identifier.typeName</code> (SLOT\nrecursion-annotation) so the emitted <code>export const</code> doesn't die of\ncircular inference (TS7022/7024). Self-recursion only — mutual\nrecursion is not detected.</li>\n<li><strong>Optional and nullable are different axes.</strong> <code>required</code> comes from\nthe PARENT object's <code>required</code> list and flows into each property\nleaf's <code>modifiers</code>; <code>nullable</code> sits on the node itself. Both render\nexactly once, in <code>applyModifiers</code>, at the leaf — no other owner, and\nnever while building stored fields.</li>\n<li><strong>additionalProperties</strong> → the record path; <code>true</code>/empty schema →\nthe unknown fallback; properties + additionalProperties together →\nSLOT(object-intersection).</li>\n<li><strong>An object schema has four forms — and position can change the\nrender.</strong> Properties-only, record-only (additionalProperties), both,\nempty: every place an object renders must survive all four. In\nTypeScript one expression serves both type and declaration positions\n(<code>z.object({...})</code>, <code>.and(z.record(...))</code> for both-forms), so the\nobject SLOTs compose freely. In a head+value language (Kotlin) the\ntwo positions DIVERGE, and a position-blind <code>toString()</code> cannot serve\nboth (compiler-verified 2026-08-04, kotlin-debug rig): properties-only\ndeclares as a <code>data class</code> parameter list, and in type position must\nrender a NAME — synthesize the named sibling declaration and\nreference it (name derived from the schema's own <code>stackTrail</code>, no\nnaming param threaded through the router; collisions policed by a\ndocument-wide claim registry that throws per-item, since the name\nshares a PACKAGE with every component class — gen-kotlin-jackson\n<code>toSynthesizedName.ts</code> + <code>synthesizedNames.ts</code>; a parameter list in\ntype position parses as a function type and fails, and widening to\n<code>Map&lt;String, Any?&gt;</code> discards the type — capitulation, not a\nsolution); record-only and empty\nmust not take a data-class head at all (<code>data class X()</code> is illegal —\ntheir declaration kind is <code>typealias</code>); both-forms has a declaration\nform (data class plus a <code>@field:JsonAnySetter @get:JsonAnyGetter</code>\ncatch-all map property) but no anonymous type form. Decide the\nidentifier KIND and the value together from the same schema guards\n(gen-kotlin-jackson <code>shape.ts</code> is the worked example) — never from\nthe name alone, and never by making one <code>toString()</code> answer both\npositions.</li>\n<li><strong>A discriminated union may be a DECLARATION, not an expression.</strong>\nIn TypeScript SLOT(union) is one expression\n(<code>z.discriminatedUnion(\"type\", [...])</code>). In a language without union\ntypes (Kotlin) a qualifying discriminated union becomes a named\n<code>sealed</code> declaration, and the member models must declare the\nsupertype — a member may be BUILT before its union is ever seen, so\nmembership comes from a document-wide scan (parent → member\ninversion, WeakMap-memoized) consulted at member construction, never\nfrom the union's own walk. Non-qualifying unions render the honest\nwire type (<code>JsonNode</code>), not <code>Any</code>. Full pattern: the Kotlin lang\nskill §8c.</li>\n<li><strong>Property keys</strong> go through <code>handleKey</code> — <code>'first-name'</code> renders\nquoted; never assume keys are identifiers.</li>\n<li><strong>Visibility.</strong> <code>readOnly</code>/<code>writeOnly</code> are captured per property in\n<code>MyLibObjectProperties.visibility</code>. Default policy ignores them; if\nthe target needs them, annotate the value (e.g. <code>.readonly()</code>) or\nemit request/response variants via <code>variant</code> threading — decide at\nSLOT(visibility). Caller options are the third route: declare the\nsecond type parameter of <code>toTsModelProjectionBase</code>, let the calling\ngenerator pass <code>{ options }</code> on the insert, and fold them into the name.</li>\n<li><strong>Unknown never throws.</strong> Untyped schemas route to the unknown\nfallback so one odd schema can't kill the subject. <code>custom</code> values\npass through untouched.</li>\n<li><strong>TypeSystem contracts.</strong> Each snippet class carries the fields peers\nrely on (<code>TypeSystemString</code> needs <code>format</code> + <code>enums</code>; objects expose\n<code>objectProperties</code>/<code>recordProperties</code>). Add fields freely; remove\nnone — removal breaks <code>insertNormalizedModel</code> consumers and fails the\n<code>SchemaToValueFn</code> check.</li>\n</ul>\n<h2>4. Verify</h2>\n<p>The shipped <code>mod.test.ts</code> runs the REAL pipeline (<code>toArtifacts</code>) over a\nfixture with an enum, an array-of-ref, a shared ref (×2 → one\ndefinition), optional + nullable, a record, and a self-recursive model.\nIt is your regression gate: green before you start, green after every\nslot. Read failures in this order: import header first (a missing\nimport means a string swallowed a snippet), then the body, then\n<code>deno lint</code> (the <code>skmtc/*</code> rules are wired in <code>deno.json</code>).</p>\n<h2>5. Model-generator pitfalls</h2>\n<table>\n<thead>\n<tr>\n<th>Symptom</th>\n<th>Fix</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Shared model duplicated per consumer</td>\n<td>A ref was rendered/expanded instead of flowing through <code>MyLibRef</code></td>\n</tr>\n<tr>\n<td>Stack overflow on recursive schema</td>\n<td>The <code>modelDepth</code> branch in <code>MyLibRef</code> was removed or bypassed</td>\n</tr>\n<tr>\n<td>Emitted file dies of TS7022/7024</td>\n<td>SLOT(recursion-annotation) not set for the target</td>\n</tr>\n<tr>\n<td><code>.optional()</code> doubled or missing</td>\n<td>Modifiers applied outside <code>applyModifiers</code>, or a second owner added</td>\n</tr>\n<tr>\n<td>Enum with <code>null</code> member renders <code>'null'</code></td>\n<td>Keep the <code>literal()</code> null-guard from <code>MyLibString</code></td>\n</tr>\n<tr>\n<td>Peer generator can't consume yours</td>\n<td><code>schemaToValueFn</code>/<code>createIdentifier</code> statics or TypeSystem contract fields removed</td>\n</tr>\n<tr>\n<td>Lint fires <code>no-template-imports</code>/<code>no-adhoc-tostring</code></td>\n<td>Target syntax leaked outside a <code>toString()</code> body — move it into the SLOT</td>\n</tr>\n<tr>\n<td><code>data class NameMap&lt;String, Any?&gt;</code> (head glued to a type) in output</td>\n<td>Declaration kind and value were decided separately — see the four-forms bullet in §3; kind+value must come from the same schema guards</td>\n</tr>\n</tbody>\n</table>\n<h2>6. Boundaries</h2>\n<p>Engine semantics (the one law, memoization, enrichments, variants,\nnaming rules) live in <strong>skmtc-generator</strong> — read it first. TS-layer\nspecifics (register shapes, identifier kinds, import machinery,\n<code>List</code>/<code>FunctionParameter</code>) live in <strong>skmtc-lang-typescript</strong>. This\nskill owns only the model SHAPE. The skeleton is TypeScript-emitting;\nfor a Kotlin model generator, keep this skill's shape and edge-case\nrules but take call shapes from the Kotlin lang skill (no Kotlin\nskeleton yet). Operation generators are a different shape — load\n<code>skmtc-operation</code>; accumulators are covered by neither (clone\n<code>gen-msw</code>/<code>gen-express</code> per skmtc-generator §2).</p>\n","files":[{"path":"skeleton/deno.json","sizeBytes":613,"isText":true},{"path":"skeleton/deno.lock","sizeBytes":4779,"isText":false},{"path":"skeleton/mod.test.ts","sizeBytes":5723,"isText":true},{"path":"skeleton/mod.ts","sizeBytes":158,"isText":true},{"path":"skeleton/src/base.ts","sizeBytes":1146,"isText":true},{"path":"skeleton/src/enrichments.ts","sizeBytes":405,"isText":true},{"path":"skeleton/src/lib.ts","sizeBytes":388,"isText":true},{"path":"skeleton/src/modifiers.ts","sizeBytes":627,"isText":true},{"path":"skeleton/src/mod.ts","sizeBytes":430,"isText":true},{"path":"skeleton/src/MyLibArray.ts","sizeBytes":1517,"isText":true},{"path":"skeleton/src/MyLibObject.ts","sizeBytes":5729,"isText":true},{"path":"skeleton/src/MyLibProjection.ts","sizeBytes":2294,"isText":true},{"path":"skeleton/src/MyLibRef.ts","sizeBytes":3055,"isText":true},{"path":"skeleton/src/MyLibScalars.ts","sizeBytes":3828,"isText":true},{"path":"skeleton/src/MyLibString.ts","sizeBytes":1880,"isText":true},{"path":"skeleton/src/MyLib.ts","sizeBytes":3721,"isText":true},{"path":"skeleton/src/MyLibUnion.ts","sizeBytes":1916,"isText":true},{"path":"SKILL.md","sizeBytes":10339,"isText":true}],"reviewScore":null,"reviewSummary":null,"trust":{"provenance":"trusted-source-unreviewed","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow.","bodySource":null},"bodyLocked":false,"purchaseUrl":null,"sourceUrl":null,"report":{"provenance":"trusted-source-unreviewed","screen":{"ran":true,"outcome":"clean","suspicious":0,"notes":0,"hiddenCharacters":false},"virusScan":{"engine":"clamav","status":"clean","scannedAt":"2026-09-18T13:28:28.643675Z","sha256":"3439410D4050FF55DB9594CBD86950EAB4B1CF1BAB1CA034EA42879466A383CE","sizeBytes":21104},"review":null,"source":{"repositoryUrl":"https://github.com/skmtc/skmtc","path":"deno/docs/skills/skmtc-model","license":"Apache-2.0","commit":"e3abffcbfd1109c694656742d3a49491dbf86b92","subtreeSha":"12A7D19933AE2A52EA70913FD96E0B63B5D5423187EEE4FB71DB0D417B7709E3","lastSyncedAt":"2026-09-27T19:30:42.792127Z"},"reviewedAt":"2026-09-18T13:31:02.846087Z","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow."},"install":[{"target":"skills-cli","command":"npx skills add https://github.com/skmtc/skmtc/tree/main/deno/docs/skills/skmtc-model"},{"target":"claude-code","command":"claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install skmtc-skmtc@llmmart"},{"target":"git","command":"git clone https://github.com/skmtc/skmtc.git"}]}