@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
@@ -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
 
@@ -23,6 +23,7 @@ import {
23
23
  opRoute,
24
24
  opOperation,
25
25
  opParam,
26
+ paramRef,
26
27
  opRequest,
27
28
  opMultiRequest,
28
29
  opResponse,
@@ -134,6 +135,34 @@ describe('generateSdk', () => {
134
135
  expect(out).toContain("method: 'GET'");
135
136
  expect(out).toContain('return await parseJson<User>(result)');
136
137
  });
138
+
139
+ it('reads path params off the params argument when the route declares a model', () => {
140
+ const root = opRoot([
141
+ opRoute(
142
+ '/pets/{petId}',
143
+ [opOperation('get', { sdk: 'getPet', responses: [opResponse(200, 'Pet', 'application/json')] })],
144
+ paramRef('PetRef'),
145
+ ),
146
+ ]);
147
+ const out = generateSdk(root);
148
+ // The signature has one argument named `params`, so a bare `petId` refers to nothing.
149
+ expect(out).toContain('async getPet(params: PetRef)');
150
+ expect(out).toContain('${encodeURIComponent(String(params.petId))}');
151
+ expect(out).not.toContain('encodeURIComponent(petId)');
152
+ });
153
+
154
+ it('brackets a hyphenated path param, which is not a property accessor', () => {
155
+ const root = opRoot([
156
+ opRoute(
157
+ '/pets/{pet-id}',
158
+ [opOperation('get', { sdk: 'getPet', responses: [opResponse(200, 'Pet', 'application/json')] })],
159
+ paramRef('PetRef'),
160
+ ),
161
+ ]);
162
+ const out = generateSdk(root);
163
+ expect(out).toContain('${encodeURIComponent(String(params["pet-id"]))}');
164
+ expect(out).not.toContain('{pet-id}');
165
+ });
137
166
  });
138
167
 
139
168
  describe('POST with JSON body', () => {
@@ -369,7 +398,7 @@ describe('generateSdk', () => {
369
398
  const out = generateSdk(root);
370
399
  expect(out).toContain('Promise<{ data: Transfer; headers: { preferenceApplied?: string; etag: string } }>');
371
400
  expect(out).toContain("preferenceApplied: result.headers.get('preference-applied') ?? undefined");
372
- expect(out).toContain("etag: result.headers.get('etag') ?? undefined");
401
+ expect(out).toContain("etag: result.headers.get('etag')!");
373
402
  expect(out).toContain('return { data, headers:');
374
403
  });
375
404
 
@@ -395,11 +424,74 @@ describe('generateSdk', () => {
395
424
  ]);
396
425
  const out = generateSdk(root);
397
426
  expect(out).toContain('Promise<{ headers: { xDeletedAt: string } }>');
398
- expect(out).toContain("xDeletedAt: result.headers.get('x-deleted-at') ?? undefined");
427
+ expect(out).toContain("xDeletedAt: result.headers.get('x-deleted-at')!");
399
428
  expect(out).toContain('return { headers:');
400
429
  expect(out).not.toContain('parseJson<void>');
401
430
  });
402
431
 
432
+ it('coerces each header to the type the return shape declares', () => {
433
+ const root = opRoot([
434
+ opRoute('/things', [
435
+ opOperation('get', {
436
+ sdk: 'getThing',
437
+ responses: [
438
+ {
439
+ statusCode: 200,
440
+ hasBlock: true,
441
+ bodies: [{ contentType: 'application/json', bodyType: { kind: 'ref', name: 'Thing' } }],
442
+ headers: [
443
+ { name: 'x-count', optional: false, type: scalarType('int') },
444
+ { name: 'x-ratio', optional: true, type: scalarType('number') },
445
+ { name: 'x-cached', optional: false, type: scalarType('boolean') },
446
+ { name: 'x-fresh', optional: true, type: scalarType('boolean') },
447
+ { name: 'x-seq', optional: false, type: scalarType('bigint') },
448
+ { name: 'x-prev', optional: true, type: scalarType('bigint') },
449
+ { name: 'x-expires', optional: true, type: scalarType('datetime') },
450
+ { name: 'x-day', optional: false, type: scalarType('date') },
451
+ ],
452
+ },
453
+ ],
454
+ }),
455
+ ]),
456
+ ]);
457
+ const out = generateSdk(root);
458
+ // Header values arrive as strings; the shape is typed from the contract, so without
459
+ // coercion every one of these is a TS2322 in one direction or the other.
460
+ expect(out).toContain('xCount: Number(result.headers.get(\'x-count\'))');
461
+ expect(out).toContain("xRatio: result.headers.get('x-ratio') === null ? undefined : Number(result.headers.get('x-ratio'))");
462
+ expect(out).toContain("xCached: result.headers.get('x-cached') === 'true'");
463
+ expect(out).toContain("xFresh: result.headers.get('x-fresh') === null ? undefined : result.headers.get('x-fresh') === 'true'");
464
+ expect(out).toContain("xSeq: BigInt(result.headers.get('x-seq')!)");
465
+ // Asserted in both branches: TS does not carry the null narrowing across a second
466
+ // `get()` call, and `BigInt` takes no null.
467
+ expect(out).toContain("xPrev: result.headers.get('x-prev') === null ? undefined : BigInt(result.headers.get('x-prev')!)");
468
+ // Temporals are Luxon objects since the SDK started reviving them, so a raw string no
469
+ // longer satisfies the shape `renderOutputTsType` produces.
470
+ expect(out).toContain("xExpires: result.headers.get('x-expires') === null ? undefined : DateTime.fromISO(result.headers.get('x-expires')!)");
471
+ expect(out).toContain("xDay: DateTime.fromFormat(result.headers.get('x-day')!, 'yyyy-MM-dd')");
472
+ });
473
+
474
+ it('rejects a header type that cannot be read from a header', () => {
475
+ const root = opRoot([
476
+ opRoute('/things', [
477
+ opOperation('get', {
478
+ sdk: 'getThing',
479
+ responses: [
480
+ {
481
+ statusCode: 200,
482
+ hasBlock: true,
483
+ bodies: [{ contentType: 'application/json', bodyType: { kind: 'ref', name: 'Thing' } }],
484
+ headers: [{ name: 'x-money', optional: false, type: scalarType('decimal') }],
485
+ },
486
+ ],
487
+ }),
488
+ ]),
489
+ ]);
490
+ // Emitting code that does not compile would be worse than refusing; the CLI turns this
491
+ // into a plugin-scoped error naming the operation.
492
+ expect(() => generateSdk(root)).toThrow(/x-money.*GET \/things.*'decimal' scalar/s);
493
+ });
494
+
403
495
  it('preserves plain return type when no response headers are declared', () => {
404
496
  const root = opRoot([
405
497
  opRoute(
@@ -415,7 +507,7 @@ describe('generateSdk', () => {
415
507
  });
416
508
 
417
509
  describe('query params', () => {
418
- it('adds query parameter to method signature', () => {
510
+ it('requires the query argument when its fields are not optional', () => {
419
511
  const root = opRoot([
420
512
  opRoute('/users', [
421
513
  opOperation('get', {
@@ -426,10 +518,47 @@ describe('generateSdk', () => {
426
518
  ]),
427
519
  ]);
428
520
  const out = generateSdk(root);
429
- expect(out).toContain('query?: { page?: number; limit?: number }');
521
+ // The contract declares neither field with `?`, so the router demands both. Typing
522
+ // them optional let a caller omit a value the request would then be rejected for.
523
+ expect(out).toContain('query: { page: number; limit: number }');
430
524
  expect(out).toContain('URLSearchParams');
431
525
  });
432
526
 
527
+ it('makes the query argument optional when every field is', () => {
528
+ const root = opRoot([
529
+ opRoute('/users', [
530
+ opOperation('get', {
531
+ sdk: 'listUsers',
532
+ query: [
533
+ opParam('page', scalarType('int'), { optional: true }),
534
+ // A default is equally omittable by the caller, so it counts as optional.
535
+ opParam('limit', scalarType('int'), { default: 20 }),
536
+ ],
537
+ responses: [opResponse(200, 'array(User)', 'application/json')],
538
+ }),
539
+ ]),
540
+ ]);
541
+ const out = generateSdk(root);
542
+ expect(out).toContain('query?: { page?: number; limit?: number }');
543
+ });
544
+
545
+ it('widens an optional query argument that precedes a required one', () => {
546
+ const root = opRoot([
547
+ opRoute('/users', [
548
+ opOperation('get', {
549
+ sdk: 'listUsers',
550
+ query: [opParam('page', scalarType('int'), { optional: true })],
551
+ headers: [opParam('x-tenant', scalarType('string'))],
552
+ responses: [opResponse(200, 'array(User)', 'application/json')],
553
+ }),
554
+ ]),
555
+ ]);
556
+ const out = generateSdk(root);
557
+ // `async m(query?: Q, customHeaders: H)` is TS1016. Widening the earlier argument is
558
+ // the only fix that keeps the positional order call sites depend on.
559
+ expect(out).toContain("async listUsers(query: { page?: number }, customHeaders: { 'x-tenant': string })");
560
+ });
561
+
433
562
  it('appends qs directly to URL', () => {
434
563
  const root = opRoot([
435
564
  opRoute('/users', [
@@ -501,7 +630,7 @@ describe('generateSdk', () => {
501
630
  ]),
502
631
  ]);
503
632
  const out = generateSdk(root);
504
- expect(out).toContain("customHeaders?: { 'x-api-key'?: string }");
633
+ expect(out).toContain("customHeaders: { 'x-api-key': string }");
505
634
  });
506
635
  });
507
636
 
@@ -1201,6 +1330,25 @@ describe('generateSdk — route-level deprecated cascade', () => {
1201
1330
  expect(deprecatedCount).toBe(2);
1202
1331
  });
1203
1332
 
1333
+ it('keeps @deprecated in the same block as the description', () => {
1334
+ const root = opRoot([
1335
+ opRoute('/users', [
1336
+ opOperation('get', {
1337
+ description: 'list the users',
1338
+ responses: [opResponse(200, 'User', 'application/json')],
1339
+ modifiers: ['deprecated'],
1340
+ }),
1341
+ ]),
1342
+ ]);
1343
+ const out = generateSdk(root);
1344
+ // TypeScript honours only the JSDoc adjacent to the declaration, so a standalone
1345
+ // `/** @deprecated */` above a description block is dropped by editors entirely.
1346
+ expect(out).not.toContain('/** @deprecated */');
1347
+ const block = out.slice(out.indexOf(' /**'), out.indexOf('async getUsers'));
1348
+ expect(block).toContain('@description list the users');
1349
+ expect(block).toContain('@deprecated');
1350
+ });
1351
+
1204
1352
  it('operation-level modifiers override route-level deprecated', () => {
1205
1353
  const root = opRoot([
1206
1354
  opRoute('/users', [opOperation('get', { modifiers: [], responses: [opResponse(200, 'User', 'application/json')] })], undefined, [
@@ -1260,9 +1408,15 @@ describe('renderTsType', () => {
1260
1408
  expect(renderTsType(scalarType('uuid'))).toBe('string');
1261
1409
  });
1262
1410
 
1263
- it('maps date and datetime to string', () => {
1264
- expect(renderTsType(scalarType('date'))).toBe('string');
1265
- expect(renderTsType(scalarType('datetime'))).toBe('string');
1411
+ it('maps date and datetime to DateTime', () => {
1412
+ expect(renderTsType(scalarType('date'))).toBe('DateTime');
1413
+ expect(renderTsType(scalarType('datetime'))).toBe('DateTime');
1414
+ });
1415
+
1416
+ it('maps duration to Duration but leaves interval a string', () => {
1417
+ expect(renderTsType(scalarType('duration'))).toBe('Duration');
1418
+ // `_ZodInterval` ends in a transform back to ISO, so a string is what arrives.
1419
+ expect(renderTsType(scalarType('interval'))).toBe('string');
1266
1420
  });
1267
1421
 
1268
1422
  it('maps bigint to bigint', () => {
@@ -1289,8 +1443,8 @@ describe('renderTsType', () => {
1289
1443
  expect(renderTsType(scalarType('json'))).toBe('JsonValue');
1290
1444
  });
1291
1445
 
1292
- it('maps time to string', () => {
1293
- expect(renderTsType(scalarType('time'))).toBe('string');
1446
+ it('maps time to DateTime', () => {
1447
+ expect(renderTsType(scalarType('time'))).toBe('DateTime');
1294
1448
  });
1295
1449
 
1296
1450
  it('throws on an unmapped scalar name', () => {
@@ -1484,6 +1638,48 @@ describe('generateSdk — multipart/form-data', () => {
1484
1638
  expect(out).not.toContain("'Content-Type': 'application/json'");
1485
1639
  expect(out).not.toContain('JSON.stringify');
1486
1640
  });
1641
+
1642
+ it('does not import a model used only as a multipart body', () => {
1643
+ const root = opRoot([
1644
+ opRoute('/uploads', [
1645
+ opOperation('post', {
1646
+ sdk: 'upload',
1647
+ request: opRequest('UploadForm', 'multipart/form-data'),
1648
+ responses: [opResponse(201, 'Upload', 'application/json')],
1649
+ }),
1650
+ ]),
1651
+ ]);
1652
+ const out = generateSdk(root, {
1653
+ outPath: '/sdk/clients/uploads.client.ts',
1654
+ modelOutPaths: new Map([
1655
+ ['UploadForm', '/sdk/types/uploads.types.ts'],
1656
+ ['Upload', '/sdk/types/uploads.types.ts'],
1657
+ ]),
1658
+ });
1659
+ // The body is typed `FormData`, so UploadForm is never named in the output; importing it
1660
+ // leaves an unused local that fails `noUnusedLocals` in the generated package.
1661
+ expect(out).toContain('async upload(body: FormData)');
1662
+ expect(out).not.toContain('UploadForm');
1663
+ // Negative control: the response model is genuinely referenced and must still be imported.
1664
+ expect(out).toContain('Upload');
1665
+ });
1666
+
1667
+ it('still imports a model that is a multipart body and also a response body', () => {
1668
+ const root = opRoot([
1669
+ opRoute('/uploads', [
1670
+ opOperation('post', {
1671
+ sdk: 'upload',
1672
+ request: opRequest('UploadForm', 'multipart/form-data'),
1673
+ responses: [opResponse(201, 'UploadForm', 'application/json')],
1674
+ }),
1675
+ ]),
1676
+ ]);
1677
+ const out = generateSdk(root, {
1678
+ outPath: '/sdk/clients/uploads.client.ts',
1679
+ modelOutPaths: new Map([['UploadForm', '/sdk/types/uploads.types.ts']]),
1680
+ });
1681
+ expect(out).toContain("import type { UploadForm } from '../types/uploads.types.js';");
1682
+ });
1487
1683
  });
1488
1684
 
1489
1685
  // ─── generateMethod fetch assembly ────────────────────────────────────────
@@ -1973,3 +2169,62 @@ describe('generateSdkTsconfig', () => {
1973
2169
  expect(raw.endsWith('}\n')).toBe(true);
1974
2170
  });
1975
2171
  });
2172
+
2173
+ // ─── bigint reviver gating ────────────────────────────────────────────────
2174
+
2175
+ describe('generateSdk — bigint reviver gating', () => {
2176
+ const withBody = (bodyType: string) =>
2177
+ opRoot([opRoute('/things', [opOperation('get', { sdk: 'getThing', responses: [opResponse(200, bodyType, 'application/json')] })])]);
2178
+
2179
+ const opts = { outPath: '/sdk/src/things.client.ts', sdkOptionsPath: '/sdk/sdk-options.ts' };
2180
+
2181
+ it('imports the plain parseJson when no response carries a bigint', () => {
2182
+ // `bigIntReviver` matches /^-?\d+n$/ against every string in the document, so a contract
2183
+ // with no bigint anywhere still had a legitimate "123n" silently turned into a BigInt.
2184
+ const out = generateSdk(withBody('Thing'), opts);
2185
+ expect(out).toContain("import { parseJson } from '../sdk-options.js';");
2186
+ expect(out).not.toContain('parseJsonWithBigInt');
2187
+ });
2188
+
2189
+ it('imports the bigint-aware variant under the same name when one does', () => {
2190
+ const root = opRoot([
2191
+ opRoute('/things', [
2192
+ opOperation('get', {
2193
+ sdk: 'getThing',
2194
+ responses: [opResponse(200, inlineObjectType([field('seq', scalarType('bigint'))]), 'application/json')],
2195
+ }),
2196
+ ]),
2197
+ ]);
2198
+ const out = generateSdk(root, opts);
2199
+ // Aliased, so the method bodies are identical either way and only the import differs.
2200
+ expect(out).toContain("import { parseJsonWithBigInt as parseJson } from '../sdk-options.js';");
2201
+ expect(out).toContain('await parseJson<');
2202
+ });
2203
+
2204
+ it('emits both variants in the shared runtime, since clients pick per contract', () => {
2205
+ const runtime = generateSdkOptions();
2206
+ expect(runtime).toContain('export async function parseJson<T>(res: Response): Promise<T> {');
2207
+ expect(runtime).toContain('export async function parseJsonWithBigInt<T>(res: Response): Promise<T> {');
2208
+ expect(runtime).toContain('return JSON.parse(await res.text()) as T;');
2209
+ expect(runtime).toContain('return JSON.parse(await res.text(), bigIntReviver) as T;');
2210
+ });
2211
+ });
2212
+
2213
+ // ─── Hyphenated path parameters ───────────────────────────────────────────
2214
+
2215
+ describe('generateSdk — path parameter names that are not identifiers', () => {
2216
+ it('binds a valid identifier and interpolates it', () => {
2217
+ const root = opRoot([
2218
+ opRoute(
2219
+ '/invoices/{invoice-id}',
2220
+ [opOperation('get', { sdk: 'getInvoice', responses: [opResponse(200, 'Invoice', 'application/json')] })],
2221
+ [opParam('invoice-id', scalarType('uuid'))],
2222
+ ),
2223
+ ]);
2224
+ const out = generateSdk(root);
2225
+ // `async getInvoice(invoice-id: string)` did not parse, and the URL kept the literal braces.
2226
+ expect(out).toContain('async getInvoice(invoiceId: string)');
2227
+ expect(out).toContain('${encodeURIComponent(invoiceId)}');
2228
+ expect(out).not.toContain('{invoice-id}');
2229
+ });
2230
+ });
@@ -5,8 +5,12 @@ import { opRoot, opRoute, opOperation, opParam, opRequest, opResponse, scalarTyp
5
5
 
6
6
  // ─── Helpers ───────────────────────────────────────────────────────────────
7
7
 
8
- function makeCtx(rootDir = '/project', options: Record<string, unknown> = {}): PluginContext & { emitted: Map<string, string> } {
8
+ function makeCtx(
9
+ rootDir = '/project',
10
+ options: Record<string, unknown> = {},
11
+ ): PluginContext & { emitted: Map<string, string>; warnings: string[] } {
9
12
  const emitted = new Map<string, string>();
13
+ const warnings: string[] = [];
10
14
  return {
11
15
  rootDir,
12
16
  options,
@@ -15,7 +19,11 @@ function makeCtx(rootDir = '/project', options: Record<string, unknown> = {}): P
15
19
  emitFile: (outPath: string, content: string) => {
16
20
  emitted.set(outPath, content);
17
21
  },
22
+ warn: (message: string) => {
23
+ warnings.push(message);
24
+ },
18
25
  emitted,
26
+ warnings,
19
27
  };
20
28
  }
21
29
 
@@ -495,3 +503,37 @@ describe('createTypescriptPlugin (sdk) — scaffold', () => {
495
503
  expect(pkg.dependencies).toBeUndefined();
496
504
  });
497
505
  });
506
+
507
+ // ─── Output path template variables ───────────────────────────────────────
508
+
509
+ describe('unresolved output path template variables', () => {
510
+ it('warns and names the key when a template variable has no value', async () => {
511
+ const plugin = createTypescriptPlugin({ server: { output: { routes: '{area}/{filename}.router.ts' } } }, '/project');
512
+ const ctx = makeCtx('/project');
513
+ // The .ck file declares no `options { keys { area } }`, so `{area}` has no value.
514
+ await plugin.generateTargets!(inputs(), ctx);
515
+
516
+ expect(ctx.warnings).toHaveLength(1);
517
+ expect(ctx.warnings[0]).toContain('{area}');
518
+ expect(ctx.warnings[0]).toContain('users.router.ts');
519
+ expect(ctx.warnings[0]).toContain('options { keys { area');
520
+ });
521
+
522
+ it('still emits the file, since visibility is the point and not refusal', async () => {
523
+ // Throwing would be worse than a literal directory: cli.ts catches and continues to the
524
+ // next plugin, so one bad template would cost this plugin its entire output.
525
+ const plugin = createTypescriptPlugin({ server: { output: { routes: '{area}/{filename}.router.ts' } } }, '/project');
526
+ const ctx = makeCtx('/project');
527
+ await plugin.generateTargets!(inputs(), ctx);
528
+
529
+ expect([...ctx.emitted.keys()].some(p => p.includes('{area}'))).toBe(true);
530
+ });
531
+
532
+ it('stays quiet when every variable resolves', async () => {
533
+ const plugin = createTypescriptPlugin({ server: { output: { routes: '{filename}.router.ts' } } }, '/project');
534
+ const ctx = makeCtx('/project');
535
+ await plugin.generateTargets!(inputs(), ctx);
536
+
537
+ expect(ctx.warnings).toEqual([]);
538
+ });
539
+ });
package/tests/helpers.ts CHANGED
@@ -107,8 +107,13 @@ export function contractRoot(models: ModelNode[], file = 'test.ck'): ContractRoo
107
107
  return { kind: 'contractRoot', meta: {}, models, file };
108
108
  }
109
109
 
110
- export function opParam(name: string, type: ContractTypeNode): OpParamNode {
111
- return { name, type, loc: loc(1, 'test.op') };
110
+ /**
111
+ * `optional` and `nullable` default to `false` rather than being omitted. Once codegen reads
112
+ * them, an omitted `undefined` is falsy and so silently means "required" — which would make a
113
+ * fixture that meant to say nothing accidentally assert something.
114
+ */
115
+ export function opParam(name: string, type: ContractTypeNode, overrides?: Partial<OpParamNode>): OpParamNode {
116
+ return { name, type, optional: false, nullable: false, loc: loc(1, 'test.op'), ...overrides };
112
117
  }
113
118
 
114
119
  export function paramNodes(nodes: OpParamNode[]): ParamSource {
@@ -5,6 +5,10 @@ import { generateSdk } from '../src/codegen-sdk.js';
5
5
  import { generateMcpFile } from '../src/codegen-mcp.js';
6
6
  import { SIMPLE_USER_CONTRACT, VISIBILITY_CONTRACT, INHERITANCE_CONTRACT, SIMPLE_USERS_OP, PARAMETERIZED_OP } from './helpers.js';
7
7
 
8
+ /** The narrowed numeric coercion `renderScalar` emits — see NUMERIC_PREPROCESS in codegen-contract. */
9
+ const NUM = `z.preprocess((v) => (typeof v === 'string' && v.trim() !== '' ? Number(v) : v), z.number())`;
10
+ const NUM_INT = `z.preprocess((v) => (typeof v === 'string' && v.trim() !== '' ? Number(v) : v), z.number().int())`;
11
+
8
12
  function compileContractSource(source: string) {
9
13
  const diag = new DiagnosticCollector();
10
14
  const ck = parseCk(source, 'test.ck', diag);
@@ -29,7 +33,7 @@ describe('Contract pipeline (source -> parse -> codegen)', () => {
29
33
  expect(output).toContain('id: z.uuid()');
30
34
  expect(output).toContain('name: z.string()');
31
35
  expect(output).toContain('email: z.email()');
32
- expect(output).toContain('age: z.coerce.number().optional()');
36
+ expect(output).toContain(`age: ${NUM}.optional()`);
33
37
  expect(output).toContain(`active: z.preprocess((v) => v === 'true' ? true : v === 'false' ? false : v, z.boolean()).default(true)`);
34
38
  });
35
39
 
@@ -60,7 +64,8 @@ contract Payslip: {
60
64
  it('compiles a contract with visibility to three-schema pattern', () => {
61
65
  const { output, diag } = compileContractSource(VISIBILITY_CONTRACT);
62
66
  expect(diag.hasErrors()).toBe(false);
63
- expect(output).toContain('const UserBase = z.strictObject({');
67
+ // No writeonly model extends User, so no UserBase is emitted — nothing would read it.
68
+ expect(output).not.toContain('const UserBase');
64
69
  expect(output).toContain('export const User = z.strictObject({');
65
70
  expect(output).toContain('export const UserInput = z.strictObject({');
66
71
 
@@ -95,11 +100,11 @@ contract Kitchen: {
95
100
  const { output, diag } = compileContractSource(source);
96
101
  expect(diag.hasErrors()).toBe(false);
97
102
  expect(output).toContain('z.array(z.string())');
98
- expect(output).toContain('z.tuple([z.coerce.number(), z.coerce.number()])');
103
+ expect(output).toContain(`z.tuple([${NUM}, ${NUM}])`);
99
104
  expect(output).toContain('z.record(z.string(), z.unknown())');
100
105
  expect(output).toContain('z.enum(["open", "closed"])');
101
106
  expect(output).toContain('z.literal("kitchen")');
102
- expect(output).toContain('z.union([z.string(), z.coerce.number()])');
107
+ expect(output).toContain(`z.union([z.string(), ${NUM}])`);
103
108
  expect(output).toContain('Address');
104
109
  expect(output).toContain('z.lazy(() => Kitchen)');
105
110
  });
@@ -462,3 +467,45 @@ operation /users: {
462
467
  expect(warnings).toHaveLength(0);
463
468
  });
464
469
  });
470
+
471
+ // ─── Numeric coercion, as it actually behaves at runtime ─────────────────
472
+
473
+ describe('numeric scalar coercion', () => {
474
+ /** Build the emitted schema for one field and run real Zod against it. */
475
+ async function schemaFor(fieldDecl: string) {
476
+ const { output } = compileContractSource(`contract M: {\n ${fieldDecl}\n}\n`);
477
+ const body = output.split('export const M = ')[1]!.split(');')[0]! + ')';
478
+ const { z } = await import('zod');
479
+ return new Function('z', `return ${body}`)(z) as { parse: (v: unknown) => unknown };
480
+ }
481
+
482
+ it('still coerces a string-shaped number, which query strings and headers depend on', async () => {
483
+ const M = await schemaFor('n: number');
484
+ expect(M.parse({ n: '42' })).toEqual({ n: 42 });
485
+ expect(M.parse({ n: 42 })).toEqual({ n: 42 });
486
+ });
487
+
488
+ it('rejects the values Number() silently turned into a number', async () => {
489
+ // `z.coerce.number()` is `Number(v)`: [] and '' become 0, null becomes 0, true becomes 1.
490
+ // Each of these validated cleanly and handed the handler a value the client never sent.
491
+ const M = await schemaFor('n: number');
492
+ for (const bad of [[], {}, null, true, '']) {
493
+ expect(() => M.parse({ n: bad }), `expected ${JSON.stringify(bad)} to be rejected`).toThrow();
494
+ }
495
+ });
496
+
497
+ it('keeps min and max chaining onto the outer schema', async () => {
498
+ const M = await schemaFor('n: int(min=1, max=5)');
499
+ expect(M.parse({ n: '3' })).toEqual({ n: 3 });
500
+ expect(() => M.parse({ n: 9 })).toThrow();
501
+ expect(() => M.parse({ n: 2.5 })).toThrow();
502
+ });
503
+
504
+ it('leaves boolean alone, which was already safe', async () => {
505
+ // Its preprocess maps only the two literal strings and hands everything else to
506
+ // z.boolean(), which rejects it — the shape the numeric scalars now share.
507
+ const M = await schemaFor('b: boolean');
508
+ expect(M.parse({ b: 'true' })).toEqual({ b: true });
509
+ expect(() => M.parse({ b: 1 })).toThrow();
510
+ });
511
+ });
@@ -0,0 +1,29 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { sourceLink } from '../src/ts-render.js';
3
+
4
+ describe('sourceLink', () => {
5
+ it('emits a plain relative path, not a file:// URL', () => {
6
+ // `file://./x.ck` opens an authority component, so `.` parses as the host and the link
7
+ // resolves to nothing. The whole point of the helper is to not do that.
8
+ const link = sourceLink('User', '/out/schemas/user.schema.ts', '/out/contracts/user.ck', 5);
9
+ expect(link).toBe('[User](../contracts/user.ck#L5)');
10
+ expect(link).not.toContain('file://');
11
+ });
12
+
13
+ it('relativises the source path against the emitted file, not the process cwd', () => {
14
+ expect(sourceLink('User', '/a/b/c/out.ts', '/a/user.ck')).toBe('[User](../../user.ck)');
15
+ });
16
+
17
+ it('prefixes a bare sibling path with ./ so it reads as relative', () => {
18
+ expect(sourceLink('User', '/out/user.schema.ts', '/out/user.ck', 3)).toBe('[User](./user.ck#L3)');
19
+ });
20
+
21
+ it('omits the line anchor when no line is given', () => {
22
+ expect(sourceLink('billing.ck', '/out/sdk.ts', '/out/billing.ck')).toBe('[billing.ck](./billing.ck)');
23
+ });
24
+
25
+ it('falls back to the source path when there is no output path', () => {
26
+ // Codegen runs without a destination in the prettier plugin and in several tests.
27
+ expect(sourceLink('User', undefined, 'contracts/user.ck', 9)).toBe('[User](./contracts/user.ck#L9)');
28
+ });
29
+ });