@workos/oagen-emitters 0.2.0 → 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.
@@ -72,7 +72,10 @@ describe('generateResources', () => {
72
72
  queryParams: [
73
73
  {
74
74
  name: 'domains',
75
- type: { kind: 'array', items: { kind: 'primitive', type: 'string' } },
75
+ type: {
76
+ kind: 'array',
77
+ items: { kind: 'primitive', type: 'string' },
78
+ },
76
79
  required: false,
77
80
  },
78
81
  ],
@@ -243,7 +246,12 @@ describe('generateResources', () => {
243
246
  methodByOperation: new Map([
244
247
  [
245
248
  'POST /auth/factors/enroll',
246
- { className: 'Mfa', methodName: 'enrollFactor', params: [], returnType: 'void' },
249
+ {
250
+ className: 'Mfa',
251
+ methodName: 'enrollFactor',
252
+ params: [],
253
+ returnType: 'void',
254
+ },
247
255
  ],
248
256
  ]),
249
257
  httpKeyByMethod: new Map(),
@@ -315,7 +323,13 @@ describe('generateResources', () => {
315
323
  name: 'getOrganization',
316
324
  httpMethod: 'get',
317
325
  path: '/organizations/{id}',
318
- pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
326
+ pathParams: [
327
+ {
328
+ name: 'id',
329
+ type: { kind: 'primitive', type: 'string' },
330
+ required: true,
331
+ },
332
+ ],
319
333
  queryParams: [],
320
334
  headerParams: [],
321
335
  response: { kind: 'model', name: 'Organization' },
@@ -340,7 +354,13 @@ describe('generateResources', () => {
340
354
  name: 'getOrganization',
341
355
  httpMethod: 'get',
342
356
  path: '/organizations/{id}',
343
- pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
357
+ pathParams: [
358
+ {
359
+ name: 'id',
360
+ type: { kind: 'primitive', type: 'string' },
361
+ required: true,
362
+ },
363
+ ],
344
364
  queryParams: [
345
365
  {
346
366
  name: 'include_fields',
@@ -372,7 +392,13 @@ describe('generateResources', () => {
372
392
  name: 'getSession',
373
393
  httpMethod: 'get',
374
394
  path: '/sessions/{id}',
375
- pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
395
+ pathParams: [
396
+ {
397
+ name: 'id',
398
+ type: { kind: 'primitive', type: 'string' },
399
+ required: true,
400
+ },
401
+ ],
376
402
  queryParams: [],
377
403
  headerParams: [
378
404
  {
@@ -421,8 +447,14 @@ describe('generateResources', () => {
421
447
  requestBody: { kind: 'model', name: 'CreateOrganizationInput' },
422
448
  response: { kind: 'model', name: 'Organization' },
423
449
  successResponses: [
424
- { statusCode: 200, type: { kind: 'model', name: 'Organization' } },
425
- { statusCode: 201, type: { kind: 'model', name: 'Organization' } },
450
+ {
451
+ statusCode: 200,
452
+ type: { kind: 'model', name: 'Organization' },
453
+ },
454
+ {
455
+ statusCode: 201,
456
+ type: { kind: 'model', name: 'Organization' },
457
+ },
426
458
  ],
427
459
  errors: [],
428
460
  injectIdempotencyKey: false,
@@ -517,7 +549,13 @@ describe('generateResources', () => {
517
549
  name: 'getOrganization',
518
550
  httpMethod: 'get',
519
551
  path: '/organizations/{id}',
520
- pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
552
+ pathParams: [
553
+ {
554
+ name: 'id',
555
+ type: { kind: 'primitive', type: 'string' },
556
+ required: true,
557
+ },
558
+ ],
521
559
  queryParams: [
522
560
  {
523
561
  name: 'include_fields',
@@ -603,7 +641,7 @@ describe('generateResources', () => {
603
641
  expect(content).toContain('query: options');
604
642
  });
605
643
 
606
- it('generates union type for non-discriminated request body (pass-through)', () => {
644
+ it('falls back to pass-through for non-discriminated union when models not in spec', () => {
607
645
  const services: Service[] = [
608
646
  {
609
647
  name: 'Auth',
@@ -640,7 +678,7 @@ describe('generateResources', () => {
640
678
  // Should NOT use Record<string, unknown>
641
679
  expect(content).not.toContain('Record<string, unknown>');
642
680
 
643
- // Should pass payload directly (no serializer for unions)
681
+ // Models not in spec falls back to pass-through
644
682
  expect(content).toContain("'/user_management/authenticate',");
645
683
  expect(content).toContain('payload,');
646
684
 
@@ -650,6 +688,111 @@ describe('generateResources', () => {
650
688
  expect(content).toContain('AuthByMagicAuth');
651
689
  });
652
690
 
691
+ it('generates field-guard serializer dispatch for non-discriminated union with models', () => {
692
+ const services: Service[] = [
693
+ {
694
+ name: 'Applications',
695
+ operations: [
696
+ {
697
+ name: 'createApplication',
698
+ httpMethod: 'post',
699
+ path: '/connect/applications',
700
+ pathParams: [],
701
+ queryParams: [],
702
+ headerParams: [],
703
+ requestBody: {
704
+ kind: 'union',
705
+ variants: [
706
+ { kind: 'model', name: 'CreateOAuthApplication' },
707
+ { kind: 'model', name: 'CreateM2MApplication' },
708
+ ],
709
+ },
710
+ response: { kind: 'model', name: 'ConnectApplication' },
711
+ errors: [],
712
+ injectIdempotencyKey: false,
713
+ },
714
+ ],
715
+ },
716
+ ];
717
+
718
+ const testCtx: EmitterContext = {
719
+ namespace: 'workos',
720
+ namespacePascal: 'WorkOS',
721
+ spec: {
722
+ ...emptySpec,
723
+ services,
724
+ models: [
725
+ {
726
+ name: 'CreateOAuthApplication',
727
+ fields: [
728
+ {
729
+ name: 'name',
730
+ type: { kind: 'primitive', type: 'string' },
731
+ required: true,
732
+ },
733
+ {
734
+ name: 'redirect_uris',
735
+ type: {
736
+ kind: 'array',
737
+ items: { kind: 'primitive', type: 'string' },
738
+ },
739
+ required: true,
740
+ },
741
+ {
742
+ name: 'uses_pkce',
743
+ type: { kind: 'primitive', type: 'boolean' },
744
+ required: false,
745
+ },
746
+ ],
747
+ },
748
+ {
749
+ name: 'CreateM2MApplication',
750
+ fields: [
751
+ {
752
+ name: 'name',
753
+ type: { kind: 'primitive', type: 'string' },
754
+ required: true,
755
+ },
756
+ {
757
+ name: 'scopes',
758
+ type: {
759
+ kind: 'array',
760
+ items: { kind: 'primitive', type: 'string' },
761
+ },
762
+ required: true,
763
+ },
764
+ ],
765
+ },
766
+ {
767
+ name: 'ConnectApplication',
768
+ fields: [
769
+ {
770
+ name: 'id',
771
+ type: { kind: 'primitive', type: 'string' },
772
+ required: true,
773
+ },
774
+ ],
775
+ },
776
+ ],
777
+ },
778
+ };
779
+
780
+ const files = generateResources(services, testCtx);
781
+ const content = files[0].content;
782
+
783
+ // Should use the union type for the payload parameter
784
+ expect(content).toContain('payload: CreateOAuthApplication | CreateM2MApplication');
785
+
786
+ // Should dispatch via unique required field guards
787
+ expect(content).toContain("'redirectUris' in payload");
788
+ expect(content).toContain('serializeCreateOAuthApplication(payload as any)');
789
+ expect(content).toContain('serializeCreateM2MApplication(payload as any)');
790
+
791
+ // Should import serializers for all union variants
792
+ expect(content).toContain('serializeCreateOAuthApplication');
793
+ expect(content).toContain('serializeCreateM2MApplication');
794
+ });
795
+
653
796
  it('generates discriminated union serializer dispatch for request body', () => {
654
797
  const services: Service[] = [
655
798
  {
@@ -790,12 +933,12 @@ describe('generateResources', () => {
790
933
  it('prefixes ListOptions with service name when method is "list"', () => {
791
934
  const services: Service[] = [
792
935
  {
793
- name: 'Connections',
936
+ name: 'Payments',
794
937
  operations: [
795
938
  {
796
939
  name: 'list',
797
940
  httpMethod: 'get',
798
- path: '/connections',
941
+ path: '/payments',
799
942
  pathParams: [],
800
943
  queryParams: [
801
944
  {
@@ -826,7 +969,15 @@ describe('generateResources', () => {
826
969
  spec: { ...emptySpec, services, models: [] },
827
970
  overlayLookup: {
828
971
  methodByOperation: new Map([
829
- ['GET /connections', { className: 'Connections', methodName: 'list', params: [], returnType: 'void' }],
972
+ [
973
+ 'GET /payments',
974
+ {
975
+ className: 'Payments',
976
+ methodName: 'list',
977
+ params: [],
978
+ returnType: 'void',
979
+ },
980
+ ],
830
981
  ]),
831
982
  httpKeyByMethod: new Map(),
832
983
  interfaceByName: new Map(),
@@ -841,8 +992,8 @@ describe('generateResources', () => {
841
992
  const content = files[0].content;
842
993
 
843
994
  // Should use service-prefixed options name instead of generic "ListOptions"
844
- expect(content).toContain('export interface ConnectionsListOptions extends PaginationOptions {');
845
- expect(content).toContain('Promise<AutoPaginatable<Connection, ConnectionsListOptions>>');
995
+ expect(content).toContain('export interface PaymentsListOptions extends PaginationOptions {');
996
+ expect(content).toContain('Promise<AutoPaginatable<Connection, PaymentsListOptions>>');
846
997
  // Should NOT use the generic "ListOptions"
847
998
  expect(content).not.toContain('export interface ListOptions ');
848
999
  });
@@ -860,7 +1011,10 @@ describe('generateResources', () => {
860
1011
  queryParams: [
861
1012
  {
862
1013
  name: 'domains',
863
- type: { kind: 'array', items: { kind: 'primitive', type: 'string' } },
1014
+ type: {
1015
+ kind: 'array',
1016
+ items: { kind: 'primitive', type: 'string' },
1017
+ },
864
1018
  required: false,
865
1019
  },
866
1020
  ],
@@ -885,6 +1039,185 @@ describe('generateResources', () => {
885
1039
  // Method is "listOrganizations", not "list", so options name should be normal
886
1040
  expect(content).toContain('export interface ListOrganizationsOptions extends PaginationOptions {');
887
1041
  });
1042
+
1043
+ it('removes skipIfExists when fully-covered service has methods absent from baseline', () => {
1044
+ const services: Service[] = [
1045
+ {
1046
+ name: 'SSOService',
1047
+ operations: [
1048
+ {
1049
+ name: 'getAuthorizationUrl',
1050
+ httpMethod: 'get',
1051
+ path: '/sso/authorize',
1052
+ pathParams: [],
1053
+ queryParams: [],
1054
+ headerParams: [],
1055
+ response: { kind: 'model', name: 'AuthorizationUrl' },
1056
+ errors: [],
1057
+ injectIdempotencyKey: false,
1058
+ },
1059
+ {
1060
+ name: 'logout',
1061
+ httpMethod: 'get',
1062
+ path: '/sso/logout',
1063
+ pathParams: [],
1064
+ queryParams: [],
1065
+ headerParams: [],
1066
+ response: { kind: 'model', name: 'LogoutResult' },
1067
+ errors: [],
1068
+ injectIdempotencyKey: false,
1069
+ },
1070
+ ],
1071
+ },
1072
+ ];
1073
+
1074
+ // Overlay maps both operations to SSO class
1075
+ // Baseline SSO class exists but only has getAuthorizationUrl (logout is missing)
1076
+ const overlayCtx: EmitterContext = {
1077
+ namespace: 'workos',
1078
+ namespacePascal: 'WorkOS',
1079
+ spec: { ...emptySpec, services, models: [] },
1080
+ overlayLookup: {
1081
+ methodByOperation: new Map([
1082
+ [
1083
+ 'GET /sso/authorize',
1084
+ {
1085
+ className: 'SSO',
1086
+ methodName: 'getAuthorizationUrl',
1087
+ params: [],
1088
+ returnType: 'void',
1089
+ },
1090
+ ],
1091
+ [
1092
+ 'GET /sso/logout',
1093
+ {
1094
+ className: 'SSO',
1095
+ methodName: 'logout',
1096
+ params: [],
1097
+ returnType: 'void',
1098
+ },
1099
+ ],
1100
+ ]),
1101
+ httpKeyByMethod: new Map(),
1102
+ interfaceByName: new Map(),
1103
+ typeAliasByName: new Map(),
1104
+ requiredExports: new Map(),
1105
+ modelNameByIR: new Map(),
1106
+ fileBySymbol: new Map(),
1107
+ },
1108
+ apiSurface: {
1109
+ language: 'node',
1110
+ extractedFrom: 'test',
1111
+ extractedAt: '2024-01-01',
1112
+ classes: {
1113
+ SSO: {
1114
+ name: 'SSO',
1115
+ methods: {
1116
+ getAuthorizationUrl: [
1117
+ {
1118
+ name: 'getAuthorizationUrl',
1119
+ params: [],
1120
+ returnType: 'void',
1121
+ async: true,
1122
+ },
1123
+ ],
1124
+ // logout method is intentionally ABSENT
1125
+ },
1126
+ properties: {},
1127
+ constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
1128
+ },
1129
+ },
1130
+ interfaces: {},
1131
+ typeAliases: {},
1132
+ enums: {},
1133
+ exports: {},
1134
+ },
1135
+ };
1136
+
1137
+ const files = generateResources(services, overlayCtx);
1138
+ expect(files.length).toBe(1);
1139
+
1140
+ // skipIfExists should be removed because 'logout' is absent from baseline
1141
+ expect(files[0].skipIfExists).toBeUndefined();
1142
+ });
1143
+
1144
+ it('keeps skipIfExists when fully-covered service has all methods in baseline', () => {
1145
+ const services: Service[] = [
1146
+ {
1147
+ name: 'SSOService',
1148
+ operations: [
1149
+ {
1150
+ name: 'getAuthorizationUrl',
1151
+ httpMethod: 'get',
1152
+ path: '/sso/authorize',
1153
+ pathParams: [],
1154
+ queryParams: [],
1155
+ headerParams: [],
1156
+ response: { kind: 'model', name: 'AuthorizationUrl' },
1157
+ errors: [],
1158
+ injectIdempotencyKey: false,
1159
+ },
1160
+ ],
1161
+ },
1162
+ ];
1163
+
1164
+ const overlayCtx: EmitterContext = {
1165
+ namespace: 'workos',
1166
+ namespacePascal: 'WorkOS',
1167
+ spec: { ...emptySpec, services, models: [] },
1168
+ overlayLookup: {
1169
+ methodByOperation: new Map([
1170
+ [
1171
+ 'GET /sso/authorize',
1172
+ {
1173
+ className: 'SSO',
1174
+ methodName: 'getAuthorizationUrl',
1175
+ params: [],
1176
+ returnType: 'void',
1177
+ },
1178
+ ],
1179
+ ]),
1180
+ httpKeyByMethod: new Map(),
1181
+ interfaceByName: new Map(),
1182
+ typeAliasByName: new Map(),
1183
+ requiredExports: new Map(),
1184
+ modelNameByIR: new Map(),
1185
+ fileBySymbol: new Map(),
1186
+ },
1187
+ apiSurface: {
1188
+ language: 'node',
1189
+ extractedFrom: 'test',
1190
+ extractedAt: '2024-01-01',
1191
+ classes: {
1192
+ SSO: {
1193
+ name: 'SSO',
1194
+ methods: {
1195
+ getAuthorizationUrl: [
1196
+ {
1197
+ name: 'getAuthorizationUrl',
1198
+ params: [],
1199
+ returnType: 'void',
1200
+ async: true,
1201
+ },
1202
+ ],
1203
+ },
1204
+ properties: {},
1205
+ constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
1206
+ },
1207
+ },
1208
+ interfaces: {},
1209
+ typeAliases: {},
1210
+ enums: {},
1211
+ exports: {},
1212
+ },
1213
+ };
1214
+
1215
+ const files = generateResources(services, overlayCtx);
1216
+ expect(files.length).toBe(1);
1217
+
1218
+ // skipIfExists should stay true because all methods exist in baseline
1219
+ expect(files[0].skipIfExists).toBe(true);
1220
+ });
888
1221
  });
889
1222
 
890
1223
  describe('resolveResourceClassName', () => {
@@ -914,7 +1247,12 @@ describe('resolveResourceClassName', () => {
914
1247
  methodByOperation: new Map([
915
1248
  [
916
1249
  'GET /webhook_events',
917
- { className: 'Webhooks', methodName: 'listWebhookEvents', params: [], returnType: 'void' },
1250
+ {
1251
+ className: 'Webhooks',
1252
+ methodName: 'listWebhookEvents',
1253
+ params: [],
1254
+ returnType: 'void',
1255
+ },
918
1256
  ],
919
1257
  ]),
920
1258
  httpKeyByMethod: new Map(),
@@ -933,7 +1271,13 @@ describe('resolveResourceClassName', () => {
933
1271
  name: 'Webhooks',
934
1272
  methods: {},
935
1273
  properties: {},
936
- constructorParams: [{ name: 'cryptoProvider', type: 'CryptoProvider', optional: false }],
1274
+ constructorParams: [
1275
+ {
1276
+ name: 'cryptoProvider',
1277
+ type: 'CryptoProvider',
1278
+ optional: false,
1279
+ },
1280
+ ],
937
1281
  },
938
1282
  },
939
1283
  interfaces: {},
@@ -957,7 +1301,12 @@ describe('resolveResourceClassName', () => {
957
1301
  methodByOperation: new Map([
958
1302
  [
959
1303
  'GET /webhook_events',
960
- { className: 'Webhooks', methodName: 'listWebhookEvents', params: [], returnType: 'void' },
1304
+ {
1305
+ className: 'Webhooks',
1306
+ methodName: 'listWebhookEvents',
1307
+ params: [],
1308
+ returnType: 'void',
1309
+ },
961
1310
  ],
962
1311
  ]),
963
1312
  httpKeyByMethod: new Map(),
@@ -1014,7 +1363,15 @@ describe('resolveResourceClassName', () => {
1014
1363
  spec: { ...emptySpec, services: [collisionService] },
1015
1364
  overlayLookup: {
1016
1365
  methodByOperation: new Map([
1017
- ['GET /webhooks', { className: 'Webhooks', methodName: 'listWebhooks', params: [], returnType: 'void' }],
1366
+ [
1367
+ 'GET /webhooks',
1368
+ {
1369
+ className: 'Webhooks',
1370
+ methodName: 'listWebhooks',
1371
+ params: [],
1372
+ returnType: 'void',
1373
+ },
1374
+ ],
1018
1375
  ]),
1019
1376
  httpKeyByMethod: new Map(),
1020
1377
  interfaceByName: new Map(),
@@ -1032,7 +1389,13 @@ describe('resolveResourceClassName', () => {
1032
1389
  name: 'Webhooks',
1033
1390
  methods: {},
1034
1391
  properties: {},
1035
- constructorParams: [{ name: 'cryptoProvider', type: 'CryptoProvider', optional: false }],
1392
+ constructorParams: [
1393
+ {
1394
+ name: 'cryptoProvider',
1395
+ type: 'CryptoProvider',
1396
+ optional: false,
1397
+ },
1398
+ ],
1036
1399
  },
1037
1400
  },
1038
1401
  interfaces: {},
@@ -1090,7 +1453,13 @@ describe('hasCompatibleConstructor', () => {
1090
1453
  name: 'Webhooks',
1091
1454
  methods: {},
1092
1455
  properties: {},
1093
- constructorParams: [{ name: 'cryptoProvider', type: 'CryptoProvider', optional: false }],
1456
+ constructorParams: [
1457
+ {
1458
+ name: 'cryptoProvider',
1459
+ type: 'CryptoProvider',
1460
+ optional: false,
1461
+ },
1462
+ ],
1094
1463
  },
1095
1464
  },
1096
1465
  interfaces: {},
@@ -1192,7 +1561,14 @@ describe('partial service coverage', () => {
1192
1561
  AuditLogs: {
1193
1562
  name: 'AuditLogs',
1194
1563
  methods: {
1195
- createEvent: [{ name: 'createEvent', params: [], returnType: 'AuditLogEvent', async: true }],
1564
+ createEvent: [
1565
+ {
1566
+ name: 'createEvent',
1567
+ params: [],
1568
+ returnType: 'AuditLogEvent',
1569
+ async: true,
1570
+ },
1571
+ ],
1196
1572
  },
1197
1573
  properties: {},
1198
1574
  constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
@@ -1211,7 +1587,233 @@ describe('partial service coverage', () => {
1211
1587
 
1212
1588
  // Should generate method for uncovered operation
1213
1589
  expect(content).toContain('async getRetention');
1214
- // Should NOT generate method for covered operation
1215
- expect(content).not.toContain('async createEvent');
1590
+ // Should also generate covered operation so the merger can apply JSDoc
1591
+ expect(content).toContain('async createEvent');
1592
+ });
1593
+
1594
+ it('generates resource class for fully covered services to provide JSDoc', () => {
1595
+ const services: Service[] = [
1596
+ {
1597
+ name: 'Permissions',
1598
+ operations: [
1599
+ {
1600
+ name: 'listPermissions',
1601
+ description: 'List all permissions.',
1602
+ httpMethod: 'get',
1603
+ path: '/authorization/permissions',
1604
+ pathParams: [],
1605
+ queryParams: [],
1606
+ headerParams: [],
1607
+ response: { kind: 'model', name: 'PermissionList' },
1608
+ errors: [{ statusCode: 404 }],
1609
+ injectIdempotencyKey: false,
1610
+ },
1611
+ ],
1612
+ },
1613
+ ];
1614
+
1615
+ const ctxCovered: EmitterContext = {
1616
+ ...ctx,
1617
+ spec: { ...emptySpec, services, models: [] },
1618
+ overlayLookup: {
1619
+ methodByOperation: new Map([
1620
+ [
1621
+ 'GET /authorization/permissions',
1622
+ {
1623
+ className: 'Permissions',
1624
+ methodName: 'listPermissions',
1625
+ params: [],
1626
+ returnType: 'void',
1627
+ },
1628
+ ],
1629
+ ]),
1630
+ httpKeyByMethod: new Map(),
1631
+ interfaceByName: new Map(),
1632
+ typeAliasByName: new Map(),
1633
+ requiredExports: new Map(),
1634
+ modelNameByIR: new Map(),
1635
+ fileBySymbol: new Map(),
1636
+ },
1637
+ apiSurface: {
1638
+ language: 'node',
1639
+ extractedFrom: 'test',
1640
+ extractedAt: '2024-01-01',
1641
+ classes: {
1642
+ Permissions: {
1643
+ name: 'Permissions',
1644
+ methods: {
1645
+ listPermissions: [
1646
+ {
1647
+ name: 'listPermissions',
1648
+ params: [],
1649
+ returnType: 'void',
1650
+ async: true,
1651
+ },
1652
+ ],
1653
+ },
1654
+ properties: {},
1655
+ constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
1656
+ },
1657
+ },
1658
+ interfaces: {},
1659
+ typeAliases: {},
1660
+ enums: {},
1661
+ exports: {},
1662
+ },
1663
+ };
1664
+
1665
+ const files = generateResources(services, ctxCovered);
1666
+ expect(files.length).toBe(1);
1667
+ const content = files[0].content;
1668
+ // Should contain JSDoc with description from the spec
1669
+ expect(content).toContain('List all permissions.');
1670
+ // skipIfExists should remain true for covered services
1671
+ expect(files[0].skipIfExists).toBe(true);
1672
+ });
1673
+
1674
+ it('reconciles method names against api-surface using word-set matching', () => {
1675
+ const services: Service[] = [
1676
+ {
1677
+ name: 'Authorization',
1678
+ operations: [
1679
+ {
1680
+ name: 'listRolesOrganizations',
1681
+ httpMethod: 'get',
1682
+ path: '/authorization/organizations/{organizationId}/roles',
1683
+ pathParams: [
1684
+ {
1685
+ name: 'organizationId',
1686
+ type: { kind: 'primitive', type: 'string' },
1687
+ required: true,
1688
+ },
1689
+ ],
1690
+ queryParams: [],
1691
+ headerParams: [],
1692
+ response: { kind: 'model', name: 'RoleList' },
1693
+ errors: [],
1694
+ pagination: {
1695
+ strategy: 'cursor' as const,
1696
+ param: 'after',
1697
+ itemType: { kind: 'model' as const, name: 'RoleList' },
1698
+ },
1699
+ injectIdempotencyKey: false,
1700
+ },
1701
+ ],
1702
+ },
1703
+ ];
1704
+
1705
+ const ctxRecon: EmitterContext = {
1706
+ ...ctx,
1707
+ spec: {
1708
+ ...emptySpec,
1709
+ services,
1710
+ models: [{ name: 'RoleList', fields: [] }],
1711
+ },
1712
+ overlayLookup: {
1713
+ methodByOperation: new Map(), // no overlay mapping
1714
+ httpKeyByMethod: new Map(),
1715
+ interfaceByName: new Map(),
1716
+ typeAliasByName: new Map(),
1717
+ requiredExports: new Map(),
1718
+ modelNameByIR: new Map(),
1719
+ fileBySymbol: new Map(),
1720
+ },
1721
+ apiSurface: {
1722
+ language: 'node',
1723
+ extractedFrom: 'test',
1724
+ extractedAt: '2024-01-01',
1725
+ classes: {
1726
+ Authorization: {
1727
+ name: 'Authorization',
1728
+ methods: {
1729
+ listOrganizationRoles: [
1730
+ {
1731
+ name: 'listOrganizationRoles',
1732
+ params: [],
1733
+ returnType: 'void',
1734
+ async: true,
1735
+ },
1736
+ ],
1737
+ },
1738
+ properties: {},
1739
+ constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
1740
+ },
1741
+ },
1742
+ interfaces: {},
1743
+ typeAliases: {},
1744
+ enums: {},
1745
+ exports: {},
1746
+ },
1747
+ };
1748
+
1749
+ const files = generateResources(services, ctxRecon);
1750
+ expect(files.length).toBe(1);
1751
+ const content = files[0].content;
1752
+ // Should use reconciled name from api-surface, not spec-derived name
1753
+ expect(content).toContain('async listOrganizationRoles');
1754
+ expect(content).not.toContain('async listRolesOrganizations');
1755
+ });
1756
+
1757
+ it('deduplicates method names for operations on different paths', () => {
1758
+ const services: Service[] = [
1759
+ {
1760
+ name: 'Organizations',
1761
+ operations: [
1762
+ {
1763
+ name: 'create',
1764
+ httpMethod: 'post',
1765
+ path: '/organization_domains',
1766
+ pathParams: [],
1767
+ queryParams: [],
1768
+ headerParams: [],
1769
+ requestBody: { kind: 'model', name: 'CreateOrgDomain' },
1770
+ response: { kind: 'model', name: 'OrgDomain' },
1771
+ errors: [],
1772
+ injectIdempotencyKey: false,
1773
+ },
1774
+ {
1775
+ name: 'create',
1776
+ httpMethod: 'post',
1777
+ path: '/organizations',
1778
+ pathParams: [],
1779
+ queryParams: [],
1780
+ headerParams: [],
1781
+ requestBody: { kind: 'model', name: 'CreateOrg' },
1782
+ response: { kind: 'model', name: 'Organization' },
1783
+ errors: [],
1784
+ injectIdempotencyKey: false,
1785
+ },
1786
+ ],
1787
+ },
1788
+ ];
1789
+
1790
+ const ctxDedup: EmitterContext = {
1791
+ ...ctx,
1792
+ spec: {
1793
+ ...emptySpec,
1794
+ services,
1795
+ models: [
1796
+ { name: 'CreateOrgDomain', fields: [] },
1797
+ { name: 'OrgDomain', fields: [] },
1798
+ { name: 'CreateOrg', fields: [] },
1799
+ { name: 'Organization', fields: [] },
1800
+ ],
1801
+ },
1802
+ };
1803
+
1804
+ const files = generateResources(services, ctxDedup);
1805
+ expect(files.length).toBe(1);
1806
+ const content = files[0].content;
1807
+ // The best-scoring plan keeps the name; the other gets disambiguated.
1808
+ // "create" matches "organizations" path better (the word "create" doesn't
1809
+ // appear in either path, but scoring is equal — first wins).
1810
+ // The other gets a path suffix.
1811
+ const createMatches = content.match(/async create\b/g);
1812
+ // At most one un-suffixed "create"
1813
+ expect(createMatches?.length ?? 0).toBeLessThanOrEqual(1);
1814
+ // The two methods should have different names
1815
+ const methodNames = [...content.matchAll(/async (\w+)\(/g)].map((m) => m[1]);
1816
+ const createMethods = methodNames.filter((n) => n.toLowerCase().startsWith('create'));
1817
+ expect(new Set(createMethods).size).toBe(createMethods.length); // all unique
1216
1818
  });
1217
1819
  });