@contractkit/plugin-typescript 0.16.1

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 (60) hide show
  1. package/.turbo/turbo-build$colon$ci.log +35 -0
  2. package/.turbo/turbo-build.log +15 -0
  3. package/.turbo/turbo-test$colon$ci.log +81 -0
  4. package/.turbo/turbo-test.log +19 -0
  5. package/CHANGELOG.md +151 -0
  6. package/README.md +153 -0
  7. package/coverage/base.css +224 -0
  8. package/coverage/block-navigation.js +87 -0
  9. package/coverage/clover.xml +1882 -0
  10. package/coverage/coverage-final.json +9 -0
  11. package/coverage/favicon.png +0 -0
  12. package/coverage/index.html +131 -0
  13. package/coverage/prettify.css +1 -0
  14. package/coverage/prettify.js +2 -0
  15. package/coverage/sort-arrow-sprite.png +0 -0
  16. package/coverage/sorter.js +210 -0
  17. package/coverage/src/codegen-contract.ts.html +3331 -0
  18. package/coverage/src/codegen-operation.ts.html +2530 -0
  19. package/coverage/src/codegen-plain-types.ts.html +901 -0
  20. package/coverage/src/codegen-sdk.ts.html +2797 -0
  21. package/coverage/src/index.html +206 -0
  22. package/coverage/src/index.ts.html +1360 -0
  23. package/coverage/src/path-utils.ts.html +649 -0
  24. package/coverage/src/ts-render.ts.html +592 -0
  25. package/coverage/tests/helpers.ts.html +826 -0
  26. package/coverage/tests/index.html +116 -0
  27. package/dist/codegen-contract.d.ts +56 -0
  28. package/dist/codegen-contract.d.ts.map +1 -0
  29. package/dist/codegen-operation.d.ts +25 -0
  30. package/dist/codegen-operation.d.ts.map +1 -0
  31. package/dist/codegen-plain-types.d.ts +10 -0
  32. package/dist/codegen-plain-types.d.ts.map +1 -0
  33. package/dist/codegen-sdk.d.ts +38 -0
  34. package/dist/codegen-sdk.d.ts.map +1 -0
  35. package/dist/index.d.ts +77 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +3162 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/path-utils.d.ts +15 -0
  40. package/dist/path-utils.d.ts.map +1 -0
  41. package/dist/ts-render.d.ts +20 -0
  42. package/dist/ts-render.d.ts.map +1 -0
  43. package/eslint.config.js +6 -0
  44. package/package.json +43 -0
  45. package/src/codegen-contract.ts +1082 -0
  46. package/src/codegen-operation.ts +815 -0
  47. package/src/codegen-plain-types.ts +272 -0
  48. package/src/codegen-sdk.ts +904 -0
  49. package/src/index.ts +425 -0
  50. package/src/path-utils.ts +188 -0
  51. package/src/ts-render.ts +169 -0
  52. package/tests/codegen-contract.test.ts +1004 -0
  53. package/tests/codegen-operation.test.ts +939 -0
  54. package/tests/codegen-plain-types.test.ts +636 -0
  55. package/tests/codegen-sdk.test.ts +1500 -0
  56. package/tests/codegen-server.test.ts +192 -0
  57. package/tests/helpers.ts +247 -0
  58. package/tests/pipeline.test.ts +372 -0
  59. package/tsconfig.json +9 -0
  60. package/vitest.config.ts +14 -0
@@ -0,0 +1,636 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generatePlainTypes } from '../src/codegen-plain-types.js';
3
+ import type { ContractCodegenContext } from '@contractkit/core';
4
+ import {
5
+ scalarType,
6
+ arrayType,
7
+ tupleType,
8
+ recordType,
9
+ enumType,
10
+ literalType,
11
+ unionType,
12
+ discriminatedUnionType,
13
+ refType,
14
+ lazyType,
15
+ inlineObjectType,
16
+ field,
17
+ model,
18
+ contractRoot,
19
+ } from './helpers.js';
20
+
21
+ describe('generatePlainTypes', () => {
22
+ // ─── Simple model ──────────────────────────────────────────────
23
+
24
+ describe('simple model', () => {
25
+ it('generates interface with fields', () => {
26
+ const root = contractRoot([model('User', [field('name', scalarType('string')), field('age', scalarType('number'))])]);
27
+ const output = generatePlainTypes(root);
28
+ expect(output).toContain('export interface User {');
29
+ expect(output).toContain('name: string;');
30
+ expect(output).toContain('age: number;');
31
+ });
32
+
33
+ it('does not contain Zod imports or references', () => {
34
+ const root = contractRoot([model('User', [field('name', scalarType('string'))])]);
35
+ const output = generatePlainTypes(root);
36
+ expect(output).not.toContain('zod');
37
+ expect(output).not.toContain('z.');
38
+ expect(output).not.toContain('z.infer');
39
+ });
40
+
41
+ it('does not contain luxon imports for date fields', () => {
42
+ const root = contractRoot([model('Event', [field('startDate', scalarType('date')), field('endDate', scalarType('datetime'))])]);
43
+ const output = generatePlainTypes(root);
44
+ expect(output).not.toContain('luxon');
45
+ expect(output).not.toContain('DateTime');
46
+ expect(output).toContain('startDate: string;');
47
+ expect(output).toContain('endDate: string;');
48
+ });
49
+ });
50
+
51
+ // ─── Scalar type mapping ──────────────────────────────────────
52
+
53
+ describe('scalar type mapping', () => {
54
+ it('maps string types to string', () => {
55
+ const root = contractRoot([
56
+ model('M', [
57
+ field('s', scalarType('string')),
58
+ field('e', scalarType('email')),
59
+ field('u', scalarType('url')),
60
+ field('uid', scalarType('uuid')),
61
+ ]),
62
+ ]);
63
+ const output = generatePlainTypes(root);
64
+ expect(output).toContain('s: string;');
65
+ expect(output).toContain('e: string;');
66
+ expect(output).toContain('u: string;');
67
+ expect(output).toContain('uid: string;');
68
+ });
69
+
70
+ it('maps numeric types correctly', () => {
71
+ const root = contractRoot([
72
+ model('M', [field('n', scalarType('number')), field('i', scalarType('int')), field('b', scalarType('bigint'))]),
73
+ ]);
74
+ const output = generatePlainTypes(root);
75
+ expect(output).toContain('n: number;');
76
+ expect(output).toContain('i: number;');
77
+ expect(output).toContain('b: bigint;');
78
+ });
79
+
80
+ it('maps boolean type', () => {
81
+ const root = contractRoot([model('M', [field('active', scalarType('boolean'))])]);
82
+ const output = generatePlainTypes(root);
83
+ expect(output).toContain('active: boolean;');
84
+ });
85
+
86
+ it('maps special types correctly', () => {
87
+ const root = contractRoot([
88
+ model('M', [
89
+ field('u', scalarType('unknown')),
90
+ field('n', scalarType('null')),
91
+ field('o', scalarType('object')),
92
+ field('bin', scalarType('binary')),
93
+ ]),
94
+ ]);
95
+ const output = generatePlainTypes(root);
96
+ expect(output).toContain('u: unknown;');
97
+ expect(output).toContain('n: null;');
98
+ expect(output).toContain('o: Record<string, unknown>;');
99
+ expect(output).toContain('bin: Blob;');
100
+ });
101
+ });
102
+
103
+ // ─── Compound types ───────────────────────────────────────────
104
+
105
+ describe('compound types', () => {
106
+ it('renders array type', () => {
107
+ const root = contractRoot([model('M', [field('items', arrayType(scalarType('string')))])]);
108
+ const output = generatePlainTypes(root);
109
+ expect(output).toContain('items: string[];');
110
+ });
111
+
112
+ it('renders array of refs', () => {
113
+ const root = contractRoot([model('M', [field('users', arrayType(refType('User')))])]);
114
+ const output = generatePlainTypes(root);
115
+ expect(output).toContain('users: User[];');
116
+ });
117
+
118
+ it('wraps union item in parens when used as array element type', () => {
119
+ const root = contractRoot([model('M', [field('statuses', arrayType(enumType('pending', 'posted', 'archived')))])]);
120
+ const output = generatePlainTypes(root);
121
+ expect(output).toContain("statuses: ('pending' | 'posted' | 'archived')[];");
122
+ });
123
+
124
+ it('wraps union item in parens for array of union type', () => {
125
+ const root = contractRoot([model('M', [field('items', arrayType(unionType(scalarType('string'), scalarType('int'))))])]);
126
+ const output = generatePlainTypes(root);
127
+ expect(output).toContain('items: (string | number)[];');
128
+ });
129
+
130
+ it('renders tuple type', () => {
131
+ const root = contractRoot([model('M', [field('pair', tupleType(scalarType('number'), scalarType('string')))])]);
132
+ const output = generatePlainTypes(root);
133
+ expect(output).toContain('pair: [number, string];');
134
+ });
135
+
136
+ it('renders record type', () => {
137
+ const root = contractRoot([model('M', [field('data', recordType(scalarType('string'), scalarType('number')))])]);
138
+ const output = generatePlainTypes(root);
139
+ expect(output).toContain('data: Record<string, number>;');
140
+ });
141
+
142
+ it('renders enum type as union of literals', () => {
143
+ const root = contractRoot([model('M', [field('role', enumType('admin', 'user', 'guest'))])]);
144
+ const output = generatePlainTypes(root);
145
+ expect(output).toContain("role: 'admin' | 'user' | 'guest';");
146
+ });
147
+
148
+ it('renders literal types', () => {
149
+ const root = contractRoot([
150
+ model('M', [field('kind', literalType('message')), field('count', literalType(42)), field('flag', literalType(true))]),
151
+ ]);
152
+ const output = generatePlainTypes(root);
153
+ expect(output).toContain("kind: 'message';");
154
+ expect(output).toContain('count: 42;');
155
+ expect(output).toContain('flag: true;');
156
+ });
157
+
158
+ it('renders union type', () => {
159
+ const root = contractRoot([model('M', [field('value', unionType(scalarType('string'), scalarType('number')))])]);
160
+ const output = generatePlainTypes(root);
161
+ expect(output).toContain('value: string | number;');
162
+ });
163
+
164
+ it('renders discriminated union as a plain TS union (TS narrows on the discriminator)', () => {
165
+ const root = contractRoot([model('M', [field('method', discriminatedUnionType('kind', refType('Card'), refType('Bank')))])]);
166
+ const output = generatePlainTypes(root);
167
+ expect(output).toContain('method: Card | Bank;');
168
+ });
169
+
170
+ it('renders model reference as type name', () => {
171
+ const root = contractRoot([model('M', [field('user', refType('User'))])]);
172
+ const output = generatePlainTypes(root);
173
+ expect(output).toContain('user: User;');
174
+ });
175
+
176
+ it('renders lazy type transparently', () => {
177
+ const root = contractRoot([model('TreeNode', [field('children', arrayType(lazyType(refType('TreeNode'))))])]);
178
+ const output = generatePlainTypes(root);
179
+ expect(output).toContain('children: TreeNode[];');
180
+ });
181
+
182
+ it('renders inline object type', () => {
183
+ const root = contractRoot([
184
+ model('M', [field('data', inlineObjectType([field('key', scalarType('string')), field('value', scalarType('number'))]))]),
185
+ ]);
186
+ const output = generatePlainTypes(root);
187
+ expect(output).toContain('data: { key: string; value: number };');
188
+ });
189
+ });
190
+
191
+ // ─── Field modifiers ──────────────────────────────────────────
192
+
193
+ describe('field modifiers', () => {
194
+ it('renders optional field with ?', () => {
195
+ const root = contractRoot([model('M', [field('f', scalarType('string'), { optional: true })])]);
196
+ const output = generatePlainTypes(root);
197
+ expect(output).toContain('f?: string;');
198
+ });
199
+
200
+ it('renders nullable field with | null', () => {
201
+ const root = contractRoot([model('M', [field('f', scalarType('string'), { nullable: true })])]);
202
+ const output = generatePlainTypes(root);
203
+ expect(output).toContain('f: string | null;');
204
+ });
205
+
206
+ it('renders field with default as optional', () => {
207
+ const root = contractRoot([model('M', [field('active', scalarType('boolean'), { default: true })])]);
208
+ const output = generatePlainTypes(root);
209
+ expect(output).toContain('active?: boolean;');
210
+ });
211
+
212
+ it('renders nullable + optional field', () => {
213
+ const root = contractRoot([model('M', [field('f', scalarType('string'), { optional: true, nullable: true })])]);
214
+ const output = generatePlainTypes(root);
215
+ expect(output).toContain('f?: string | null;');
216
+ });
217
+ });
218
+
219
+ // ─── Type alias ────────────────────────────────────────────────
220
+
221
+ describe('type alias', () => {
222
+ it('generates type alias for type-only models', () => {
223
+ const root = contractRoot([model('Currency', [], { type: scalarType('string') })]);
224
+ const output = generatePlainTypes(root);
225
+ expect(output).toContain('export type Currency = string;');
226
+ expect(output).not.toContain('interface');
227
+ });
228
+
229
+ it('generates type alias for complex types', () => {
230
+ const root = contractRoot([model('UserIds', [], { type: arrayType(scalarType('uuid')) })]);
231
+ const output = generatePlainTypes(root);
232
+ expect(output).toContain('export type UserIds = string[];');
233
+ });
234
+ });
235
+
236
+ // ─── Visibility (read/write) ──────────────────────────────────
237
+
238
+ describe('visibility pattern', () => {
239
+ it('generates read and write interfaces for models with visibility', () => {
240
+ const root = contractRoot([
241
+ model('User', [
242
+ field('id', scalarType('uuid'), { visibility: 'readonly' }),
243
+ field('name', scalarType('string')),
244
+ field('password', scalarType('string'), { visibility: 'writeonly' }),
245
+ ]),
246
+ ]);
247
+ const output = generatePlainTypes(root);
248
+ expect(output).toContain('export interface User {');
249
+ expect(output).toContain('export interface UserInput {');
250
+ });
251
+
252
+ it('read interface omits writeonly fields', () => {
253
+ const root = contractRoot([
254
+ model('User', [
255
+ field('id', scalarType('uuid'), { visibility: 'readonly' }),
256
+ field('name', scalarType('string')),
257
+ field('password', scalarType('string'), { visibility: 'writeonly' }),
258
+ ]),
259
+ ]);
260
+ const output = generatePlainTypes(root);
261
+ const userSection = output.split('export interface User {')[1]!.split('}')[0]!;
262
+ expect(userSection).toContain('id: string;');
263
+ expect(userSection).toContain('name: string;');
264
+ expect(userSection).not.toContain('password');
265
+ });
266
+
267
+ it('write interface omits readonly fields', () => {
268
+ const root = contractRoot([
269
+ model('User', [
270
+ field('id', scalarType('uuid'), { visibility: 'readonly' }),
271
+ field('name', scalarType('string')),
272
+ field('password', scalarType('string'), { visibility: 'writeonly' }),
273
+ ]),
274
+ ]);
275
+ const output = generatePlainTypes(root);
276
+ const inputSection = output.split('export interface UserInput {')[1]!.split('}')[0]!;
277
+ expect(inputSection).toContain('name: string;');
278
+ expect(inputSection).toContain('password: string;');
279
+ expect(inputSection).not.toContain('id');
280
+ });
281
+ });
282
+
283
+ // ─── Transitive Input variants ─────────────────────────────────
284
+
285
+ describe('transitive Input variants', () => {
286
+ it('generates Input interface for model that references a visibility model (local)', () => {
287
+ const root = contractRoot([
288
+ model('Entry', [field('id', scalarType('uuid'), { visibility: 'readonly' }), field('amount', scalarType('bigint'))]),
289
+ model('Transaction', [field('entries', arrayType(refType('Entry')))]),
290
+ ]);
291
+ const output = generatePlainTypes(root);
292
+ expect(output).toContain('export interface TransactionInput {');
293
+ });
294
+
295
+ it('write interface of parent uses Input variant of referenced child', () => {
296
+ const root = contractRoot([
297
+ model('Entry', [field('id', scalarType('uuid'), { visibility: 'readonly' }), field('amount', scalarType('bigint'))]),
298
+ model('Transaction', [field('entries', arrayType(refType('Entry')))]),
299
+ ]);
300
+ const output = generatePlainTypes(root);
301
+ const inputSection = output.split('export interface TransactionInput {')[1]!.split('}')[0]!;
302
+ expect(inputSection).toContain('EntryInput');
303
+ expect(inputSection).not.toContain('Entry[]');
304
+ });
305
+
306
+ it('handles multi-level transitive chain', () => {
307
+ const root = contractRoot([
308
+ model('Leaf', [field('id', scalarType('uuid'), { visibility: 'readonly' })]),
309
+ model('Middle', [field('leaf', refType('Leaf'))]),
310
+ model('Top', [field('middle', refType('Middle'))]),
311
+ ]);
312
+ const output = generatePlainTypes(root);
313
+ expect(output).toContain('export interface MiddleInput {');
314
+ expect(output).toContain('export interface TopInput {');
315
+ const middleInputSection = output.split('export interface MiddleInput {')[1]!.split('}')[0]!;
316
+ expect(middleInputSection).toContain('LeafInput');
317
+ const topInputSection = output.split('export interface TopInput {')[1]!.split('}')[0]!;
318
+ expect(topInputSection).toContain('MiddleInput');
319
+ });
320
+
321
+ it('handles transitive ref from external context', () => {
322
+ const root = contractRoot([model('Transaction', [field('entries', arrayType(refType('ExternalEntry')))])]);
323
+ const context: ContractCodegenContext = {
324
+ currentOutPath: '/out/transaction.ts',
325
+ modelOutPaths: new Map([
326
+ ['ExternalEntry', '/out/entry.ts'],
327
+ ['ExternalEntryInput', '/out/entry.ts'],
328
+ ]),
329
+ modelsWithInput: new Set(['ExternalEntry']),
330
+ };
331
+ const output = generatePlainTypes(root, context);
332
+ expect(output).toContain('export interface TransactionInput {');
333
+ const inputSection = output.split('export interface TransactionInput {')[1]!.split('}')[0]!;
334
+ expect(inputSection).toContain('ExternalEntryInput');
335
+ expect(output).toContain("import type { ExternalEntryInput } from './entry.js';");
336
+ });
337
+
338
+ it('model without visibility that only refs plain models stays simple', () => {
339
+ const root = contractRoot([
340
+ model('PlainChild', [field('name', scalarType('string'))]),
341
+ model('Parent', [field('child', refType('PlainChild'))]),
342
+ ]);
343
+ const output = generatePlainTypes(root);
344
+ expect(output).not.toContain('ParentInput');
345
+ expect(output).not.toContain('PlainChildInput');
346
+ });
347
+ });
348
+
349
+ // ─── Inheritance ──────────────────────────────────────────────
350
+
351
+ describe('inheritance', () => {
352
+ it('generates extends clause for models with a base', () => {
353
+ const root = contractRoot([model('Admin', [field('role', scalarType('string'))], { bases: ['User'] })]);
354
+ const output = generatePlainTypes(root);
355
+ expect(output).toContain('export interface Admin extends User {');
356
+ });
357
+
358
+ it('generates extends for visibility model with base (base has no Input variant)', () => {
359
+ const root = contractRoot([
360
+ model('Admin', [field('id', scalarType('uuid'), { visibility: 'readonly' }), field('role', scalarType('string'))], {
361
+ bases: ['User'],
362
+ }),
363
+ ]);
364
+ const output = generatePlainTypes(root);
365
+ expect(output).toContain('export interface Admin extends User {');
366
+ // User not in modelsWithInput — AdminInput extends User (not UserInput)
367
+ expect(output).toContain('export interface AdminInput extends User {');
368
+ });
369
+
370
+ it('generates extends for visibility model with base (base has Input variant)', () => {
371
+ const root = contractRoot([
372
+ model('Admin', [field('id', scalarType('uuid'), { visibility: 'readonly' }), field('role', scalarType('string'))], {
373
+ bases: ['User'],
374
+ }),
375
+ ]);
376
+ const output = generatePlainTypes(root, {
377
+ modelsWithInput: new Set(['User']),
378
+ currentOutPath: '/out/admin.ts',
379
+ modelOutPaths: new Map(),
380
+ });
381
+ expect(output).toContain('export interface Admin extends User {');
382
+ // User is in modelsWithInput — AdminInput extends UserInput
383
+ expect(output).toContain('export interface AdminInput extends UserInput {');
384
+ });
385
+
386
+ it('child extends parent in same file both get Input when parent has visibility', () => {
387
+ const root = contractRoot([
388
+ model('User', [field('id', scalarType('uuid'), { visibility: 'readonly' }), field('name', scalarType('string'))]),
389
+ model('Admin', [field('role', scalarType('string'))], { bases: ['User'] }),
390
+ ]);
391
+ const output = generatePlainTypes(root);
392
+ // User has visibility fields → needs Input; Admin extends User → also needs Input
393
+ expect(output).toContain('export interface User {');
394
+ expect(output).toContain('export interface UserInput {');
395
+ expect(output).toContain('export interface Admin extends User {');
396
+ expect(output).toContain('export interface AdminInput extends UserInput {');
397
+ });
398
+
399
+ it('emits a multi-base extends clause', () => {
400
+ const root = contractRoot([model('Test5', [field('e', scalarType('string'))], { bases: ['A', 'B', 'C', 'D'] })]);
401
+ const output = generatePlainTypes(root);
402
+ expect(output).toContain('export interface Test5 extends A, B, C, D {');
403
+ });
404
+
405
+ it('omits override fields from each base via Omit', () => {
406
+ const root = contractRoot([
407
+ model('Test5', [field('a', scalarType('int'), { override: true }), field('e', scalarType('string'))], { bases: ['A', 'B'] }),
408
+ ]);
409
+ const output = generatePlainTypes(root);
410
+ expect(output).toContain("export interface Test5 extends Omit<A, 'a'>, Omit<B, 'a'> {");
411
+ });
412
+
413
+ it('combines multiple override fields into a union Omit key', () => {
414
+ const root = contractRoot([
415
+ model('Test5', [field('a', scalarType('int'), { override: true }), field('b', scalarType('string'), { override: true })], {
416
+ bases: ['A'],
417
+ }),
418
+ ]);
419
+ const output = generatePlainTypes(root);
420
+ expect(output).toContain("export interface Test5 extends Omit<A, 'a' | 'b'> {");
421
+ });
422
+ });
423
+
424
+ // ─── Type alias Input variants ────────────────────────────────
425
+
426
+ describe('type alias Input variants', () => {
427
+ it('type alias referencing model with Input variant gets its own Input variant', () => {
428
+ const root = contractRoot([
429
+ model('Pagination', [field('page', scalarType('int')), field('total', scalarType('int'), { visibility: 'readonly' })]),
430
+ model('ListQuery', [], {
431
+ type: {
432
+ kind: 'intersection',
433
+ members: [
434
+ { kind: 'ref', name: 'Pagination' },
435
+ { kind: 'inlineObject', fields: [field('status', scalarType('string'), { optional: true })] },
436
+ ],
437
+ },
438
+ }),
439
+ ]);
440
+ const output = generatePlainTypes(root);
441
+ expect(output).toContain('export type ListQuery = Pagination & {');
442
+ expect(output).toContain('export type ListQueryInput = PaginationInput & {');
443
+ });
444
+
445
+ it('imports PaginationInput when type alias references external Pagination with Input variant', () => {
446
+ const root = contractRoot([
447
+ model('ListQuery', [], {
448
+ type: {
449
+ kind: 'intersection',
450
+ members: [
451
+ { kind: 'ref', name: 'Pagination' },
452
+ { kind: 'inlineObject', fields: [field('status', scalarType('string'), { optional: true })] },
453
+ ],
454
+ },
455
+ }),
456
+ ]);
457
+ const output = generatePlainTypes(root, {
458
+ modelsWithInput: new Set(['Pagination']),
459
+ currentOutPath: '/out/list.query.ts',
460
+ modelOutPaths: new Map([
461
+ ['Pagination', '/out/pagination.ts'],
462
+ ['PaginationInput', '/out/pagination.ts'],
463
+ ]),
464
+ });
465
+ expect(output).toContain("import type { Pagination } from './pagination.js';");
466
+ expect(output).toContain("import type { PaginationInput } from './pagination.js';");
467
+ expect(output).toContain('export type ListQueryInput = PaginationInput & {');
468
+ });
469
+
470
+ it('type alias NOT referencing any model with Input stays simple', () => {
471
+ const root = contractRoot([model('UserId', [], { type: { kind: 'scalar', name: 'uuid' } })]);
472
+ const output = generatePlainTypes(root);
473
+ expect(output).toContain('export type UserId =');
474
+ expect(output).not.toContain('UserIdInput');
475
+ });
476
+ });
477
+
478
+ // ─── JSDoc comments ───────────────────────────────────────────
479
+
480
+ describe('comments', () => {
481
+ it('includes model description in JSDoc', () => {
482
+ const root = contractRoot([model('User', [field('name', scalarType('string'))], { description: 'A system user' })]);
483
+ const output = generatePlainTypes(root);
484
+ expect(output).toContain('* A system user');
485
+ });
486
+
487
+ it('includes source location in JSDoc', () => {
488
+ const root = contractRoot([model('User', [field('name', scalarType('string'))], { loc: { file: 'user.ck', line: 5 } })]);
489
+ const output = generatePlainTypes(root);
490
+ expect(output).toContain('file://./user.ck#L5');
491
+ });
492
+ });
493
+
494
+ // ─── Import resolution ─────────────────────────────────────────
495
+
496
+ describe('import resolution', () => {
497
+ it('uses type-only imports for external references', () => {
498
+ const root = contractRoot([model('Counterparty', [field('accounts', arrayType(refType('CounterpartyAccount')))])]);
499
+ const output = generatePlainTypes(root);
500
+ expect(output).toContain("import type { CounterpartyAccount } from './counterparty.account.js';");
501
+ });
502
+
503
+ it('does not import locally defined models', () => {
504
+ const root = contractRoot([
505
+ model('Currency', [field('code', scalarType('string'))]),
506
+ model('Account', [field('currency', refType('Currency'))]),
507
+ ]);
508
+ const output = generatePlainTypes(root);
509
+ expect(output).not.toContain('import type { Currency }');
510
+ });
511
+
512
+ it('resolves imports using modelOutPaths context', () => {
513
+ const root = contractRoot([model('Transfer', [field('account', refType('LedgerAccount'))])]);
514
+ const context: ContractCodegenContext = {
515
+ currentOutPath: '/out/modules/transfers/transfer.ts',
516
+ modelOutPaths: new Map([['LedgerAccount', '/out/modules/ledger/ledger.account.ts']]),
517
+ };
518
+ const output = generatePlainTypes(root, context);
519
+ expect(output).toContain("import type { LedgerAccount } from '../ledger/ledger.account.js';");
520
+ });
521
+
522
+ it('falls back to pascalToDotCase when ref not in modelOutPaths', () => {
523
+ const root = contractRoot([model('Order', [field('item', refType('UnknownExternal'))])]);
524
+ const context: ContractCodegenContext = {
525
+ currentOutPath: '/out/order.ts',
526
+ modelOutPaths: new Map(),
527
+ };
528
+ const output = generatePlainTypes(root, context);
529
+ expect(output).toContain("import type { UnknownExternal } from './unknown.external.js';");
530
+ });
531
+
532
+ it('imports base model when inherited from external', () => {
533
+ const root = contractRoot([model('Admin', [field('role', scalarType('string'))], { bases: ['User'] })]);
534
+ const output = generatePlainTypes(root);
535
+ expect(output).toContain("import type { User } from './user.js';");
536
+ });
537
+ });
538
+
539
+ // ─── Topological sorting ───────────────────────────────────────
540
+
541
+ describe('topological sorting', () => {
542
+ it('emits dependencies before dependents', () => {
543
+ const root = contractRoot([model('B', [field('a', refType('A'))]), model('A', [field('name', scalarType('string'))])]);
544
+ const output = generatePlainTypes(root);
545
+ const aIndex = output.indexOf('export interface A {');
546
+ const bIndex = output.indexOf('export interface B {');
547
+ expect(aIndex).toBeLessThan(bIndex);
548
+ });
549
+ });
550
+
551
+ // ─── Multiple models ───────────────────────────────────────────
552
+
553
+ describe('multiple models', () => {
554
+ it('generates all models in one output', () => {
555
+ const root = contractRoot([
556
+ model('User', [field('id', scalarType('uuid')), field('name', scalarType('string'))]),
557
+ model('Post', [field('id', scalarType('uuid')), field('title', scalarType('string')), field('author', refType('User'))]),
558
+ ]);
559
+ const output = generatePlainTypes(root);
560
+ expect(output).toContain('export interface User {');
561
+ expect(output).toContain('export interface Post {');
562
+ expect(output).toContain('author: User;');
563
+ // No import for User since it's local
564
+ expect(output).not.toContain('import type { User }');
565
+ });
566
+ });
567
+ });
568
+
569
+ describe('field name quoting', () => {
570
+ it('quotes hyphenated field names in interfaces', () => {
571
+ const root = contractRoot([
572
+ model('WebhookHeaders', [
573
+ field('x-topic', scalarType('string')),
574
+ field('x-event-id', scalarType('string')),
575
+ field('normalField', scalarType('string')),
576
+ ]),
577
+ ]);
578
+ const output = generatePlainTypes(root);
579
+ expect(output).toContain("'x-topic': string;");
580
+ expect(output).toContain("'x-event-id': string;");
581
+ expect(output).toContain('normalField: string;');
582
+ });
583
+
584
+ it('quotes hyphenated optional fields correctly', () => {
585
+ const root = contractRoot([model('Headers', [field('x-request-id', scalarType('string'), { optional: true })])]);
586
+ const output = generatePlainTypes(root);
587
+ expect(output).toContain("'x-request-id'?: string;");
588
+ });
589
+
590
+ // ─── Output variants (format(output=...)) ──────────────────────────────
591
+
592
+ describe('Output variants', () => {
593
+ it('emits a snake_case Output interface for a model with output=snake', () => {
594
+ const root = contractRoot([
595
+ model('AuthToken', [field('accessToken', scalarType('string')), field('refreshToken', scalarType('string'), { optional: true })], {
596
+ outputCase: 'snake',
597
+ }),
598
+ ]);
599
+ const output = generatePlainTypes(root, {
600
+ modelOutPaths: new Map(),
601
+ currentOutPath: '/tmp/auth.ts',
602
+ modelsWithOutput: new Set(['AuthToken']),
603
+ });
604
+ // Base interface stays camelCase (developer-facing).
605
+ expect(output).toContain('export interface AuthToken {');
606
+ expect(output).toContain('accessToken: string;');
607
+ // Output interface uses snake_case keys for the wire shape.
608
+ expect(output).toContain('export interface AuthTokenOutput {');
609
+ expect(output).toContain('access_token: string;');
610
+ expect(output).toContain('refresh_token?: string;');
611
+ });
612
+
613
+ it('substitutes nested model refs with their Output variants in transitive containers', () => {
614
+ const root = contractRoot([
615
+ model('AuthToken', [field('accessToken', scalarType('string'))], { outputCase: 'snake' }),
616
+ model('Wrapper', [field('token', refType('AuthToken')), field('issuedAt', scalarType('string'))]),
617
+ ]);
618
+ const output = generatePlainTypes(root, {
619
+ modelOutPaths: new Map(),
620
+ currentOutPath: '/tmp/auth.ts',
621
+ modelsWithOutput: new Set(['AuthToken', 'Wrapper']),
622
+ });
623
+ // Wrapper itself doesn't have an outputCase, so its keys stay camelCase
624
+ // but AuthToken refs become AuthTokenOutput in the wire shape.
625
+ expect(output).toContain('export interface WrapperOutput {');
626
+ expect(output).toContain('token: AuthTokenOutput;');
627
+ expect(output).toContain('issuedAt: string;');
628
+ });
629
+
630
+ it('does not emit Output interface for models without outputCase', () => {
631
+ const root = contractRoot([model('User', [field('firstName', scalarType('string'))])]);
632
+ const output = generatePlainTypes(root);
633
+ expect(output).not.toContain('UserOutput');
634
+ });
635
+ });
636
+ });