@contractkit/plugin-typescript 0.33.3 → 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.
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ import type {
8
8
  ContractRootNode,
9
9
  OpRootNode,
10
10
  ModelNode,
11
+ ScalarTypeNode,
11
12
  IncrementalManifest,
12
13
  IncrementalUnit,
13
14
  IncrementalOutputFile,
@@ -21,7 +22,7 @@ import {
21
22
  collectTransitiveModelRefs,
22
23
  collectTypeRefs,
23
24
  computeModelsWithCaseTransform,
24
- computeModelsWithDecimal,
25
+ computeModelsWithScalar,
25
26
  } from '@contractkit/core';
26
27
  import {
27
28
  generateSdk,
@@ -43,9 +44,14 @@ import {
43
44
  type SdkScaffoldDeps,
44
45
  } from './codegen-sdk.js';
45
46
  import { generatePlainTypes } from './codegen-plain-types.js';
47
+ import { DEFAULT_REVIVABLE_SCALARS } from './codegen-revive.js';
48
+
49
+ /** Taint set for the SDK's bigint response reviver. */
50
+ const BIGINT_SCALARS: ReadonlySet<ScalarTypeNode['name']> = new Set(['bigint']);
46
51
  import { generateMcpFile, generateMcpAggregator, generateMcpRouter, hasMcpOperations, deriveMcpRegisterFnName } from './codegen-mcp.js';
47
52
  import {
48
53
  TEMPLATE_VAR_RE,
54
+ TEMPLATE_VAR_RE_G,
49
55
  resolveTemplate,
50
56
  commonDir,
51
57
  computeOpOutPath,
@@ -167,7 +173,12 @@ export interface TypescriptPluginConfig {
167
173
  // ─── Caching constants ─────────────────────────────────────────────────────
168
174
 
169
175
  /** Bumped when the codegen output shape changes in a way that should bust every per-file fingerprint. */
170
- export const TYPESCRIPT_CODEGEN_VERSION = '1';
176
+ export const TYPESCRIPT_CODEGEN_VERSION = '2';
177
+
178
+ // The taint set is `DEFAULT_REVIVABLE_SCALARS` rather than decimal alone, which is what makes a
179
+ // temporal field a real Luxon object in an SDK client rather than a string wearing a `DateTime`
180
+ // type. It also feeds every `hashFingerprint` that already slices this set, so a model gaining a
181
+ // `datetime` in another `.ck` file invalidates this file's cached output with no extra plumbing.
171
182
 
172
183
  /** Filename for the persisted TypeScript manifest under the CLI cache directory. */
173
184
  const CACHE_MANIFEST_FILENAME = 'typescript-manifest.json';
@@ -241,9 +252,23 @@ async function runTypescriptCodegen(
241
252
 
242
253
  deleteStalePaths(result.deletedPaths);
243
254
 
255
+ const unresolved = new Set<string>();
244
256
  for (const { relativePath, content, ifAbsent } of result.filesToWrite) {
257
+ for (const [, key] of relativePath.matchAll(TEMPLATE_VAR_RE_G)) unresolved.add(`${key}::${relativePath}`);
245
258
  ctx.emitFile(relativePath, content, ifAbsent ? { ifAbsent: true } : undefined);
246
259
  }
260
+ // `resolveTemplate` leaves an unknown `{key}` in place, which then joins straight into the
261
+ // output path — producing a literal `{area}` directory rather than an error. `assertWithinBase`
262
+ // does not catch it, since the path is inside the base, just wrong. Checked here rather than
263
+ // threaded down through five path helpers: every output path passes through this one funnel,
264
+ // whichever helper built it.
265
+ for (const entry of [...unresolved].sort()) {
266
+ const [key, outPath] = entry.split('::');
267
+ ctx.warn?.(
268
+ `Output path template variable {${key}} has no value, so '${outPath}' contains it literally. ` +
269
+ `Declare it in the source file's 'options { keys { ${key}: ... } }' block, or remove it from the path template.`,
270
+ );
271
+ }
247
272
 
248
273
  writeManifest(manifestPath, result.manifest);
249
274
  }
@@ -467,7 +492,11 @@ function collectSdkOutput(
467
492
  const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
468
493
  // Computed across every contract root, not per file: one decimal below a model taints it, and
469
494
  // the reference that reaches it may live in another .ck file entirely.
470
- const modelsWithDecimal = computeModelsWithDecimal(inputs.contractRoots.flatMap(r => r.models));
495
+ const modelsWithDecimal = computeModelsWithScalar(inputs.contractRoots.flatMap(r => r.models), DEFAULT_REVIVABLE_SCALARS);
496
+ // Which response bodies need the `123n` reviver. Transitive, because a bigint reached through
497
+ // a referenced model counts, and cross-file, because that model may live in another .ck file —
498
+ // which is also why it is sliced into every fingerprint below, exactly as modelsWithDecimal is.
499
+ const modelsWithBigInt = computeModelsWithScalar(inputs.contractRoots.flatMap(r => r.models), BIGINT_SCALARS);
471
500
  const modelMap = buildModelMap(inputs.contractRoots);
472
501
  const allFiles = [...inputs.contractRoots.map(r => r.file), ...inputs.opRoots.map(r => r.file)];
473
502
  const ckCommonRoot = commonDir(allFiles, rootDir);
@@ -509,6 +538,7 @@ function collectSdkOutput(
509
538
  // Not covered by `root`: adding a decimal to a model in a *different* .ck file changes
510
539
  // this file's revivers with no change to `root` or the config.
511
540
  modelsWithDecimal: sliceModelSet(refs, ownNames, modelsWithDecimal),
541
+ modelsWithBigInt: sliceModelSet(refs, ownNames, modelsWithBigInt),
512
542
  sdkOptionsPath,
513
543
  sub: subConfigKey,
514
544
  });
@@ -527,6 +557,9 @@ function collectSdkOutput(
527
557
  modelsWithOutput,
528
558
  modelsWithDecimal,
529
559
  emitRevivers: true,
560
+ // An SDK client runs in a browser as readily as in Node, and its scaffold
561
+ // declares no `@types/node`.
562
+ target: 'client',
530
563
  });
531
564
  } else {
532
565
  let rel = relative(dirname(typeOutPath), sdkOptionsPath).replace(/\.ts$/, '.js');
@@ -587,6 +620,7 @@ function collectSdkOutput(
587
620
  modelsWithInput: sliceModelSet(refs, new Set(), modelsWithInput),
588
621
  modelsWithOutput: sliceModelSet(refs, new Set(), modelsWithOutput),
589
622
  modelsWithDecimal: sliceModelSet(refs, new Set(), modelsWithDecimal),
623
+ modelsWithBigInt: sliceModelSet(refs, new Set(), modelsWithBigInt),
590
624
  sdkOptionsPath,
591
625
  className,
592
626
  includeInternal: config.includeInternal ?? false,
@@ -606,6 +640,7 @@ function collectSdkOutput(
606
640
  modelsWithInput,
607
641
  modelsWithOutput,
608
642
  modelsWithDecimal,
643
+ modelsWithBigInt,
609
644
  modelMap,
610
645
  includeInternal: config.includeInternal,
611
646
  clientClassName: className,
@@ -630,6 +665,7 @@ function collectSdkOutput(
630
665
  modelsWithInput: sliceModelSet(refs, new Set(), modelsWithInput),
631
666
  modelsWithOutput: sliceModelSet(refs, new Set(), modelsWithOutput),
632
667
  modelsWithDecimal: sliceModelSet(refs, new Set(), modelsWithDecimal),
668
+ modelsWithBigInt: sliceModelSet(refs, new Set(), modelsWithBigInt),
633
669
  sdkOptionsPath,
634
670
  includeInternal: config.includeInternal ?? false,
635
671
  sub: subConfigKey,
@@ -648,6 +684,7 @@ function collectSdkOutput(
648
684
  modelsWithInput,
649
685
  modelsWithOutput,
650
686
  modelsWithDecimal,
687
+ modelsWithBigInt,
651
688
  modelMap,
652
689
  includeInternal: config.includeInternal,
653
690
  }),
@@ -731,6 +768,7 @@ function collectSdkOutput(
731
768
  modelsWithInput: sliceModelSet(allInlineRefs, new Set(), modelsWithInput),
732
769
  modelsWithOutput: sliceModelSet(allInlineRefs, new Set(), modelsWithOutput),
733
770
  modelsWithDecimal: sliceModelSet(allInlineRefs, new Set(), modelsWithDecimal),
771
+ modelsWithBigInt: sliceModelSet(allInlineRefs, new Set(), modelsWithBigInt),
734
772
  sdkOptionsPath,
735
773
  includeInternal: config.includeInternal ?? false,
736
774
  sub: subConfigKey,
@@ -746,6 +784,7 @@ function collectSdkOutput(
746
784
  modelsWithInput,
747
785
  modelsWithOutput,
748
786
  modelsWithDecimal,
787
+ modelsWithBigInt,
749
788
  modelMap,
750
789
  includeInternal: config.includeInternal,
751
790
  },
@@ -878,7 +917,10 @@ function collectZodOutput(
878
917
  render: () => [
879
918
  {
880
919
  relativePath: outPath,
881
- content: generateContract(ast, { modelOutPaths, currentOutPath: outPath, modelsWithInput, modelsWithOutput }),
920
+ // Server-shaped, which is what this sub-generator has always emitted. The
921
+ // standalone `zod:` output has no target option of its own; only the SDK's
922
+ // schemas are client-shaped, and they pass their own target.
923
+ content: generateContract(ast, { modelOutPaths, currentOutPath: outPath, modelsWithInput, modelsWithOutput, target: 'server' }),
882
924
  },
883
925
  ],
884
926
  });
package/src/path-utils.ts CHANGED
@@ -21,6 +21,16 @@ function assertWithinBase(baseOutDir: string, outPath: string): string {
21
21
  return outPath;
22
22
  }
23
23
 
24
+ /** Global-flagged twin of {@link TEMPLATE_VAR_RE}, for finding every variable in a string. */
25
+ export const TEMPLATE_VAR_RE_G = /\{(\w+)\}/g;
26
+
27
+ /**
28
+ * Substitute `{key}` placeholders from `vars`.
29
+ *
30
+ * An unknown key is left in place rather than throwing, because the caller is mid-way through
31
+ * building a path and has better context for the complaint — see the check at the emit funnel in
32
+ * `index.ts`, which reports it against the file it would have been written to.
33
+ */
24
34
  export function resolveTemplate(template: string, vars: Record<string, string>): string {
25
35
  return template.replace(/\{(\w+)\}/g, (_, key) => vars[key] ?? `{${key}}`);
26
36
  }
package/src/ts-render.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { dirname, relative } from 'node:path';
1
2
  import type { ContractTypeNode, FieldNode, ScalarTypeNode } from '@contractkit/core';
2
3
 
3
4
  /** Declaration emitted into generated files that reference the `json` scalar. */
@@ -20,6 +21,25 @@ export function escapeSingleQuoted(s: string): string {
20
21
  return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r');
21
22
  }
22
23
 
24
+ /**
25
+ * Render the markdown link back to the `.ck` declaration a generated construct came from, as
26
+ * `[label](./path/to/file.ck#L12)`. Returns the link only; callers supply the surrounding prose
27
+ * and comment prefix, since some sites emit it inside a JSDoc block and others inside a `//` line.
28
+ *
29
+ * The path is relative to the emitted file's own directory, so it resolves when the reader clicks
30
+ * it from wherever the file was written. `outPath` is optional because codegen can run without a
31
+ * destination (the prettier plugin, and several tests), in which case the source path is used
32
+ * as-is.
33
+ *
34
+ * Not `file://./path`: `file://` opens an authority component, so the `.` parses as the host and
35
+ * the link resolves to nothing. A plain relative path is the correct form.
36
+ */
37
+ export function sourceLink(label: string, outPath: string | undefined, sourceFile: string, line?: number): string {
38
+ const rel = outPath ? relative(dirname(outPath), sourceFile) : sourceFile;
39
+ const href = rel.startsWith('.') ? rel : `./${rel}`;
40
+ return `[${label}](${href}${line === undefined ? '' : `#L${line}`})`;
41
+ }
42
+
23
43
  /** Convert an HTTP header name (e.g. `preference-applied`, `X-Request-ID`, `ETag`) to camelCase for use as a JS property. */
24
44
  export function headerNameToProperty(name: string): string {
25
45
  const parts = name.split(/[-_]/).filter(Boolean);
@@ -107,8 +127,14 @@ function renderTsScalar(name: ScalarTypeNode['name'], target: TsRenderTarget): s
107
127
  case 'date':
108
128
  case 'time':
109
129
  case 'datetime':
130
+ // Luxon objects on both sides. The router parses them via `_ZodDatetime`, and the SDK
131
+ // rehydrates them in its generated revivers, so `string` was a claim neither honoured.
132
+ return 'DateTime';
110
133
  case 'duration':
134
+ return 'Duration';
111
135
  case 'interval':
136
+ // The exception: `_ZodInterval` transforms back to an ISO string on output, so a
137
+ // string is genuinely what a consumer receives.
112
138
  return 'string';
113
139
  case 'null':
114
140
  return 'null';
@@ -1,7 +1,8 @@
1
- import { generateContract, renderType } from '../src/codegen-contract.js';
1
+ import { generateContract, renderType, applyFieldModifiers } from '../src/codegen-contract.js';
2
2
  import type { ContractCodegenContext } from '../src/codegen-contract.js';
3
3
  import {
4
4
  scalarType,
5
+ opParam,
5
6
  arrayType,
6
7
  tupleType,
7
8
  recordType,
@@ -18,6 +19,10 @@ import {
18
19
  } from './helpers.js';
19
20
  import type { ScalarTypeNode } from '@contractkit/core';
20
21
 
22
+ /** The narrowed numeric coercion `renderScalar` emits — see NUMERIC_PREPROCESS in codegen-contract. */
23
+ const NUM = `z.preprocess((v) => (typeof v === 'string' && v.trim() !== '' ? Number(v) : v), z.number())`;
24
+ const NUM_INT = `z.preprocess((v) => (typeof v === 'string' && v.trim() !== '' ? Number(v) : v), z.number().int())`;
25
+
21
26
  describe('renderType', () => {
22
27
  // ─── Scalar types ───────────────────────────────────────────────
23
28
 
@@ -70,24 +75,24 @@ describe('renderType', () => {
70
75
  expect(renderType(scalarType('string', { regex: 'price:\\$' }))).toBe('z.string().regex(/^price:\\$$/)');
71
76
  });
72
77
 
73
- it('renders z.coerce.number()', () => {
74
- expect(renderType(scalarType('number'))).toBe('z.coerce.number()');
78
+ it('coerces number from a string, rejecting what Number() would swallow', () => {
79
+ expect(renderType(scalarType('number'))).toBe(NUM);
75
80
  });
76
81
 
77
- it('renders z.coerce.number() with min', () => {
78
- expect(renderType(scalarType('number', { min: 0 }))).toBe('z.coerce.number().min(0)');
82
+ it('renders number with min', () => {
83
+ expect(renderType(scalarType('number', { min: 0 }))).toBe(`z.preprocess((v) => (typeof v === 'string' && v.trim() !== '' ? Number(v) : v), z.number().min(0))`);
79
84
  });
80
85
 
81
- it('renders z.coerce.number() with min and max', () => {
82
- expect(renderType(scalarType('number', { min: 0, max: 100 }))).toBe('z.coerce.number().min(0).max(100)');
86
+ it('renders number with min and max', () => {
87
+ expect(renderType(scalarType('number', { min: 0, max: 100 }))).toBe(`z.preprocess((v) => (typeof v === 'string' && v.trim() !== '' ? Number(v) : v), z.number().min(0).max(100))`);
83
88
  });
84
89
 
85
- it('renders z.coerce.number().int()', () => {
86
- expect(renderType(scalarType('int'))).toBe('z.coerce.number().int()');
90
+ it('coerces int from a string, rejecting what Number() would swallow', () => {
91
+ expect(renderType(scalarType('int'))).toBe(NUM_INT);
87
92
  });
88
93
 
89
- it('renders z.coerce.number().int() with constraints', () => {
90
- expect(renderType(scalarType('int', { min: 1, max: 10 }))).toBe('z.coerce.number().int().min(1).max(10)');
94
+ it('renders int with constraints, chained inside the preprocess', () => {
95
+ expect(renderType(scalarType('int', { min: 1, max: 10 }))).toBe(`z.preprocess((v) => (typeof v === 'string' && v.trim() !== '' ? Number(v) : v), z.number().int().min(1).max(10))`);
91
96
  });
92
97
 
93
98
  it('renders z.bigint() with preprocess coercion from string or bigint', () => {
@@ -238,11 +243,11 @@ describe('renderType', () => {
238
243
  });
239
244
 
240
245
  it('renders tuple type', () => {
241
- expect(renderType(tupleType(scalarType('number'), scalarType('string')))).toBe('z.tuple([z.coerce.number(), z.string()])');
246
+ expect(renderType(tupleType(scalarType('number'), scalarType('string')))).toBe(`z.tuple([${NUM}, z.string()])`);
242
247
  });
243
248
 
244
249
  it('renders record type', () => {
245
- expect(renderType(recordType(scalarType('string'), scalarType('number')))).toBe('z.record(z.string(), z.coerce.number())');
250
+ expect(renderType(recordType(scalarType('string'), scalarType('number')))).toBe(`z.record(z.string(), ${NUM})`);
246
251
  });
247
252
 
248
253
  it('renders enum type', () => {
@@ -266,7 +271,7 @@ describe('renderType', () => {
266
271
  });
267
272
 
268
273
  it('renders union type', () => {
269
- expect(renderType(unionType(scalarType('string'), scalarType('number')))).toBe('z.union([z.string(), z.coerce.number()])');
274
+ expect(renderType(unionType(scalarType('string'), scalarType('number')))).toBe(`z.union([z.string(), ${NUM}])`);
270
275
  });
271
276
 
272
277
  it('renders discriminated union as z.discriminatedUnion', () => {
@@ -286,7 +291,7 @@ describe('renderType', () => {
286
291
  const result = renderType(inlineObjectType([field('key', scalarType('string')), field('value', scalarType('number'))]));
287
292
  expect(result).toContain('z.strictObject({');
288
293
  expect(result).toContain('key: z.string(),');
289
- expect(result).toContain('value: z.coerce.number(),');
294
+ expect(result).toContain(`value: ${NUM},`);
290
295
  });
291
296
  });
292
297
  });
@@ -300,7 +305,7 @@ describe('generateContract', () => {
300
305
  const output = generateContract(root);
301
306
  expect(output).toContain('export const User = z.strictObject({');
302
307
  expect(output).toContain('name: z.string(),');
303
- expect(output).toContain('age: z.coerce.number(),');
308
+ expect(output).toContain(`age: ${NUM},`);
304
309
  expect(output).toContain('export type User = z.infer<typeof User>;');
305
310
  });
306
311
 
@@ -458,13 +463,20 @@ describe('generateContract', () => {
458
463
  expect(output).toContain("import { DateTime } from 'luxon';");
459
464
  });
460
465
 
461
- it('emits _ZodBinary helper when binary field present', () => {
466
+ it('emits a Blob _ZodBinary by default, since the default target is the client', () => {
462
467
  const root = contractRoot([model('M', [field('f', scalarType('binary'))])]);
463
468
  const output = generateContract(root);
464
- expect(output).toContain('const _ZodBinary = z.custom<Buffer>');
469
+ expect(output).toContain('const _ZodBinary = z.custom<Blob>');
465
470
  expect(output).toContain('_ZodBinary,');
466
471
  });
467
472
 
473
+ it('emits a Buffer _ZodBinary for the server target', () => {
474
+ const root = contractRoot([model('M', [field('f', scalarType('binary'))])]);
475
+ const output = generateContract(root, { modelOutPaths: new Map(), currentOutPath: '/out/m.ts', target: 'server' });
476
+ // `Buffer` in an SDK type file is unresolvable: the scaffold declares no @types/node.
477
+ expect(output).toContain('const _ZodBinary = z.custom<Buffer>');
478
+ });
479
+
468
480
  it('omits _ZodBinary helper when no binary fields', () => {
469
481
  const root = contractRoot([model('M', [field('f', scalarType('string'))])]);
470
482
  const output = generateContract(root);
@@ -567,7 +579,7 @@ describe('generateContract', () => {
567
579
  // ─── Three-schema pattern (visibility) ─────────────────────────
568
580
 
569
581
  describe('three-schema pattern', () => {
570
- it('generates Base, Read, and Write schemas when visibility fields exist', () => {
582
+ it('generates Read and Write schemas when visibility fields exist', () => {
571
583
  const root = contractRoot([
572
584
  model('User', [
573
585
  field('id', scalarType('uuid'), { visibility: 'readonly' }),
@@ -576,11 +588,12 @@ describe('generateContract', () => {
576
588
  ]),
577
589
  ]);
578
590
  const output = generateContract(root);
579
- expect(output).toContain('const UserBase = z.strictObject({');
580
591
  expect(output).toContain('export const User = z.strictObject({');
581
592
  expect(output).toContain('export const UserInput = z.strictObject({');
582
593
  expect(output).toContain('export type User = z.infer<typeof User>;');
583
594
  expect(output).toContain('export type UserInput = z.infer<typeof UserInput>;');
595
+ // No writeonly model extends User, so nothing would read a UserBase.
596
+ expect(output).not.toContain('const UserBase');
584
597
  });
585
598
 
586
599
  it('read schema omits writeonly fields', () => {
@@ -732,20 +745,31 @@ describe('generateContract', () => {
732
745
  expect(output).toContain('export const AdminInput = User.extend({');
733
746
  });
734
747
 
735
- it('parent with writeonly fields generates Base; child Base extends ParentBase', () => {
748
+ it('inherits writeonly fields through the Input chain, with no Base schema', () => {
736
749
  const root = contractRoot([
737
750
  model('User', [field('password', scalarType('string'), { visibility: 'writeonly' }), field('name', scalarType('string'))]),
738
- model('Admin', [field('role', scalarType('string'))], { bases: ['User'] }),
751
+ model('Admin', [field('token', scalarType('string'), { visibility: 'writeonly' }), field('role', scalarType('string'))], {
752
+ bases: ['User'],
753
+ }),
739
754
  ]);
740
755
  const output = generateContract(root);
741
- // User has writeonly — Base !== Read, so UserBase is emitted
742
- expect(output).toContain('const UserBase =');
743
- expect(output).toContain('export const User =');
744
- expect(output).toContain('export const UserInput =');
745
- // Admin has no writeonly — no AdminBase; but its Input still extends UserInput
746
- expect(output).not.toContain('AdminBase');
747
- expect(output).toContain('export const Admin = User.extend({');
756
+ expect(output).not.toContain('Base');
757
+ // AdminInput extends UserInput, which carries User's writeonly `password` — which is
758
+ // what the Base schemas were meant to deliver and never did.
748
759
  expect(output).toContain('export const AdminInput = UserInput.extend({');
760
+ expect(output).toContain('export const Admin = User.extend({');
761
+ });
762
+
763
+ it('leaves a user-declared model named XBase alone', () => {
764
+ const root = contractRoot([
765
+ model('User', [field('password', scalarType('string'), { visibility: 'writeonly' }), field('name', scalarType('string'))]),
766
+ model('UserBase', [field('label', scalarType('string'))]),
767
+ ]);
768
+ const output = generateContract(root);
769
+ // A text-derived rule would have had to distinguish these two; nothing generated is
770
+ // named UserBase any more, so the user's own model is the only one.
771
+ expect(output.match(/const UserBase\b/g)).toHaveLength(1);
772
+ expect(output).toContain('export const UserBase = z.strictObject({');
749
773
  });
750
774
 
751
775
  it('child inheriting from external parent with Input variant uses ParentInput.extend()', () => {
@@ -922,7 +946,7 @@ describe('generateContract', () => {
922
946
  it('includes source location comment above schema', () => {
923
947
  const root = contractRoot([model('User', [field('name', scalarType('string'))], { loc: { file: 'user.ck', line: 5 } })]);
924
948
  const output = generateContract(root);
925
- expect(output).toContain('file://./user.ck#L5');
949
+ expect(output).toContain('[User](./user.ck#L5)');
926
950
  });
927
951
 
928
952
  it('includes source location for three-schema models', () => {
@@ -932,7 +956,7 @@ describe('generateContract', () => {
932
956
  }),
933
957
  ]);
934
958
  const output = generateContract(root);
935
- expect(output).toContain('file://./user.ck#L1');
959
+ expect(output).toContain('[User](./user.ck#L1)');
936
960
  });
937
961
  });
938
962
 
@@ -1236,10 +1260,41 @@ describe('generateContract', () => {
1236
1260
  ]);
1237
1261
  const output = generateContract(root);
1238
1262
  // Inline object emits its own transform inside the Webhook input shape.
1239
- expect(output).toContain('Amount: z.coerce.number().nullish()');
1263
+ expect(output).toContain(`Amount: ${NUM}.nullish()`);
1240
1264
  expect(output).toContain('id: data.Id,');
1241
1265
  expect(output).toContain('...(data.Amount != null ? { amount: data.Amount } : {}),');
1242
1266
  expect(output).not.toContain('?? undefined');
1243
1267
  });
1244
1268
  });
1245
1269
  });
1270
+
1271
+ // ─── applyFieldModifiers ──────────────────────────────────────────────────
1272
+
1273
+ describe('applyFieldModifiers', () => {
1274
+ it('appends nothing for a plain required field', () => {
1275
+ expect(applyFieldModifiers('z.string()', {})).toBe('z.string()');
1276
+ });
1277
+
1278
+ it('chains nullable, then optionality, then the description', () => {
1279
+ expect(applyFieldModifiers('z.string()', { nullable: true, optional: true, description: 'a note' })).toBe(
1280
+ 'z.string().nullable().optional().describe("a note")',
1281
+ );
1282
+ });
1283
+
1284
+ it('prefers a default over .optional()', () => {
1285
+ // `.default()` already makes the input side optional; adding `.optional()` on top would
1286
+ // widen the output type to include undefined, which is what a default exists to prevent.
1287
+ expect(applyFieldModifiers('z.number()', { optional: true, default: 20 })).toBe('z.number().default(20)');
1288
+ });
1289
+
1290
+ it('quotes and escapes a string default', () => {
1291
+ expect(applyFieldModifiers('z.string()', { default: 'a "quoted" value' })).toBe('z.string().default("a \\"quoted\\" value")');
1292
+ });
1293
+
1294
+ it('accepts an OpParamNode, which is a FieldNode without the visibility modifiers', () => {
1295
+ // This is the point of the structural parameter type: it lets a `query:` or `headers:`
1296
+ // field render through exactly the same path a model field does.
1297
+ const param = opParam('limit', scalarType('int'), { optional: true, default: 20 });
1298
+ expect(applyFieldModifiers(NUM_INT, param)).toBe(`${NUM_INT}.default(20)`);
1299
+ });
1300
+ });
@@ -19,6 +19,10 @@ import {
19
19
  opRoot,
20
20
  } from './helpers.js';
21
21
 
22
+ /** The narrowed numeric coercion `renderScalar` emits — see NUMERIC_PREPROCESS in codegen-contract. */
23
+ const NUM = `z.preprocess((v) => (typeof v === 'string' && v.trim() !== '' ? Number(v) : v), z.number())`;
24
+ const NUM_INT = `z.preprocess((v) => (typeof v === 'string' && v.trim() !== '' ? Number(v) : v), z.number().int())`;
25
+
22
26
  describe('generateOperation', () => {
23
27
  // ─── Router name derivation ─────────────────────────────────────
24
28
 
@@ -401,8 +405,8 @@ describe('generateOperation', () => {
401
405
  ]);
402
406
  const output = generateOp(root);
403
407
  expect(output).toContain('ctx.query');
404
- expect(output).toContain('page: z.coerce.number().int()');
405
- expect(output).toContain('limit: z.coerce.number().int()');
408
+ expect(output).toContain(`page: ${NUM_INT}`);
409
+ expect(output).toContain(`limit: ${NUM_INT}`);
406
410
  });
407
411
 
408
412
  it('generates parseAndValidate import when operation has query', () => {
@@ -441,7 +445,7 @@ describe('generateOperation', () => {
441
445
  expect(output).toContain('z.preprocess');
442
446
  expect(output).toContain("typeof v === 'string' ? v.split(',') : v");
443
447
  // Non-array params should not be wrapped
444
- expect(output).toContain('limit: z.coerce.number().int()');
448
+ expect(output).toContain(`limit: ${NUM_INT}`);
445
449
  });
446
450
 
447
451
  it('imports Input variant for refs inside an intersection query', () => {
@@ -542,7 +546,7 @@ describe('generateOperation', () => {
542
546
  // Boolean should use preprocess for string coercion
543
547
  expect(output).toContain("active: z.preprocess((v) => v === 'true' ? true : v === 'false' ? false : v, z.boolean())");
544
548
  // Int should still use z.coerce
545
- expect(output).toContain('page: z.coerce.number().int()');
549
+ expect(output).toContain(`page: ${NUM_INT}`);
546
550
  });
547
551
  });
548
552
 
@@ -756,10 +760,21 @@ describe('generateOperation', () => {
756
760
  expect(output).not.toContain('ctx.body =');
757
761
  });
758
762
 
759
- it('defaults to status 200 when no response specified', () => {
763
+ it('defaults to status 204 when no response is specified', () => {
760
764
  const root = opRoot([opRoute('/users', [opOperation('get')])]);
761
765
  const output = generateOp(root);
762
- expect(output).toContain('ctx.status = 200');
766
+ // Nothing is emitted, so there is no body to send; 204 says that precisely, and it is
767
+ // what the SDK's `Promise<void>` for the same operation already means.
768
+ expect(output).toContain('ctx.status = 204');
769
+ });
770
+
771
+ it('returns 204 rather than an error status when only a 4xx is declared', () => {
772
+ const root = opRoot([opRoute('/users', [opOperation('delete', { responses: [opResponse(400)] })])]);
773
+ const output = generateOp(root);
774
+ // A bare `400:` is documentation — something else produces it. Falling back to the
775
+ // first declared status wrote 400 on the success path.
776
+ expect(output).toContain('ctx.status = 204');
777
+ expect(output).not.toContain('ctx.status = 400');
763
778
  });
764
779
 
765
780
  // ─── Which statuses the service produces ─────────────────────────
@@ -1345,7 +1360,7 @@ describe('generateOperation', () => {
1345
1360
  it('includes source location in JSDoc above handler', () => {
1346
1361
  const root = opRoot([opRoute('/users', [opOperation('get', { loc: { file: 'users.op', line: 3 } })])], 'users.op');
1347
1362
  const output = generateOp(root);
1348
- expect(output).toContain('file://./users.op#L3');
1363
+ expect(output).toContain('[users.op](./users.op#L3)');
1349
1364
  });
1350
1365
  });
1351
1366
 
@@ -1369,7 +1384,7 @@ describe('generateOperation', () => {
1369
1384
  const root = opRoot([opRoute('/users', [opOperation('get')])]);
1370
1385
  const output = generateOp(root);
1371
1386
  expect(output).toContain('/**');
1372
- expect(output).toContain('file://');
1387
+ expect(output).toMatch(/ \* from \[[^\]]+\]\(\.\/[^)]+#L\d+\)/);
1373
1388
  });
1374
1389
  });
1375
1390
 
@@ -1622,3 +1637,48 @@ describe('generateOp — route modifiers JSDoc', () => {
1622
1637
  });
1623
1638
  });
1624
1639
  });
1640
+
1641
+ // ─── Hyphenated path parameters ───────────────────────────────────────────
1642
+
1643
+ describe('generateOperation — path parameter names that are not identifiers', () => {
1644
+ const root = () =>
1645
+ opRoot([
1646
+ opRoute(
1647
+ '/invoices/{invoice-id}',
1648
+ [opOperation('get', { service: 'InvoiceService.getById', responses: [opResponse(200, 'Invoice', 'application/json')] })],
1649
+ [opParam('invoice-id', scalarType('uuid'))],
1650
+ ),
1651
+ ]);
1652
+
1653
+ it('registers a Koa pattern with a bindable name', () => {
1654
+ const out = generateOp(root());
1655
+ // Previously `{invoice-id}` survived verbatim, so the route was a literal path that no
1656
+ // real request could ever match. The name is internal — Koa matches by position — so
1657
+ // renaming it costs nothing on the wire.
1658
+ expect(out).toContain("get('/invoices/:invoiceId'");
1659
+ expect(out).not.toContain('{invoice-id}');
1660
+ });
1661
+
1662
+ it('keys the params schema by the same name, since that is what ctx.params carries', () => {
1663
+ const out = generateOp(root());
1664
+ expect(out).toContain('const { invoiceId } = await parseAndValidate(');
1665
+ expect(out).toContain('invoiceId: z.uuid()');
1666
+ expect(out).toContain('service.getById(invoiceId)');
1667
+ });
1668
+
1669
+ it('leaves query and header names alone, which the client actually sends', () => {
1670
+ const out = generateOp(
1671
+ opRoot([
1672
+ opRoute('/things', [
1673
+ opOperation('get', {
1674
+ query: [opParam('sort-by', scalarType('string'))],
1675
+ headers: [opParam('x-api-key', scalarType('string'))],
1676
+ responses: [opResponse(200, 'Thing', 'application/json')],
1677
+ }),
1678
+ ]),
1679
+ ]),
1680
+ );
1681
+ expect(out).toContain("'sort-by': z.string()");
1682
+ expect(out).toContain("'x-api-key': z.string()");
1683
+ });
1684
+ });
@@ -38,13 +38,21 @@ describe('generatePlainTypes', () => {
38
38
  expect(output).not.toContain('z.infer');
39
39
  });
40
40
 
41
- it('does not contain luxon imports for date fields', () => {
41
+ it('imports luxon for date fields, which render as DateTime', () => {
42
42
  const root = contractRoot([model('Event', [field('startDate', scalarType('date')), field('endDate', scalarType('datetime'))])]);
43
43
  const output = generatePlainTypes(root);
44
+ // The router parses these into Luxon objects and the SDK's revivers rehydrate them,
45
+ // so a `string` here was a claim neither side honoured.
46
+ expect(output).toContain("import { DateTime } from 'luxon';");
47
+ expect(output).toContain('startDate: DateTime;');
48
+ expect(output).toContain('endDate: DateTime;');
49
+ });
50
+
51
+ it('leaves interval as a string, since it transforms back to ISO on output', () => {
52
+ const root = contractRoot([model('Window', [field('span', scalarType('interval'))])]);
53
+ const output = generatePlainTypes(root);
54
+ expect(output).toContain('span: string;');
44
55
  expect(output).not.toContain('luxon');
45
- expect(output).not.toContain('DateTime');
46
- expect(output).toContain('startDate: string;');
47
- expect(output).toContain('endDate: string;');
48
56
  });
49
57
  });
50
58
 
@@ -573,7 +581,7 @@ describe('generatePlainTypes', () => {
573
581
  it('includes source location in JSDoc', () => {
574
582
  const root = contractRoot([model('User', [field('name', scalarType('string'))], { loc: { file: 'user.ck', line: 5 } })]);
575
583
  const output = generatePlainTypes(root);
576
- expect(output).toContain('file://./user.ck#L5');
584
+ expect(output).toContain('[User](./user.ck#L5)');
577
585
  });
578
586
  });
579
587