@contractkit/plugin-typescript 0.33.2 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.turbo/turbo-build$colon$ci.log +4 -4
  2. package/.turbo/turbo-test$colon$ci.log +26 -25
  3. package/CHANGELOG.md +273 -0
  4. package/dist/codegen-contract.d.ts +22 -6
  5. package/dist/codegen-contract.d.ts.map +1 -1
  6. package/dist/codegen-mcp.d.ts.map +1 -1
  7. package/dist/codegen-operation.d.ts.map +1 -1
  8. package/dist/codegen-plain-types.d.ts.map +1 -1
  9. package/dist/codegen-revive.d.ts +38 -4
  10. package/dist/codegen-revive.d.ts.map +1 -1
  11. package/dist/codegen-sdk.d.ts +2 -0
  12. package/dist/codegen-sdk.d.ts.map +1 -1
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +413 -202
  16. package/dist/index.js.map +1 -1
  17. package/dist/path-utils.d.ts +9 -0
  18. package/dist/path-utils.d.ts.map +1 -1
  19. package/dist/ts-render.d.ts +14 -0
  20. package/dist/ts-render.d.ts.map +1 -1
  21. package/package.json +3 -2
  22. package/src/codegen-contract.ts +84 -68
  23. package/src/codegen-mcp.ts +12 -11
  24. package/src/codegen-operation.ts +32 -19
  25. package/src/codegen-plain-types.ts +15 -8
  26. package/src/codegen-revive.ts +140 -20
  27. package/src/codegen-sdk.ts +322 -63
  28. package/src/index.ts +46 -4
  29. package/src/path-utils.ts +10 -0
  30. package/src/ts-render.ts +26 -0
  31. package/tests/codegen-contract.test.ts +87 -32
  32. package/tests/codegen-mcp.test.ts +31 -0
  33. package/tests/codegen-operation.test.ts +68 -8
  34. package/tests/codegen-plain-types.test.ts +13 -5
  35. package/tests/codegen-sdk.test.ts +265 -10
  36. package/tests/codegen-server.test.ts +43 -1
  37. package/tests/helpers.ts +7 -2
  38. package/tests/pipeline.test.ts +51 -4
  39. package/tests/ts-render.test.ts +29 -0
@@ -1,5 +1,14 @@
1
1
  import type { ContractRootNode, OpRootNode } from '@contractkit/core';
2
2
  export declare const TEMPLATE_VAR_RE: RegExp;
3
+ /** Global-flagged twin of {@link TEMPLATE_VAR_RE}, for finding every variable in a string. */
4
+ export declare const TEMPLATE_VAR_RE_G: RegExp;
5
+ /**
6
+ * Substitute `{key}` placeholders from `vars`.
7
+ *
8
+ * An unknown key is left in place rather than throwing, because the caller is mid-way through
9
+ * building a path and has better context for the complaint — see the check at the emit funnel in
10
+ * `index.ts`, which reports it against the file it would have been written to.
11
+ */
3
12
  export declare function resolveTemplate(template: string, vars: Record<string, string>): string;
4
13
  export declare function includesFilename(p: string): boolean;
5
14
  export declare function commonDir(files: string[], rootDir: string): string;
@@ -1 +1 @@
1
- {"version":3,"file":"path-utils.d.ts","sourceRoot":"","sources":["../src/path-utils.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAGtE,eAAO,MAAM,eAAe,QAAY,CAAC;AAmBzC,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAEtF;AAED,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAGnD;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAclE;AAID,wBAAgB,gBAAgB,CAC5B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,CAiBR;AAED,wBAAgB,sBAAsB,CAClC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,CAER;AAID,wBAAgB,iBAAiB,CAC7B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,GAAG,IAAI,CAkBf;AAED;;;;;;;;;GASG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAoBnH;AAED,wBAAgB,qBAAqB,CACjC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,GAAG,IAAI,CAef;AAED,wBAAgB,mBAAmB,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,EAAE,CAiBnG;AAED,wBAAgB,6BAA6B,CACzC,MAAM,EAAE,UAAU,EAAE,EACpB,YAAY,EAAE,gBAAgB,EAAE,EAChC,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,EAC5B,gBAAgB,GAAE,GAAG,CAAC,MAAM,CAAa,GAC1C,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CA0CpB"}
1
+ {"version":3,"file":"path-utils.d.ts","sourceRoot":"","sources":["../src/path-utils.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAGtE,eAAO,MAAM,eAAe,QAAY,CAAC;AAmBzC,8FAA8F;AAC9F,eAAO,MAAM,iBAAiB,QAAe,CAAC;AAE9C;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAEtF;AAED,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAGnD;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAclE;AAID,wBAAgB,gBAAgB,CAC5B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,CAiBR;AAED,wBAAgB,sBAAsB,CAClC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,CAER;AAID,wBAAgB,iBAAiB,CAC7B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,GAAG,IAAI,CAkBf;AAED;;;;;;;;;GASG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAoBnH;AAED,wBAAgB,qBAAqB,CACjC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,GAAG,IAAI,CAef;AAED,wBAAgB,mBAAmB,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,EAAE,CAiBnG;AAED,wBAAgB,6BAA6B,CACzC,MAAM,EAAE,UAAU,EAAE,EACpB,YAAY,EAAE,gBAAgB,EAAE,EAChC,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,EAC5B,gBAAgB,GAAE,GAAG,CAAC,MAAM,CAAa,GAC1C,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CA0CpB"}
@@ -9,6 +9,20 @@ export declare function quoteKey(name: string): string;
9
9
  export declare function escapeJsDocLines(text: string): string[];
10
10
  /** Escape a string for inclusion inside a single-quoted TypeScript string literal. */
11
11
  export declare function escapeSingleQuoted(s: string): string;
12
+ /**
13
+ * Render the markdown link back to the `.ck` declaration a generated construct came from, as
14
+ * `[label](./path/to/file.ck#L12)`. Returns the link only; callers supply the surrounding prose
15
+ * and comment prefix, since some sites emit it inside a JSDoc block and others inside a `//` line.
16
+ *
17
+ * The path is relative to the emitted file's own directory, so it resolves when the reader clicks
18
+ * it from wherever the file was written. `outPath` is optional because codegen can run without a
19
+ * destination (the prettier plugin, and several tests), in which case the source path is used
20
+ * as-is.
21
+ *
22
+ * Not `file://./path`: `file://` opens an authority component, so the `.` parses as the host and
23
+ * the link resolves to nothing. A plain relative path is the correct form.
24
+ */
25
+ export declare function sourceLink(label: string, outPath: string | undefined, sourceFile: string, line?: number): string;
12
26
  /** Convert an HTTP header name (e.g. `preference-applied`, `X-Request-ID`, `ETag`) to camelCase for use as a JS property. */
13
27
  export declare function headerNameToProperty(name: string): string;
14
28
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"ts-render.d.ts","sourceRoot":"","sources":["../src/ts-render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAA6B,MAAM,mBAAmB,CAAC;AAErF,iFAAiF;AACjF,eAAO,MAAM,oBAAoB,2GAA2G,CAAC;AAE7I,qFAAqF;AACrF,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED;;sFAEsF;AACtF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAEvD;AAED,sFAAsF;AACtF,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,6HAA6H;AAC7H,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQzD;AAID;;;;GAIG;AACH,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEjD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,gBAAgB,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CAoC9F;AAsDD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,EAAE,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CA2BlI;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,EAAE,gBAAgB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CA2BpI"}
1
+ {"version":3,"file":"ts-render.d.ts","sourceRoot":"","sources":["../src/ts-render.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAA6B,MAAM,mBAAmB,CAAC;AAErF,iFAAiF;AACjF,eAAO,MAAM,oBAAoB,2GAA2G,CAAC;AAE7I,qFAAqF;AACrF,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED;;sFAEsF;AACtF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAEvD;AAED,sFAAsF;AACtF,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,UAAU,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAIhH;AAED,6HAA6H;AAC7H,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQzD;AAID;;;;GAIG;AACH,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEjD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,gBAAgB,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CAoC9F;AA4DD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,EAAE,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CA2BlI;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,EAAE,gBAAgB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CA2BpI"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contractkit/plugin-typescript",
3
- "version": "0.33.2",
3
+ "version": "0.34.0",
4
4
  "description": "ContractKit built-in plugin: TypeScript codegen (SDK clients, Koa routers, Zod schemas, plain types)",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -27,9 +27,10 @@
27
27
  ".": "./dist/index.js"
28
28
  },
29
29
  "dependencies": {
30
- "@contractkit/core": "0.28.2"
30
+ "@contractkit/core": "0.29.0"
31
31
  },
32
32
  "devDependencies": {
33
+ "zod": "^4.3.6",
33
34
  "@repo/config-eslint": "0.3.1",
34
35
  "@repo/config-typescript": "0.1.0"
35
36
  },
@@ -21,10 +21,10 @@ import {
21
21
  computeModelsWithOutput as ckComputeModelsWithOutput,
22
22
  collectExternalOutputRefs as ckCollectExternalOutputRefs,
23
23
  } from '@contractkit/core';
24
- import { escapeJsDocLines } from './ts-render.js';
24
+ import { escapeJsDocLines, sourceLink } from './ts-render.js';
25
25
  import type { TsRenderTarget } from './ts-render.js';
26
26
  import { DECIMAL_IMPORT, DECIMAL_PRELUDE_LINES } from './decimal-runtime.js';
27
- import { renderReviveFunctions, reviveFnName, DECIMAL_COERCE_DECL } from './codegen-revive.js';
27
+ import { renderReviveFunctions, reviveFnName, coerceDeclsFor } from './codegen-revive.js';
28
28
 
29
29
  /**
30
30
  * Maps a ContractKit object mode to its Zod constructor name.
@@ -57,9 +57,9 @@ export interface ContractCodegenContext {
57
57
  /** If set, import JsonValue from this path instead of re-declaring it (avoids barrel re-export conflicts) */
58
58
  jsonValueImportPath?: string;
59
59
  /**
60
- * Runtime the emitted plain types describe. Only affects scalars whose TypeScript type differs
61
- * per runtime (`binary` `Buffer` on the server, `Blob` in the client). Ignored by
62
- * `generateContract`, whose Zod schemas are server-shaped by construction. Default `'client'`.
60
+ * Runtime the emitted types describe. Only affects scalars whose TypeScript type differs per
61
+ * runtime: `binary` is a `Buffer` on a Node server and a `Blob` in a fetch client, and
62
+ * `_ZodBinary` is generated to match. Default `'client'`.
63
63
  */
64
64
  target?: TsRenderTarget;
65
65
  /** Model names that carry a `decimal`, directly or transitively. */
@@ -128,8 +128,7 @@ function generateComments(model: ModelNode, outPath?: string): string[] {
128
128
  for (const l of escapeJsDocLines(model.description)) lines.push(` * ${l}`);
129
129
  }
130
130
 
131
- const relPath = outPath ? relative(dirname(outPath), model.loc.file) : model.loc.file;
132
- lines.push(` * generated from [${model.name}](file://./${relPath}#L${model.loc.line})`);
131
+ lines.push(` * generated from ${sourceLink(model.name, outPath, model.loc.file, model.loc.line)}`);
133
132
  lines.push('*/');
134
133
  return lines;
135
134
  }
@@ -137,8 +136,10 @@ function generateComments(model: ModelNode, outPath?: string): string[] {
137
136
  /**
138
137
  * Generate a TypeScript module containing Zod schemas for every model in `root`.
139
138
  *
140
- * Emits up to three schemas per model when visibility modifiers are present:
141
- * `ModelBase` (all fields), `Model` (read — no writeonly), `ModelInput` (write — no readonly).
139
+ * Emits two schemas per model when visibility modifiers are present: `Model` (read — no
140
+ * writeonly fields) and `ModelInput` (write — no readonly fields). Writeonly inheritance rides
141
+ * on the Input chain, since a child's `Input` extends its parent's `Input`, which already carries
142
+ * the parent's writeonly fields.
142
143
  *
143
144
  * @param root - The parsed contract root node.
144
145
  * @param context - Optional cross-file context for import resolution and Input/Output variant tracking.
@@ -187,7 +188,15 @@ export function generateContract(root: ContractRootNode, context?: ContractCodeg
187
188
  }
188
189
  lines.push('');
189
190
  if (needsBinary) {
190
- lines.push(`const _ZodBinary = z.custom<Buffer>((val) => Buffer.isBuffer(val), { error: 'Must be binary data' });`);
191
+ // The one scalar with no single correct runtime type, so it follows `target` here exactly
192
+ // as `renderTsScalar` does. An SDK type file reaching this through the shared
193
+ // `generateContract` used to emit `Buffer.isBuffer` into a browser client, whose scaffold
194
+ // declares no `@types/node` — the type did not resolve and the check could not run.
195
+ lines.push(
196
+ context?.target === 'server'
197
+ ? `const _ZodBinary = z.custom<Buffer>((val) => Buffer.isBuffer(val), { error: 'Must be binary data' });`
198
+ : `const _ZodBinary = z.custom<Blob>((val) => val instanceof Blob, { error: 'Must be binary data' });`,
199
+ );
191
200
  }
192
201
  if (needsDatetime) {
193
202
  lines.push(
@@ -210,9 +219,9 @@ export function generateContract(root: ContractRootNode, context?: ContractCodeg
210
219
  }
211
220
  if (needsBinary || needsDatetime || needsInterval || needsDecimal || needsJson) lines.push('');
212
221
 
213
- const modelsWithWriteonly = new Set(root.models.filter(m => m.fields.some(f => f.visibility === 'writeonly')).map(m => m.name));
214
222
  const modelMap = new Map(root.models.map(m => [m.name, m]));
215
223
 
224
+
216
225
  const reviveOpts =
217
226
  context?.emitRevivers && context.modelsWithDecimal
218
227
  ? { modelsWithDecimal: context.modelsWithDecimal, modelsWithOutput: allModelsWithOutput, modelMap }
@@ -220,7 +229,7 @@ export function generateContract(root: ContractRootNode, context?: ContractCodeg
220
229
 
221
230
  const bodyLines: string[] = [];
222
231
  for (const model of topoSortModels(root.models)) {
223
- bodyLines.push(...generateModel(model, context?.currentOutPath, allModelsWithInput, modelsWithWriteonly, modelMap, allModelsWithOutput));
232
+ bodyLines.push(...generateModel(model, context?.currentOutPath, allModelsWithInput, modelMap, allModelsWithOutput));
224
233
  if (reviveOpts) {
225
234
  const revivers = renderReviveFunctions(model, reviveOpts);
226
235
  if (revivers.length > 0) {
@@ -232,9 +241,10 @@ export function generateContract(root: ContractRootNode, context?: ContractCodeg
232
241
  }
233
242
 
234
243
  // Decided from the emitted revivers rather than from a predicate over the AST, so the
235
- // declaration and its uses cannot drift apart and leave an unused local behind.
236
- if (bodyLines.some(l => l.includes('__dec('))) {
237
- lines.push(...DECIMAL_COERCE_DECL);
244
+ // declarations and their uses cannot drift apart and leave an unused local behind.
245
+ const coerceDecls = coerceDeclsFor(bodyLines);
246
+ if (coerceDecls.length > 0) {
247
+ lines.push(...coerceDecls);
238
248
  lines.push('');
239
249
  }
240
250
  lines.push(...bodyLines);
@@ -282,7 +292,6 @@ function generateModel(
282
292
  model: ModelNode,
283
293
  outPath?: string,
284
294
  modelsWithInput?: Set<string>,
285
- modelsWithWriteonly?: Set<string>,
286
295
  modelMap?: Map<string, ModelNode>,
287
296
  modelsWithOutput?: Set<string>,
288
297
  ): string[] {
@@ -298,7 +307,7 @@ function generateModel(
298
307
  const needsInputSplit = effective.fields.some(f => f.visibility !== 'normal') || (modelsWithInput?.has(effective.name) ?? false);
299
308
 
300
309
  const lines = needsInputSplit
301
- ? generateThreeSchemaModel(effective, outPath, modelsWithInput, modelsWithWriteonly, modelMap)
310
+ ? generateThreeSchemaModel(effective, outPath, modelsWithInput, modelMap)
302
311
  : generateSimpleModel(effective, outPath);
303
312
 
304
313
  // Emit Output type alias when this model (transitively) has format(output=...)
@@ -418,7 +427,6 @@ function generateThreeSchemaModel(
418
427
  model: ModelNode,
419
428
  outPath?: string,
420
429
  modelsWithInput?: Set<string>,
421
- modelsWithWriteonly?: Set<string>,
422
430
  modelMap?: Map<string, ModelNode>,
423
431
  ): string[] {
424
432
  const lines: string[] = [];
@@ -429,25 +437,9 @@ function generateThreeSchemaModel(
429
437
  const wrapper = modeToWrapper(model.mode ?? 'strict');
430
438
 
431
439
  const allFields = model.fields;
432
- const hasWriteonly = allFields.some(f => f.visibility === 'writeonly');
433
440
 
434
441
  const bases = model.bases ?? [];
435
442
 
436
- // Base schema — all fields (used internally when a submodel extends this one).
437
- // Only needed when this model has writeonly fields; otherwise Base === Read.
438
- if (hasWriteonly) {
439
- const baseBody = renderFields(allFields, model.mode);
440
- if (bases.length > 0) {
441
- const { head, tail } = buildExtendChain(bases, b => (modelsWithWriteonly?.has(b) ? `${b}Base` : b));
442
- lines.push(`const ${name}Base = ${head}${tail}.extend({`);
443
- } else {
444
- lines.push(`const ${name}Base = ${wrapper}({`);
445
- }
446
- lines.push(...baseBody.map(l => ` ${l}`));
447
- lines.push(`});`);
448
- lines.push('');
449
- }
450
-
451
443
  // Read schema — omit writeonly fields; extends parent read schema
452
444
  const readFields = allFields.filter(f => f.visibility !== 'writeonly');
453
445
  const readBody = renderFields(readFields, model.mode);
@@ -555,12 +547,20 @@ function renderFieldsAsSnakeCase(fields: FieldNode[], defaultMode?: ObjectMode):
555
547
  });
556
548
  }
557
549
 
558
- function renderField(field: FieldNode, defaultMode?: ObjectMode): string[] {
559
- const lines: string[] = [];
560
- if (field.deprecated) lines.push('/** @deprecated */');
561
-
562
- let expr = renderType(field.type, undefined, defaultMode);
563
-
550
+ /**
551
+ * Append the modifier chain a declared field carries: nullability, then a default or optionality,
552
+ * then the description.
553
+ *
554
+ * Takes a structural subset of `FieldNode` rather than the node itself, so an `OpParamNode` — which
555
+ * is a `FieldNode` minus `visibility`, `deprecated` and `override` — can be rendered through the
556
+ * same path. That is what lets the router, the SDK and OpenAPI agree on what an inline `query:` or
557
+ * `headers:` field means.
558
+ *
559
+ * A default and `optional` are mutually exclusive on purpose: `.default()` already makes the input
560
+ * side optional, and adding `.optional()` on top would widen the *output* type to include
561
+ * `undefined`, which is exactly what a default exists to prevent.
562
+ */
563
+ export function applyFieldModifiers(expr: string, field: Pick<FieldNode, 'nullable' | 'default' | 'optional' | 'description'>): string {
564
564
  if (field.nullable) expr += '.nullable()';
565
565
  if (field.default !== undefined) {
566
566
  const dv = typeof field.default === 'string' ? `"${escapeString(field.default)}"` : String(field.default);
@@ -569,6 +569,16 @@ function renderField(field: FieldNode, defaultMode?: ObjectMode): string[] {
569
569
  expr += '.optional()';
570
570
  }
571
571
  if (field.description) expr += `.describe("${escapeString(field.description)}")`;
572
+ return expr;
573
+ }
574
+
575
+ function renderField(field: FieldNode, defaultMode?: ObjectMode): string[] {
576
+ const lines: string[] = [];
577
+ if (field.deprecated) lines.push('/** @deprecated */');
578
+
579
+ let expr = renderType(field.type, undefined, defaultMode);
580
+
581
+ expr = applyFieldModifiers(expr, field);
572
582
 
573
583
  lines.push(`${quoteKey(field.name)}: ${expr},`);
574
584
  return lines;
@@ -641,6 +651,21 @@ function regexHasAnchor(source: string): boolean {
641
651
  return backslashes % 2 === 0;
642
652
  }
643
653
 
654
+ /**
655
+ * Coercion for the numeric scalars: convert a non-empty string, pass everything else through for
656
+ * `z.number()` to judge.
657
+ *
658
+ * `z.coerce.number()` is `Number(v)`, which accepts far more than a number. `[]` and `''` become
659
+ * `0`, `null` becomes `0`, `true` becomes `1` — so `{"quantity": []}` validated as `0` and the
660
+ * handler ran on a value the client never sent. Only the string case is a real coercion; it exists
661
+ * because query strings and headers arrive as text, and it stays because a JSON body carrying
662
+ * `"42"` is common enough that rejecting it would break working callers.
663
+ *
664
+ * The `boolean` scalar below already has this shape and needed no change: its preprocess maps only
665
+ * the two literal strings and hands everything else to `z.boolean()`, which rejects it.
666
+ */
667
+ const NUMERIC_PREPROCESS = `(v) => (typeof v === 'string' && v.trim() !== '' ? Number(v) : v)`;
668
+
644
669
  function renderScalar(s: ScalarTypeNode): string {
645
670
  switch (s.name) {
646
671
  case 'string': {
@@ -652,17 +677,14 @@ function renderScalar(s: ScalarTypeNode): string {
652
677
  if (s.regex) e += `.regex(${renderRegexLiteral(s.regex)})`;
653
678
  return e;
654
679
  }
655
- case 'number': {
656
- let e = 'z.coerce.number()';
657
- if (s.min !== undefined) e += `.min(${s.min})`;
658
- if (s.max !== undefined) e += `.max(${s.max})`;
659
- return e;
660
- }
680
+ case 'number':
661
681
  case 'int': {
662
- let e = 'z.coerce.number().int()';
663
- if (s.min !== undefined) e += `.min(${s.min})`;
664
- if (s.max !== undefined) e += `.max(${s.max})`;
665
- return e;
682
+ // Constraints go on the inner schema, not the outer expression: `z.preprocess` returns
683
+ // a ZodPipe, which has no `.min()`. Same shape as the `bigint` case below.
684
+ let inner = s.name === 'int' ? 'z.number().int()' : 'z.number()';
685
+ if (s.min !== undefined) inner += `.min(${s.min})`;
686
+ if (s.max !== undefined) inner += `.max(${s.max})`;
687
+ return `z.preprocess(${NUMERIC_PREPROCESS}, ${inner})`;
666
688
  }
667
689
  case 'bigint': {
668
690
  let inner = 'z.bigint()';
@@ -848,8 +870,16 @@ function renderInlineObject(o: InlineObjectTypeNode, parseCaseTransform?: 'snake
848
870
  // ─── Input type rendering ─────────────────────────────────────────────────
849
871
 
850
872
  /**
851
- * Like renderScalar, but coerces from string input (JSON wire format).
852
- * Used for Input (write) schemas where data arrives as JSON strings.
873
+ * The request-side rendering of a scalar. A pure passthrough today, and the seam where a genuine
874
+ * input/wire split would live.
875
+ *
876
+ * The docstring here used to claim it "coerces from string input", describing a distinction the
877
+ * body does not make: `XInput` is a single exported `const`, and `generateParamValidation`'s ref
878
+ * branch uses that same schema for `query: X` that the body path uses for
879
+ * `request: { application/json: X }`. Making it strict would break query-by-model; leaving it
880
+ * coercing leaves a JSON body accepting string-shaped numbers. A real split needs a second emitted
881
+ * variant (`XInputWire`) plus the import plumbing to reach it, which is separate work — the
882
+ * narrowed coercion in `renderScalar` closes the soundness hole in the meantime.
853
883
  */
854
884
  function renderInputScalar(s: ScalarTypeNode): string {
855
885
  return renderScalar(s);
@@ -924,14 +954,7 @@ function renderInputField(field: FieldNode, modelsWithInput: Set<string>, defaul
924
954
 
925
955
  let expr = renderInputType(field.type, modelsWithInput, defaultMode);
926
956
 
927
- if (field.nullable) expr += '.nullable()';
928
- if (field.default !== undefined) {
929
- const dv = typeof field.default === 'string' ? `"${escapeString(field.default)}"` : String(field.default);
930
- expr += `.default(${dv})`;
931
- } else if (field.optional) {
932
- expr += '.optional()';
933
- }
934
- if (field.description) expr += `.describe("${escapeString(field.description)}")`;
957
+ expr = applyFieldModifiers(expr, field);
935
958
 
936
959
  lines.push(`${quoteKey(field.name)}: ${expr},`);
937
960
  return lines;
@@ -996,14 +1019,7 @@ function renderQueryField(field: FieldNode, modelsWithInput?: Set<string>, defau
996
1019
  ? renderInputType(field.type, modelsWithInput, defaultMode)
997
1020
  : renderType(field.type, undefined, defaultMode);
998
1021
 
999
- if (field.nullable) expr += '.nullable()';
1000
- if (field.default !== undefined) {
1001
- const dv = typeof field.default === 'string' ? `"${escapeString(field.default)}"` : String(field.default);
1002
- expr += `.default(${dv})`;
1003
- } else if (field.optional) {
1004
- expr += '.optional()';
1005
- }
1006
- if (field.description) expr += `.describe("${escapeString(field.description)}")`;
1022
+ expr = applyFieldModifiers(expr, field);
1007
1023
 
1008
1024
  return `${quoteKey(field.name)}: ${expr},`;
1009
1025
  }
@@ -1,8 +1,8 @@
1
1
  import type { OpRootNode, OpRouteNode, OpOperationNode, McpConfigNode, ParamSource, ContractTypeNode } from '@contractkit/core';
2
- import { resolveModifiers, emittedResponses } from '@contractkit/core';
2
+ import { resolveModifiers, emittedResponses, toIdentifier } from '@contractkit/core';
3
3
  import { renderType, renderInputType, pascalToDotCase } from './codegen-contract.js';
4
4
  import { inferService, deriveModulePath, buildArgs, deriveBaseName } from './codegen-operation.js';
5
- import { quoteKey, escapeSingleQuoted } from './ts-render.js';
5
+ import { quoteKey, escapeSingleQuoted, sourceLink } from './ts-render.js';
6
6
  import { DECIMAL_IMPORT, DECIMAL_PRELUDE_LINES } from './decimal-runtime.js';
7
7
  import { basename, dirname, relative } from 'node:path';
8
8
 
@@ -110,7 +110,9 @@ function buildArgsProps(route: OpRouteNode, op: OpOperationNode, modelsWithInput
110
110
  if (route.params) {
111
111
  if (route.params.kind === 'params') {
112
112
  for (const node of route.params.nodes) {
113
- props.push({ key: node.name, expr: renderInputType(node.type, modelsWithInput), optional: false });
113
+ // The handler destructures these, so the key has to be a valid identifier. The MCP
114
+ // input schema is ours to name — nothing on an HTTP wire depends on it.
115
+ props.push({ key: toIdentifier(node.name), expr: renderInputType(node.type, modelsWithInput), optional: false });
114
116
  }
115
117
  } else if (route.params.kind === 'ref') {
116
118
  props.push({ key: 'params', expr: refSchema(route.params.name, modelsWithInput), optional: false });
@@ -337,9 +339,8 @@ function renderToolClass(plan: ToolPlan, file: string, options: McpCodegenOption
337
339
  const lines: string[] = [];
338
340
 
339
341
  // JSDoc source link
340
- const relFile = options.outPath ? relative(dirname(options.outPath), file) : file;
341
342
  lines.push('/**');
342
- lines.push(` * from [${basename(file)}](file://./${relFile}#L${op.loc.line})`);
343
+ lines.push(` * from ${sourceLink(basename(file), options.outPath, file, op.loc.line)}`);
343
344
  lines.push(' */');
344
345
 
345
346
  lines.push('@Injectable()');
@@ -455,8 +456,7 @@ export function generateMcpFile(root: OpRootNode, options: McpCodegenOptions = {
455
456
  // Schema imports.
456
457
  imports.push(...schemaImportLines(collectSchemaIds(plans, options.modelsWithInput), options));
457
458
 
458
- const relFile = options.outPath ? relative(dirname(options.outPath), root.file) : root.file;
459
- const header = `// Auto-generated MCP tools\n// generated from [${basename(root.file)}](file://./${relFile})`;
459
+ const header = `// Auto-generated MCP tools\n// generated from ${sourceLink(basename(root.file), options.outPath, root.file)}`;
460
460
 
461
461
  return `${header}\n${imports.join('\n')}\n\n${bodyWithHelpers}\n`;
462
462
  }
@@ -490,23 +490,24 @@ export function generateMcpAggregator(entries: McpAggregatorEntry[]): string {
490
490
  /** Generate the optional `mcp.router.ts` — the standard ServerKit route wiring for the dispatcher. */
491
491
  export function generateMcpRouter(options: { path?: string } = {}): string {
492
492
  const path = options.path ?? '/mcp';
493
- return `import { ServerKitRouter, requireSignature } from '@maroonedsoftware/koa';
493
+ return `import { ServerKitRouter, bodyParserMiddleware, requireSignature } from '@maroonedsoftware/koa';
494
494
  import { McpDispatcher, createMcpRequestContext, MCP_AUTH_POLICY } from '@maroonedsoftware/mcp';
495
495
 
496
496
  /** Mount the MCP endpoint onto a ServerKit router. Call \`registerMcpTools(container)\` at startup. */
497
497
  export function mountMcp(router: ReturnType<typeof ServerKitRouter>): void {
498
- router.post('${path}', requireSignature('mcp', { policy: MCP_AUTH_POLICY }), async (ctx) => {
498
+ router.post('${path}', bodyParserMiddleware(['json']), requireSignature('mcp', { policy: MCP_AUTH_POLICY }), async (ctx) => {
499
499
  const dispatcher = ctx.container.get(McpDispatcher);
500
500
  const context = createMcpRequestContext({ requestId: ctx.requestId, logger: ctx.logger });
501
501
  if (dispatcher.sessionMode === 'stateful') {
502
502
  ctx.respond = false;
503
503
  await dispatcher.dispatchStateful(
504
- { req: ctx.req, res: ctx.res, body: ctx.request.body, sessionId: ctx.get('mcp-session-id') },
504
+ { req: ctx.req, res: ctx.res, body: ctx.parsedBody, sessionId: ctx.get('mcp-session-id') },
505
505
  context,
506
506
  );
507
507
  } else {
508
- const response = await dispatcher.dispatch(JSON.parse(ctx.rawBody), context);
508
+ const response = await dispatcher.dispatch(JSON.parse(String(ctx.rawBody)), context);
509
509
  if (response) ctx.body = response;
510
+ else ctx.status = 202; // a notification — nothing to return
510
511
  }
511
512
  });
512
513
  }
@@ -9,15 +9,16 @@ import type {
9
9
  ParamSource,
10
10
  ObjectMode,
11
11
  } from '@contractkit/core';
12
- import { resolveModifiers, resolveSecurity, SECURITY_NONE, classifyContentType, emittedResponses } from '@contractkit/core';
12
+ import { resolveModifiers, resolveSecurity, SECURITY_NONE, classifyContentType, emittedResponses, PATH_PARAM_RE_G, toIdentifier } from '@contractkit/core';
13
13
  import {
14
14
  renderType,
15
15
  renderInputType,
16
16
  renderQueryType,
17
+ applyFieldModifiers,
17
18
  pascalToDotCase,
18
19
  modeToWrapper,
19
20
  } from './codegen-contract.js';
20
- import { renderOutputTsType, quoteKey, headerNameToProperty, escapeJsDocLines, escapeSingleQuoted } from './ts-render.js';
21
+ import { renderOutputTsType, quoteKey, headerNameToProperty, escapeJsDocLines, escapeSingleQuoted, sourceLink } from './ts-render.js';
21
22
  import { DECIMAL_IMPORT, DECIMAL_PRELUDE_LINES } from './decimal-runtime.js';
22
23
  import { basename, dirname, relative } from 'path';
23
24
 
@@ -174,8 +175,7 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
174
175
 
175
176
  lines.push('');
176
177
  lines.push('/**');
177
- const relFile = options.outPath ? relative(dirname(options.outPath), root.file) : root.file;
178
- lines.push(` * generated from [${basename(root.file)}](file://./${relFile})`);
178
+ lines.push(` * generated from ${sourceLink(basename(root.file), options.outPath, root.file)}`);
179
179
  lines.push('*/');
180
180
  lines.push(`export const ${routerName} = ServerKitRouter();`);
181
181
  lines.push('');
@@ -294,8 +294,7 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
294
294
  for (const l of escapeJsDocLines(desc)) lines.push(` * ${l}`);
295
295
  }
296
296
  // Source location comment
297
- const relFile = outPath ? relative(dirname(outPath), file) : file;
298
- lines.push(` * from [${basename(file)}](file://./${relFile}#L${op.loc.line})`);
297
+ lines.push(` * from ${sourceLink(basename(file), outPath, file, op.loc.line)}`);
299
298
 
300
299
  // Security annotation (operation-level wins; falls back to route → file level)
301
300
  const effectiveSecurity = resolveSecurity(route, op, root);
@@ -311,7 +310,10 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
311
310
  lines.push('*/');
312
311
 
313
312
  const method = op.method;
314
- const path = route.path.replace(/\{(\w+)\}/g, ':$1');
313
+ // `:name` from `{name}`, mapped to a valid identifier. Unlike a query parameter or a header,
314
+ // a path placeholder's name never reaches the wire — Koa matches by position — so renaming it
315
+ // is free, and it is what lets `ctx.params` be destructured at all.
316
+ const path = route.path.replace(PATH_PARAM_RE_G, (_m, name: string) => `:${toIdentifier(name)}`);
315
317
  const bodies = op.request?.bodies ?? [];
316
318
  const hasBody = bodies.length > 0;
317
319
  const isSingleMultipart = bodies.length === 1 && bodies[0]!.contentType === 'multipart/form-data';
@@ -449,9 +451,12 @@ function generateSingleStatusResult(
449
451
  }
450
452
 
451
453
  lines.push('');
452
- // With nothing emitted, the status is still declared somewhere — fall back to the first
453
- // one written, which is what a documentation-only 3xx/4xx operation means.
454
- lines.push(` ctx.status = ${resp?.statusCode ?? op.responses[0]?.statusCode ?? 200};`);
454
+ // 204 when the operation emits nothing. Falling back to the first *declared* status wrote an
455
+ // error code on the success path, since a documentation-only block starts at a 4xx. 204 is
456
+ // also what aligns the three generators: `observableResponses` excludes a bare `400:` too, so
457
+ // the SDK already types such a method `Promise<void>` and `thrownResponses` puts the 400 in
458
+ // `@throws`. A bodyless 204 success is exactly what `Promise<void>` means.
459
+ lines.push(` ctx.status = ${resp?.statusCode ?? 204};`);
455
460
  lines.push(...headerSetLines(respHeaders, ' '));
456
461
 
457
462
  if (bodies.length === 1) {
@@ -627,7 +632,7 @@ export function buildArgs(route: OpRouteNode, op: OpOperationNode): string {
627
632
  // Path params: spread individually (inline) or pass 'params' object (type-ref/ContractTypeNode)
628
633
  if (route.params) {
629
634
  if (route.params.kind === 'params') {
630
- args.push(...route.params.nodes.map(p => p.name));
635
+ args.push(...route.params.nodes.map(p => toIdentifier(p.name)));
631
636
  } else {
632
637
  args.push('params');
633
638
  }
@@ -803,18 +808,26 @@ function generateParamValidation(
803
808
  if (source.nodes.length > 0) {
804
809
  // Destructure only for params (spread individually in service call);
805
810
  // query/headers are passed as whole objects.
806
- const lhs = varName === 'params' ? `{ ${source.nodes.map(p => p.name).join(', ')} }` : varName;
811
+ // Path params are destructured and spread into the service call; query and headers pass
812
+ // as whole objects.
813
+ const isPathParams = ctxExpr === 'ctx.params';
814
+ const bind = (name: string) => (isPathParams ? toIdentifier(name) : name);
815
+ const lhs = varName === 'params' ? `{ ${source.nodes.map(p => bind(p.name)).join(', ')} }` : varName;
807
816
  lines.push(` const ${lhs} = await parseAndValidate(`);
808
817
  lines.push(` ${ctxExpr},`);
809
818
  lines.push(` ${modeToWrapper(mode)}({`);
810
819
  for (const param of source.nodes) {
811
- const key = isValidIdentifier(param.name) ? param.name : `'${param.name}'`;
812
- if (isQuery && param.type.kind === 'array') {
813
- const inner = renderType(param.type);
814
- lines.push(` ${key}: z.preprocess((v) => typeof v === 'string' ? v.split(',') : v, ${inner}),`);
815
- } else {
816
- lines.push(` ${key}: ${renderType(param.type)},`);
817
- }
820
+ // For path params the key must match the name in the route pattern above, which
821
+ // is what `ctx.params` is keyed by. For query and headers it is the wire name,
822
+ // quoted when that is not an identifier — those the client actually sends.
823
+ const bound = bind(param.name);
824
+ const key = isValidIdentifier(bound) ? bound : `'${bound}'`;
825
+ // Delegating to renderQueryType rather than hand-rolling the array preprocess here:
826
+ // it is the same rule, and a second copy is a second thing to keep in sync. The
827
+ // modifier chain then comes from the shared helper, so an inline param means the
828
+ // same thing to the router as a model field does.
829
+ const base = isQuery ? renderQueryType(param.type, modelsWithInput) : renderInputType(param.type, modelsWithInput);
830
+ lines.push(` ${key}: ${applyFieldModifiers(base, param)},`);
818
831
  }
819
832
  lines.push(` })${suffix},`);
820
833
  lines.push(` );`);
@@ -1,4 +1,3 @@
1
- import { relative, dirname } from 'node:path';
2
1
  import type { ContractRootNode, ModelNode, FieldNode } from '@contractkit/core';
3
2
  import { computeModelsWithOutput, collectExternalOutputRefs } from '@contractkit/core';
4
3
  import type { ContractCodegenContext } from './codegen-contract.js';
@@ -10,10 +9,10 @@ import {
10
9
  resolveImportPath,
11
10
  rootNeedsScalar,
12
11
  } from './codegen-contract.js';
13
- import { renderTsType, renderInputTsType, renderOutputTsType, quoteKey, escapeJsDocLines, JSON_VALUE_TYPE_DECL } from './ts-render.js';
12
+ import { renderTsType, renderInputTsType, renderOutputTsType, quoteKey, escapeJsDocLines, sourceLink, JSON_VALUE_TYPE_DECL } from './ts-render.js';
14
13
  import type { TsRenderTarget } from './ts-render.js';
15
14
  import { DECIMAL_IMPORT, DECIMAL_CONFIG_LINE } from './decimal-runtime.js';
16
- import { renderReviveFunctions, reviveFnName, DECIMAL_COERCE_DECL } from './codegen-revive.js';
15
+ import { renderReviveFunctions, reviveFnName, coerceDeclsFor } from './codegen-revive.js';
17
16
 
18
17
  // ─── Public entry point ────────────────────────────────────────────────────
19
18
 
@@ -52,6 +51,14 @@ export function generatePlainTypes(root: ContractRootNode, context?: ContractCod
52
51
  const needsDecimal = rootNeedsScalar(root, 'decimal') || (context?.emitRevivers && context.modelsWithDecimal ? root.models.some(m => context.modelsWithDecimal!.has(m.name)) : false);
53
52
  if (needsDecimal) lines.push(DECIMAL_IMPORT);
54
53
 
54
+ // Likewise for the temporal scalars: `renderTsScalar` maps them to Luxon classes, which are a
55
+ // real runtime dependency of anyone holding one. `interval` is not among them — it renders as
56
+ // a string, since `_ZodInterval` transforms back to ISO on output.
57
+ const luxonImports: string[] = [];
58
+ if (rootNeedsScalar(root, 'date') || rootNeedsScalar(root, 'time') || rootNeedsScalar(root, 'datetime')) luxonImports.push('DateTime');
59
+ if (rootNeedsScalar(root, 'duration')) luxonImports.push('Duration');
60
+ if (luxonImports.length > 0) lines.push(`import { ${luxonImports.join(', ')} } from 'luxon';`);
61
+
55
62
  // Type-only imports for external references. A cross-file model carrying a decimal also
56
63
  // contributes its reviver, which is a value and so needs a second, non-type import.
57
64
  for (const ref of allExternalRefs) {
@@ -99,10 +106,11 @@ export function generatePlainTypes(root: ContractRootNode, context?: ContractCod
99
106
  lines.push(DECIMAL_CONFIG_LINE);
100
107
  }
101
108
 
102
- // Same rule as in `generateContract`: the helper is emitted only if the revivers actually
109
+ // Same rule as in `generateContract`: a helper is emitted only if the revivers actually
103
110
  // reference it, so the two cannot drift and trip `noUnusedLocals`.
104
- if (bodyLines.some(l => l.includes('__dec('))) {
105
- lines.push(...DECIMAL_COERCE_DECL);
111
+ const coerceDecls = coerceDeclsFor(bodyLines);
112
+ if (coerceDecls.length > 0) {
113
+ lines.push(...coerceDecls);
106
114
  lines.push('');
107
115
  }
108
116
  lines.push(...bodyLines);
@@ -174,8 +182,7 @@ function generateComments(model: ModelNode, outPath?: string): string[] {
174
182
  for (const l of escapeJsDocLines(model.description)) lines.push(` * ${l}`);
175
183
  }
176
184
 
177
- const relPath = outPath ? relative(dirname(outPath), model.loc.file) : model.loc.file;
178
- lines.push(` * generated from [${model.name}](file://./${relPath}#L${model.loc.line})`);
185
+ lines.push(` * generated from ${sourceLink(model.name, outPath, model.loc.file, model.loc.line)}`);
179
186
  lines.push(' */');
180
187
  return lines;
181
188
  }