@workos/oagen-emitters 0.0.1 → 0.2.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 (49) hide show
  1. package/.github/workflows/release-please.yml +9 -1
  2. package/.husky/commit-msg +0 -0
  3. package/.husky/pre-commit +1 -0
  4. package/.husky/pre-push +1 -0
  5. package/.oxfmtrc.json +8 -1
  6. package/.prettierignore +1 -0
  7. package/.release-please-manifest.json +3 -0
  8. package/.vscode/settings.json +3 -0
  9. package/CHANGELOG.md +61 -0
  10. package/README.md +2 -2
  11. package/dist/index.d.mts +7 -0
  12. package/dist/index.d.mts.map +1 -0
  13. package/dist/index.mjs +4070 -0
  14. package/dist/index.mjs.map +1 -0
  15. package/package.json +14 -18
  16. package/release-please-config.json +11 -0
  17. package/smoke/sdk-dotnet.ts +17 -3
  18. package/smoke/sdk-elixir.ts +17 -3
  19. package/smoke/sdk-go.ts +21 -4
  20. package/smoke/sdk-kotlin.ts +23 -4
  21. package/smoke/sdk-node.ts +15 -3
  22. package/smoke/sdk-ruby.ts +17 -3
  23. package/smoke/sdk-rust.ts +16 -3
  24. package/src/node/client.ts +521 -206
  25. package/src/node/common.ts +74 -4
  26. package/src/node/config.ts +1 -0
  27. package/src/node/enums.ts +53 -9
  28. package/src/node/errors.ts +82 -3
  29. package/src/node/fixtures.ts +87 -16
  30. package/src/node/index.ts +66 -10
  31. package/src/node/manifest.ts +4 -2
  32. package/src/node/models.ts +251 -124
  33. package/src/node/naming.ts +107 -3
  34. package/src/node/resources.ts +1162 -108
  35. package/src/node/serializers.ts +512 -52
  36. package/src/node/tests.ts +650 -110
  37. package/src/node/type-map.ts +89 -11
  38. package/src/node/utils.ts +426 -113
  39. package/test/node/client.test.ts +1083 -20
  40. package/test/node/enums.test.ts +73 -4
  41. package/test/node/errors.test.ts +4 -21
  42. package/test/node/models.test.ts +499 -5
  43. package/test/node/naming.test.ts +14 -7
  44. package/test/node/resources.test.ts +1568 -9
  45. package/test/node/serializers.test.ts +241 -5
  46. package/tsconfig.json +2 -3
  47. package/{tsup.config.ts → tsdown.config.ts} +1 -1
  48. package/dist/index.d.ts +0 -5
  49. package/dist/index.js +0 -2158
@@ -15,7 +15,6 @@ const ctx: EmitterContext = {
15
15
  namespace: 'workos',
16
16
  namespacePascal: 'WorkOS',
17
17
  spec: emptySpec,
18
- irVersion: 6,
19
18
  };
20
19
 
21
20
  describe('generateEnums', () => {
@@ -31,7 +30,13 @@ describe('generateEnums', () => {
31
30
  name: 'getOrganization',
32
31
  httpMethod: 'get',
33
32
  path: '/organizations/{id}',
34
- pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
33
+ pathParams: [
34
+ {
35
+ name: 'id',
36
+ type: { kind: 'primitive', type: 'string' },
37
+ required: true,
38
+ },
39
+ ],
35
40
  queryParams: [],
36
41
  headerParams: [],
37
42
  response: {
@@ -77,7 +82,13 @@ describe('generateEnums', () => {
77
82
  name: 'getOrganization',
78
83
  httpMethod: 'get',
79
84
  path: '/organizations/{id}',
80
- pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
85
+ pathParams: [
86
+ {
87
+ name: 'id',
88
+ type: { kind: 'primitive', type: 'string' },
89
+ required: true,
90
+ },
91
+ ],
81
92
  queryParams: [],
82
93
  headerParams: [],
83
94
  response: { kind: 'enum', name: 'OrgStatus' },
@@ -104,13 +115,71 @@ describe('generateEnums', () => {
104
115
  expect(files[0].path).toBe('src/organizations/interfaces/org-status.interface.ts');
105
116
  });
106
117
 
118
+ it('derives PascalCase member names when merging new enum values into baseline', () => {
119
+ const enums: Enum[] = [
120
+ {
121
+ name: 'OrganizationDomainState',
122
+ values: [
123
+ { name: 'FAILED', value: 'failed' },
124
+ { name: 'PENDING', value: 'pending' },
125
+ { name: 'VERIFIED', value: 'verified' },
126
+ { name: 'LEGACY_VERIFIED', value: 'legacy_verified' },
127
+ { name: 'UNVERIFIED', value: 'unverified' },
128
+ ],
129
+ },
130
+ ];
131
+
132
+ const testCtx: EmitterContext = {
133
+ ...ctx,
134
+ apiSurface: {
135
+ language: 'node',
136
+ extractedFrom: 'test',
137
+ extractedAt: '2024-01-01',
138
+ classes: {},
139
+ interfaces: {},
140
+ typeAliases: {},
141
+ enums: {
142
+ OrganizationDomainState: {
143
+ name: 'OrganizationDomainState',
144
+ members: {
145
+ Failed: 'failed',
146
+ Pending: 'pending',
147
+ Verified: 'verified',
148
+ },
149
+ },
150
+ },
151
+ exports: {},
152
+ },
153
+ };
154
+
155
+ const files = generateEnums(enums, testCtx);
156
+ const content = files[0].content;
157
+
158
+ // Existing members should be preserved as-is
159
+ expect(content).toContain("Failed = 'failed',");
160
+ expect(content).toContain("Pending = 'pending',");
161
+ expect(content).toContain("Verified = 'verified',");
162
+
163
+ // New members should be PascalCase, not lowercased
164
+ expect(content).toContain("LegacyVerified = 'legacy_verified',");
165
+ expect(content).toContain("Unverified = 'unverified',");
166
+
167
+ // Should NOT produce lowercased member names
168
+ expect(content).not.toContain('legacyverified');
169
+ });
170
+
107
171
  it('renders @deprecated on enum values', () => {
108
172
  const enums: Enum[] = [
109
173
  {
110
174
  name: 'Status',
111
175
  values: [
112
176
  { name: 'ACTIVE', value: 'active' },
113
- { name: 'LEGACY', value: 'legacy', description: 'No longer supported.', deprecated: true },
177
+ {
178
+ name: 'LEGACY',
179
+ value: 'legacy',
180
+ description: 'No longer supported.',
181
+ deprecated: true,
182
+ },
114
183
  { name: 'OLD', value: 'old', deprecated: true },
115
184
  ],
116
185
  },
@@ -1,26 +1,9 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { generateErrors } from '../../src/node/errors.js';
3
- import type { EmitterContext, ApiSpec } from '@workos/oagen';
4
-
5
- const emptySpec: ApiSpec = {
6
- name: 'Test',
7
- version: '1.0.0',
8
- baseUrl: '',
9
- services: [],
10
- models: [],
11
- enums: [],
12
- };
13
-
14
- const ctx: EmitterContext = {
15
- namespace: 'workos',
16
- namespacePascal: 'WorkOS',
17
- spec: emptySpec,
18
- irVersion: 6,
19
- };
20
3
 
21
4
  describe('generateErrors', () => {
22
5
  it('generates all exception classes', () => {
23
- const files = generateErrors(ctx);
6
+ const files = generateErrors();
24
7
 
25
8
  const names = files.map((f) => f.path);
26
9
  expect(names).toContain('src/common/exceptions/bad-request.exception.ts');
@@ -35,7 +18,7 @@ describe('generateErrors', () => {
35
18
  });
36
19
 
37
20
  it('generates NotFoundException with correct status', () => {
38
- const files = generateErrors(ctx);
21
+ const files = generateErrors();
39
22
  const notFoundFile = files.find((f) => f.path.includes('not-found.exception.ts'))!;
40
23
 
41
24
  expect(notFoundFile.content).toContain('export class NotFoundException extends Error');
@@ -44,7 +27,7 @@ describe('generateErrors', () => {
44
27
  });
45
28
 
46
29
  it('generates RateLimitExceededException with retryAfter', () => {
47
- const files = generateErrors(ctx);
30
+ const files = generateErrors();
48
31
  const rateLimitFile = files.find((f) => f.path.includes('rate-limit-exceeded.exception.ts'))!;
49
32
 
50
33
  expect(rateLimitFile.content).toContain('export class RateLimitExceededException extends Error');
@@ -53,7 +36,7 @@ describe('generateErrors', () => {
53
36
  });
54
37
 
55
38
  it('generates exception barrel with all exports', () => {
56
- const files = generateErrors(ctx);
39
+ const files = generateErrors();
57
40
  const barrel = files.find((f) => f.path === 'src/common/exceptions/index.ts')!;
58
41
 
59
42
  expect(barrel.content).toContain('export { BadRequestException }');
@@ -15,7 +15,6 @@ const ctx: EmitterContext = {
15
15
  namespace: 'workos',
16
16
  namespacePascal: 'WorkOS',
17
17
  spec: emptySpec,
18
- irVersion: 6,
19
18
  };
20
19
 
21
20
  describe('generateModels', () => {
@@ -31,7 +30,13 @@ describe('generateModels', () => {
31
30
  name: 'getOrganization',
32
31
  httpMethod: 'get',
33
32
  path: '/organizations/{id}',
34
- pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
33
+ pathParams: [
34
+ {
35
+ name: 'id',
36
+ type: { kind: 'primitive', type: 'string' },
37
+ required: true,
38
+ },
39
+ ],
35
40
  queryParams: [],
36
41
  headerParams: [],
37
42
  response: { kind: 'model', name: 'Organization' },
@@ -85,7 +90,7 @@ describe('generateModels', () => {
85
90
  expect(files[0].content).toContain('export interface Organization {');
86
91
  expect(files[0].content).toContain(' id: string;');
87
92
  expect(files[0].content).toContain(' name: string;');
88
- expect(files[0].content).toContain(' createdAt: string;');
93
+ expect(files[0].content).toContain(' createdAt: Date;');
89
94
  expect(files[0].content).toContain(' externalId?: string | null;');
90
95
 
91
96
  // Response interface has snake_case fields
@@ -102,7 +107,13 @@ describe('generateModels', () => {
102
107
  name: 'getOrganization',
103
108
  httpMethod: 'get',
104
109
  path: '/organizations/{id}',
105
- pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
110
+ pathParams: [
111
+ {
112
+ name: 'id',
113
+ type: { kind: 'primitive', type: 'string' },
114
+ required: true,
115
+ },
116
+ ],
106
117
  queryParams: [],
107
118
  headerParams: [],
108
119
  response: { kind: 'model', name: 'Organization' },
@@ -248,7 +259,13 @@ describe('generateModels', () => {
248
259
  name: 'getOrganization',
249
260
  httpMethod: 'get',
250
261
  path: '/organizations/{id}',
251
- pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
262
+ pathParams: [
263
+ {
264
+ name: 'id',
265
+ type: { kind: 'primitive', type: 'string' },
266
+ required: true,
267
+ },
268
+ ],
252
269
  queryParams: [],
253
270
  headerParams: [],
254
271
  response: { kind: 'model', name: 'Organization' },
@@ -298,4 +315,481 @@ describe('generateModels', () => {
298
315
  // Field with only deprecated gets single-line JSDoc
299
316
  expect(content).toContain(' /** @deprecated */');
300
317
  });
318
+
319
+ it('renders field-level JSDoc from OpenAPI descriptions', () => {
320
+ const service: Service = {
321
+ name: 'Organizations',
322
+ operations: [
323
+ {
324
+ name: 'getOrganization',
325
+ httpMethod: 'get',
326
+ path: '/organizations/{id}',
327
+ pathParams: [
328
+ {
329
+ name: 'id',
330
+ type: { kind: 'primitive', type: 'string' },
331
+ required: true,
332
+ },
333
+ ],
334
+ queryParams: [],
335
+ headerParams: [],
336
+ response: { kind: 'model', name: 'Organization' },
337
+ errors: [],
338
+ injectIdempotencyKey: false,
339
+ },
340
+ ],
341
+ };
342
+
343
+ const models: Model[] = [
344
+ {
345
+ name: 'Organization',
346
+ description: 'An organization in the WorkOS system.',
347
+ fields: [
348
+ {
349
+ name: 'id',
350
+ type: { kind: 'primitive', type: 'string' },
351
+ required: true,
352
+ description: 'Unique identifier for the organization.',
353
+ },
354
+ {
355
+ name: 'name',
356
+ type: { kind: 'primitive', type: 'string' },
357
+ required: true,
358
+ description: 'The display name of the organization.',
359
+ },
360
+ {
361
+ name: 'created_at',
362
+ type: { kind: 'primitive', type: 'string', format: 'date-time' },
363
+ required: true,
364
+ // No description — should not get JSDoc
365
+ },
366
+ {
367
+ name: 'allow_profiles_outside_organization',
368
+ type: { kind: 'primitive', type: 'boolean' },
369
+ required: false,
370
+ description:
371
+ 'Whether connections within the organization allow profiles\nthat do not have a domain that is verified by the organization.',
372
+ },
373
+ ],
374
+ },
375
+ ];
376
+
377
+ const ctxWithServices: EmitterContext = {
378
+ ...ctx,
379
+ spec: { ...emptySpec, services: [service], models },
380
+ };
381
+
382
+ const files = generateModels(models, ctxWithServices);
383
+ const content = files[0].content;
384
+
385
+ // Model-level JSDoc is emitted
386
+ expect(content).toContain('/** An organization in the WorkOS system. */');
387
+
388
+ // Fields with description get per-field JSDoc
389
+ expect(content).toContain('/** Unique identifier for the organization. */');
390
+ expect(content).toContain('/** The display name of the organization. */');
391
+
392
+ // Multiline description renders correctly
393
+ expect(content).toContain(
394
+ ' /**\n * Whether connections within the organization allow profiles\n * that do not have a domain that is verified by the organization.\n */',
395
+ );
396
+
397
+ // Field without description does NOT get JSDoc
398
+ const lines = content.split('\n');
399
+ const createdAtIdx = lines.findIndex((l) => l.includes('createdAt'));
400
+ expect(createdAtIdx).toBeGreaterThan(0);
401
+ // The line before createdAt should not be a JSDoc closing tag
402
+ expect(lines[createdAtIdx - 1].trim()).not.toBe('*/');
403
+ });
404
+
405
+ it('renders readOnly/writeOnly/default annotations', () => {
406
+ const service: Service = {
407
+ name: 'Organizations',
408
+ operations: [
409
+ {
410
+ name: 'getOrganization',
411
+ httpMethod: 'get',
412
+ path: '/organizations/{id}',
413
+ pathParams: [
414
+ {
415
+ name: 'id',
416
+ type: { kind: 'primitive', type: 'string' },
417
+ required: true,
418
+ },
419
+ ],
420
+ queryParams: [],
421
+ headerParams: [],
422
+ response: { kind: 'model', name: 'Organization' },
423
+ errors: [],
424
+ injectIdempotencyKey: false,
425
+ },
426
+ ],
427
+ };
428
+
429
+ const models: Model[] = [
430
+ {
431
+ name: 'Organization',
432
+ fields: [
433
+ {
434
+ name: 'id',
435
+ type: { kind: 'primitive', type: 'string' },
436
+ required: true,
437
+ readOnly: true,
438
+ },
439
+ {
440
+ name: 'secret_key',
441
+ type: { kind: 'primitive', type: 'string' },
442
+ required: true,
443
+ writeOnly: true,
444
+ },
445
+ {
446
+ name: 'status',
447
+ type: { kind: 'primitive', type: 'string' },
448
+ required: false,
449
+ default: 'active',
450
+ },
451
+ ],
452
+ },
453
+ ];
454
+
455
+ const ctxWithServices: EmitterContext = {
456
+ ...ctx,
457
+ spec: { ...emptySpec, services: [service], models },
458
+ };
459
+
460
+ const files = generateModels(models, ctxWithServices);
461
+ const content = files[0].content;
462
+
463
+ // readOnly field gets @readonly JSDoc and readonly TS modifier
464
+ expect(content).toContain('/** @readonly */');
465
+ expect(content).toContain(' readonly id: string;');
466
+
467
+ // writeOnly field gets @writeonly JSDoc
468
+ expect(content).toContain('/** @writeonly */');
469
+
470
+ // default field gets @default JSDoc
471
+ expect(content).toContain('@default "active"');
472
+ });
473
+
474
+ it('skips per-domain ListMetadata models (Fix #4)', () => {
475
+ const service: Service = {
476
+ name: 'Connections',
477
+ operations: [
478
+ {
479
+ name: 'listConnections',
480
+ httpMethod: 'get',
481
+ path: '/connections',
482
+ pathParams: [],
483
+ queryParams: [],
484
+ headerParams: [],
485
+ response: { kind: 'model', name: 'ConnectionList' },
486
+ errors: [],
487
+ injectIdempotencyKey: false,
488
+ pagination: {
489
+ strategy: 'cursor',
490
+ param: 'after',
491
+ itemType: { kind: 'model', name: 'Connection' },
492
+ },
493
+ },
494
+ ],
495
+ };
496
+
497
+ const models: Model[] = [
498
+ {
499
+ name: 'ConnectionListListMetadata',
500
+ fields: [
501
+ {
502
+ name: 'before',
503
+ type: {
504
+ kind: 'nullable',
505
+ inner: { kind: 'primitive', type: 'string' },
506
+ },
507
+ required: false,
508
+ },
509
+ {
510
+ name: 'after',
511
+ type: {
512
+ kind: 'nullable',
513
+ inner: { kind: 'primitive', type: 'string' },
514
+ },
515
+ required: false,
516
+ },
517
+ ],
518
+ },
519
+ {
520
+ name: 'Connection',
521
+ fields: [
522
+ {
523
+ name: 'id',
524
+ type: { kind: 'primitive', type: 'string' },
525
+ required: true,
526
+ },
527
+ ],
528
+ },
529
+ ];
530
+
531
+ const ctxWithServices: EmitterContext = {
532
+ ...ctx,
533
+ spec: { ...emptySpec, services: [service], models },
534
+ };
535
+
536
+ const files = generateModels(models, ctxWithServices);
537
+
538
+ // The ListMetadata model should be skipped entirely
539
+ const listMetadataFile = files.find((f) => f.path.includes('list-metadata'));
540
+ expect(listMetadataFile).toBeUndefined();
541
+
542
+ // The Connection model should still be generated
543
+ const connectionFile = files.find((f) => f.path.includes('connection.interface.ts'));
544
+ expect(connectionFile).toBeDefined();
545
+ });
546
+
547
+ it('skips per-domain list wrapper models (Fix #6)', () => {
548
+ const service: Service = {
549
+ name: 'Connections',
550
+ operations: [
551
+ {
552
+ name: 'listConnections',
553
+ httpMethod: 'get',
554
+ path: '/connections',
555
+ pathParams: [],
556
+ queryParams: [],
557
+ headerParams: [],
558
+ response: { kind: 'model', name: 'ConnectionList' },
559
+ errors: [],
560
+ injectIdempotencyKey: false,
561
+ pagination: {
562
+ strategy: 'cursor',
563
+ param: 'after',
564
+ itemType: { kind: 'model', name: 'Connection' },
565
+ },
566
+ },
567
+ ],
568
+ };
569
+
570
+ const models: Model[] = [
571
+ {
572
+ name: 'ConnectionList',
573
+ fields: [
574
+ {
575
+ name: 'object',
576
+ type: { kind: 'literal', value: 'list' },
577
+ required: true,
578
+ },
579
+ {
580
+ name: 'data',
581
+ type: {
582
+ kind: 'array',
583
+ items: { kind: 'model', name: 'Connection' },
584
+ },
585
+ required: true,
586
+ },
587
+ {
588
+ name: 'list_metadata',
589
+ type: { kind: 'model', name: 'ConnectionListListMetadata' },
590
+ required: true,
591
+ },
592
+ ],
593
+ },
594
+ {
595
+ name: 'ConnectionListListMetadata',
596
+ fields: [
597
+ {
598
+ name: 'before',
599
+ type: {
600
+ kind: 'nullable',
601
+ inner: { kind: 'primitive', type: 'string' },
602
+ },
603
+ required: false,
604
+ },
605
+ {
606
+ name: 'after',
607
+ type: {
608
+ kind: 'nullable',
609
+ inner: { kind: 'primitive', type: 'string' },
610
+ },
611
+ required: false,
612
+ },
613
+ ],
614
+ },
615
+ {
616
+ name: 'Connection',
617
+ fields: [
618
+ {
619
+ name: 'id',
620
+ type: { kind: 'primitive', type: 'string' },
621
+ required: true,
622
+ },
623
+ ],
624
+ },
625
+ ];
626
+
627
+ const ctxWithServices: EmitterContext = {
628
+ ...ctx,
629
+ spec: { ...emptySpec, services: [service], models },
630
+ };
631
+
632
+ const files = generateModels(models, ctxWithServices);
633
+
634
+ // The list wrapper model should be skipped
635
+ const listFile = files.find((f) => f.path.includes('connection-list.interface.ts'));
636
+ expect(listFile).toBeUndefined();
637
+
638
+ // The ListMetadata model should also be skipped
639
+ const listMetadataFile = files.find((f) => f.path.includes('list-metadata'));
640
+ expect(listMetadataFile).toBeUndefined();
641
+
642
+ // The Connection model should still be generated
643
+ const connectionFile = files.find((f) => f.path.includes('connection.interface.ts'));
644
+ expect(connectionFile).toBeDefined();
645
+ });
646
+
647
+ it('does not skip models that only partially match list-metadata shape', () => {
648
+ const service: Service = {
649
+ name: 'Organizations',
650
+ operations: [
651
+ {
652
+ name: 'getOrganization',
653
+ httpMethod: 'get',
654
+ path: '/organizations/{id}',
655
+ pathParams: [
656
+ {
657
+ name: 'id',
658
+ type: { kind: 'primitive', type: 'string' },
659
+ required: true,
660
+ },
661
+ ],
662
+ queryParams: [],
663
+ headerParams: [],
664
+ response: { kind: 'model', name: 'Pagination' },
665
+ errors: [],
666
+ injectIdempotencyKey: false,
667
+ },
668
+ ],
669
+ };
670
+
671
+ const models: Model[] = [
672
+ {
673
+ name: 'Pagination',
674
+ fields: [
675
+ {
676
+ name: 'before',
677
+ type: {
678
+ kind: 'nullable',
679
+ inner: { kind: 'primitive', type: 'string' },
680
+ },
681
+ required: false,
682
+ },
683
+ {
684
+ name: 'after',
685
+ type: {
686
+ kind: 'nullable',
687
+ inner: { kind: 'primitive', type: 'string' },
688
+ },
689
+ required: false,
690
+ },
691
+ {
692
+ name: 'total',
693
+ type: { kind: 'primitive', type: 'integer' },
694
+ required: true,
695
+ },
696
+ ],
697
+ },
698
+ ];
699
+
700
+ const ctxWithServices: EmitterContext = {
701
+ ...ctx,
702
+ spec: { ...emptySpec, services: [service], models },
703
+ };
704
+
705
+ const files = generateModels(models, ctxWithServices);
706
+ // Model with 3 fields should NOT be skipped even if it has before/after
707
+ expect(files.length).toBe(1);
708
+ expect(files[0].path).toContain('pagination.interface.ts');
709
+ });
710
+ });
711
+
712
+ describe('model deduplication', () => {
713
+ it('emits type alias for structurally identical models', () => {
714
+ const service: Service = {
715
+ name: 'Roles',
716
+ operations: [
717
+ {
718
+ name: 'getRole',
719
+ httpMethod: 'get',
720
+ path: '/roles/{id}',
721
+ pathParams: [
722
+ {
723
+ name: 'id',
724
+ type: { kind: 'primitive', type: 'string' },
725
+ required: true,
726
+ },
727
+ ],
728
+ queryParams: [],
729
+ headerParams: [],
730
+ response: { kind: 'model', name: 'EnvironmentRole' },
731
+ errors: [],
732
+ injectIdempotencyKey: false,
733
+ },
734
+ ],
735
+ };
736
+
737
+ const models: Model[] = [
738
+ {
739
+ name: 'EnvironmentRole',
740
+ fields: [
741
+ {
742
+ name: 'id',
743
+ type: { kind: 'primitive', type: 'string' },
744
+ required: true,
745
+ },
746
+ {
747
+ name: 'name',
748
+ type: { kind: 'primitive', type: 'string' },
749
+ required: true,
750
+ },
751
+ {
752
+ name: 'type',
753
+ type: { kind: 'literal', value: 'environment_role' },
754
+ required: true,
755
+ },
756
+ ],
757
+ },
758
+ {
759
+ name: 'OrganizationRole',
760
+ fields: [
761
+ {
762
+ name: 'id',
763
+ type: { kind: 'primitive', type: 'string' },
764
+ required: true,
765
+ },
766
+ {
767
+ name: 'name',
768
+ type: { kind: 'primitive', type: 'string' },
769
+ required: true,
770
+ },
771
+ {
772
+ name: 'type',
773
+ type: { kind: 'literal', value: 'environment_role' },
774
+ required: true,
775
+ },
776
+ ],
777
+ },
778
+ ];
779
+
780
+ const ctxWithServices: EmitterContext = {
781
+ ...ctx,
782
+ spec: { ...emptySpec, services: [service], models },
783
+ };
784
+
785
+ const files = generateModels(models, ctxWithServices);
786
+ expect(files.length).toBe(2);
787
+
788
+ // First model: full interface
789
+ expect(files[0].content).toContain('export interface EnvironmentRole');
790
+
791
+ // Second model: type alias referencing canonical
792
+ expect(files[1].content).toContain('export type OrganizationRole = EnvironmentRole');
793
+ expect(files[1].content).toContain('export type OrganizationRoleResponse = EnvironmentRoleResponse');
794
+ });
301
795
  });