@workos/oagen-emitters 0.15.2 → 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 (46) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +19 -0
  3. package/README.md +48 -1
  4. package/dist/index.d.mts +51 -2
  5. package/dist/index.d.mts.map +1 -1
  6. package/dist/index.mjs +852 -2
  7. package/dist/index.mjs.map +1 -0
  8. package/dist/{plugin-Xkr83G9A.mjs → plugin-CpO8rePT.mjs} +1219 -493
  9. package/dist/plugin-CpO8rePT.mjs.map +1 -0
  10. package/dist/plugin.mjs +1 -1
  11. package/package.json +7 -7
  12. package/src/dotnet/naming.ts +1 -1
  13. package/src/go/naming.ts +1 -1
  14. package/src/index.ts +15 -0
  15. package/src/node/enums.ts +17 -4
  16. package/src/node/index.ts +264 -4
  17. package/src/node/live-surface.ts +309 -0
  18. package/src/node/models.ts +69 -3
  19. package/src/node/naming.ts +204 -23
  20. package/src/node/resources.ts +39 -3
  21. package/src/node/tests.ts +29 -3
  22. package/src/node/utils.ts +140 -22
  23. package/src/snippets/dotnet.ts +159 -0
  24. package/src/snippets/go.ts +148 -0
  25. package/src/snippets/index.ts +8 -0
  26. package/src/snippets/kotlin.ts +144 -0
  27. package/src/snippets/php.ts +149 -0
  28. package/src/snippets/plugin.ts +36 -0
  29. package/src/snippets/python.ts +135 -0
  30. package/src/snippets/ruby.ts +152 -0
  31. package/src/snippets/rust.ts +189 -0
  32. package/test/node/enums.test.ts +239 -2
  33. package/test/node/live-surface.test.ts +771 -1
  34. package/test/node/models.test.ts +738 -3
  35. package/test/node/naming.test.ts +159 -0
  36. package/test/node/resources.test.ts +464 -0
  37. package/test/node/utils.test.ts +157 -2
  38. package/test/snippets/_helpers.ts +67 -0
  39. package/test/snippets/dotnet.test.ts +49 -0
  40. package/test/snippets/go.test.ts +94 -0
  41. package/test/snippets/kotlin.test.ts +53 -0
  42. package/test/snippets/php.test.ts +48 -0
  43. package/test/snippets/python.test.ts +73 -0
  44. package/test/snippets/ruby.test.ts +339 -0
  45. package/test/snippets/rust.test.ts +76 -0
  46. package/dist/plugin-Xkr83G9A.mjs.map +0 -1
@@ -84,6 +84,165 @@ describe('resolveInterfaceName', () => {
84
84
  });
85
85
  });
86
86
 
87
+ // ---------------------------------------------------------------------------
88
+ // Structural name resolution must be INJECTIVE: a live-surface name may be
89
+ // claimed by at most one IR model per run. Reconstructs the workos-node
90
+ // AuditLogs incident: the spec has two near-identical models
91
+ // (AuditLogEventActor / AuditLogEventTarget) and the live SDK declares a
92
+ // hand-written AuditLogActor with the same shape. The structural fallback
93
+ // mapped BOTH IR models onto AuditLogActor, so
94
+ // audit-log-event-target.interface.ts was emitted declaring
95
+ // `export interface AuditLogActor` (file stem and declaration disagree),
96
+ // with duplicate imports/describe blocks and two serializeAuditLogActor
97
+ // definitions downstream.
98
+ // ---------------------------------------------------------------------------
99
+ describe('resolveInterfaceName structural injectivity', () => {
100
+ const field = (name: string, required = false) => ({
101
+ name,
102
+ type: { kind: 'primitive', type: 'string' },
103
+ required,
104
+ });
105
+
106
+ // Shape ~ { id?, name, type?, metadata? } — matches the live AuditLogActor.
107
+ const eventShape = (extra: string) => [
108
+ field('id'),
109
+ field('name', true),
110
+ field('type'),
111
+ field('metadata'),
112
+ field(extra),
113
+ ];
114
+
115
+ const liveActorFields = {
116
+ id: { type: 'string', optional: true },
117
+ name: { type: 'string', optional: false },
118
+ type: { type: 'string', optional: true },
119
+ metadata: { type: 'string', optional: true },
120
+ };
121
+
122
+ function auditCtx(opts: {
123
+ models?: { name: string; fields: unknown[] }[];
124
+ modelNameByIR: [string, string][];
125
+ interfaceByName?: [string, string][];
126
+ extraInterfaces?: Record<string, unknown>;
127
+ }): EmitterContext {
128
+ const models = opts.models ?? [
129
+ { name: 'AuditLogEventActor', fields: eventShape('ip_address') },
130
+ { name: 'AuditLogEventTarget', fields: eventShape('domain') },
131
+ ];
132
+ return {
133
+ ...ctx,
134
+ spec: { ...ctx.spec, models },
135
+ apiSurface: {
136
+ language: 'node',
137
+ extractedFrom: '/tmp/workos-node',
138
+ extractedAt: '2026-06-10T00:00:00Z',
139
+ classes: {},
140
+ interfaces: {
141
+ AuditLogActor: { fields: liveActorFields },
142
+ ...opts.extraInterfaces,
143
+ },
144
+ typeAliases: {},
145
+ enums: {},
146
+ exports: {},
147
+ },
148
+ overlayLookup: {
149
+ methodByOperation: new Map(),
150
+ interfaceByName: new Map(opts.interfaceByName ?? []),
151
+ modelNameByIR: new Map(opts.modelNameByIR),
152
+ },
153
+ } as unknown as EmitterContext;
154
+ }
155
+
156
+ it('never lets two IR models collapse onto one live name', () => {
157
+ const c = auditCtx({
158
+ modelNameByIR: [
159
+ ['AuditLogEventActor', 'AuditLogActor'],
160
+ ['AuditLogEventTarget', 'AuditLogActor'],
161
+ ],
162
+ });
163
+
164
+ const actor = resolveInterfaceName('AuditLogEventActor', c);
165
+ const target = resolveInterfaceName('AuditLogEventTarget', c);
166
+
167
+ // The closer name wins the contested live name; the loser keeps its
168
+ // canonical IR-derived name — it must NEVER unify onto AuditLogActor.
169
+ expect(actor).toBe('AuditLogActor');
170
+ expect(target).toBe('AuditLogEventTarget');
171
+ expect(actor).not.toBe(target);
172
+ });
173
+
174
+ it('awards a contested name independently of overlay insertion order', () => {
175
+ const c = auditCtx({
176
+ models: [
177
+ { name: 'AuditLogEventTarget', fields: eventShape('domain') },
178
+ { name: 'AuditLogEventActor', fields: eventShape('ip_address') },
179
+ ],
180
+ modelNameByIR: [
181
+ ['AuditLogEventTarget', 'AuditLogActor'],
182
+ ['AuditLogEventActor', 'AuditLogActor'],
183
+ ],
184
+ });
185
+
186
+ expect(resolveInterfaceName('AuditLogEventActor', c)).toBe('AuditLogActor');
187
+ expect(resolveInterfaceName('AuditLogEventTarget', c)).toBe('AuditLogEventTarget');
188
+ });
189
+
190
+ it('stays injective when Serialized* normalization collapses two distinct raw matches', () => {
191
+ // The engine overlay itself is injective on raw names (actor →
192
+ // AuditLogActor, target → SerializedAuditLogActor), but the resolver
193
+ // normalizes Serialized* down to the bare name — that post-processing
194
+ // must not re-introduce a collision.
195
+ const c = auditCtx({
196
+ modelNameByIR: [
197
+ ['AuditLogEventActor', 'AuditLogActor'],
198
+ ['AuditLogEventTarget', 'SerializedAuditLogActor'],
199
+ ],
200
+ extraInterfaces: { SerializedAuditLogActor: { fields: liveActorFields } },
201
+ });
202
+
203
+ expect(resolveInterfaceName('AuditLogEventActor', c)).toBe('AuditLogActor');
204
+ expect(resolveInterfaceName('AuditLogEventTarget', c)).toBe('AuditLogEventTarget');
205
+ });
206
+
207
+ it('prefers the structurally closer claimant over the closer name', () => {
208
+ // Target matches the live shape exactly; actor only shares two fields.
209
+ // Similarity outranks name distance, so target wins even though
210
+ // "AuditLogEventActor" is the closer name.
211
+ const c = auditCtx({
212
+ models: [
213
+ { name: 'AuditLogEventActor', fields: [field('id'), field('name', true), field('ip'), field('agent')] },
214
+ { name: 'AuditLogEventTarget', fields: [field('id'), field('name', true), field('type'), field('metadata')] },
215
+ ],
216
+ modelNameByIR: [
217
+ ['AuditLogEventActor', 'AuditLogActor'],
218
+ ['AuditLogEventTarget', 'AuditLogActor'],
219
+ ],
220
+ });
221
+
222
+ expect(resolveInterfaceName('AuditLogEventTarget', c)).toBe('AuditLogActor');
223
+ expect(resolveInterfaceName('AuditLogEventActor', c)).toBe('AuditLogEventActor');
224
+ });
225
+
226
+ it('blocks structural claims on names already claimed by an exact-name override', () => {
227
+ const c = auditCtx({
228
+ interfaceByName: [['AuditLogEventActor', 'AuditLogActor']],
229
+ modelNameByIR: [['AuditLogEventTarget', 'AuditLogActor']],
230
+ });
231
+
232
+ expect(resolveInterfaceName('AuditLogEventActor', c)).toBe('AuditLogActor');
233
+ expect(resolveInterfaceName('AuditLogEventTarget', c)).toBe('AuditLogEventTarget');
234
+ });
235
+
236
+ it('still applies a single-model structural rename (the legitimate overlay case)', () => {
237
+ const c = auditCtx({
238
+ models: [{ name: 'AuditLogEventActor', fields: eventShape('ip_address') }],
239
+ modelNameByIR: [['AuditLogEventActor', 'AuditLogActor']],
240
+ });
241
+
242
+ expect(resolveInterfaceName('AuditLogEventActor', c)).toBe('AuditLogActor');
243
+ });
244
+ });
245
+
87
246
  describe('wireInterfaceName', () => {
88
247
  it('emits *Wire for a fresh `*Response`-named IR model with an empty baseline', () => {
89
248
  expect(wireInterfaceName('CreateDataKeyResponse')).toBe('CreateDataKeyResponseWire');
@@ -466,6 +466,470 @@ describe('generateResources', () => {
466
466
  });
467
467
  });
468
468
 
469
+ describe('body-less POST/PUT operations', () => {
470
+ // The WorkOS client's `post(path, entity, options?)` and `put(path, entity, options?)`
471
+ // REQUIRE the entity argument. Operations with no request body must still pass `{}`
472
+ // or the generated call fails with TS2554 "Expected 2-3 arguments, but got 1".
473
+ const domainModel: Model = {
474
+ name: 'OrganizationDomain',
475
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
476
+ };
477
+
478
+ it('passes an empty object body for a body-less POST with a response model', () => {
479
+ const services: Service[] = [
480
+ {
481
+ name: 'OrganizationDomains',
482
+ operations: [
483
+ {
484
+ name: 'verifyOrganizationDomain',
485
+ httpMethod: 'post',
486
+ path: '/organization_domains/{id}/verify',
487
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
488
+ queryParams: [],
489
+ headerParams: [],
490
+ response: { kind: 'model', name: 'OrganizationDomain' },
491
+ errors: [],
492
+ injectIdempotencyKey: false,
493
+ },
494
+ ],
495
+ },
496
+ ];
497
+
498
+ const spec: ApiSpec = { ...emptySpec, services, models: [domainModel] };
499
+ const result = generateResources(services, { ...ctx, spec });
500
+ const resourceFile = result.find((f) => f.path.includes('organization-domains.ts'));
501
+ expect(resourceFile).toBeDefined();
502
+ // The post() call must pass `{}` as the required entity argument.
503
+ expect(resourceFile!.content).toMatch(/await this\.workos\.post<[^>]+>\(`[^`]+`, \{\}\);/);
504
+ expect(resourceFile!.content).not.toMatch(/await this\.workos\.post<[^>]+>\(`[^`]+`\);/);
505
+ });
506
+
507
+ it('passes an empty object body for a body-less PUT with a response model', () => {
508
+ const flagModel: Model = {
509
+ name: 'FeatureFlag',
510
+ fields: [{ name: 'slug', type: { kind: 'primitive', type: 'string' }, required: true }],
511
+ };
512
+ const services: Service[] = [
513
+ {
514
+ name: 'FeatureFlags',
515
+ operations: [
516
+ {
517
+ name: 'enableFeatureFlag',
518
+ httpMethod: 'put',
519
+ path: '/feature_flags/{slug}/enable',
520
+ pathParams: [{ name: 'slug', type: { kind: 'primitive', type: 'string' }, required: true }],
521
+ queryParams: [],
522
+ headerParams: [],
523
+ response: { kind: 'model', name: 'FeatureFlag' },
524
+ errors: [],
525
+ injectIdempotencyKey: false,
526
+ },
527
+ ],
528
+ },
529
+ ];
530
+
531
+ const spec: ApiSpec = { ...emptySpec, services, models: [flagModel] };
532
+ const result = generateResources(services, { ...ctx, spec });
533
+ const resourceFile = result.find((f) => f.path.includes('feature-flags.ts'));
534
+ expect(resourceFile).toBeDefined();
535
+ expect(resourceFile!.content).toMatch(/await this\.workos\.put<[^>]+>\(`[^`]+`, \{\}\);/);
536
+ expect(resourceFile!.content).not.toMatch(/await this\.workos\.put<[^>]+>\(`[^`]+`\);/);
537
+ });
538
+
539
+ it('does not add a body argument to body-less GET calls', () => {
540
+ const services: Service[] = [
541
+ {
542
+ name: 'OrganizationDomains',
543
+ operations: [
544
+ {
545
+ name: 'getOrganizationDomain',
546
+ httpMethod: 'get',
547
+ path: '/organization_domains/{id}',
548
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
549
+ queryParams: [],
550
+ headerParams: [],
551
+ response: { kind: 'model', name: 'OrganizationDomain' },
552
+ errors: [],
553
+ injectIdempotencyKey: false,
554
+ },
555
+ ],
556
+ },
557
+ ];
558
+
559
+ const spec: ApiSpec = { ...emptySpec, services, models: [domainModel] };
560
+ const result = generateResources(services, { ...ctx, spec });
561
+ const resourceFile = result.find((f) => f.path.includes('organization-domains.ts'));
562
+ expect(resourceFile).toBeDefined();
563
+ expect(resourceFile!.content).toMatch(/await this\.workos\.get<[^>]+>\(`[^`]+`\);/);
564
+ });
565
+ });
566
+
567
+ describe('paginated list methods and path params (AutoPaginatable typing)', () => {
568
+ // List methods with PATH parameters destructure those params out of the
569
+ // options object (`const { actionName, ...paginationOptions } = options;`)
570
+ // and pass the REST object to AutoPaginatable/fetchAndDeserialize. The
571
+ // declared second type argument must therefore be the rest type
572
+ // (Omit<FullOptions, pathFields>) — declaring the full options interface
573
+ // fails TS2322 because the rest object lacks the required path-param fields.
574
+ const schemaModel: Model = {
575
+ name: 'AuditLogSchema',
576
+ fields: [{ name: 'version', type: { kind: 'primitive', type: 'number' }, required: true }],
577
+ };
578
+
579
+ const paginationQueryParams = [
580
+ { name: 'limit', type: { kind: 'primitive' as const, type: 'number' as const }, required: false },
581
+ { name: 'after', type: { kind: 'primitive' as const, type: 'string' as const }, required: false },
582
+ ];
583
+
584
+ const cursorPagination = {
585
+ strategy: 'cursor' as const,
586
+ param: 'after',
587
+ itemType: { kind: 'model' as const, name: 'AuditLogSchema' },
588
+ };
589
+
590
+ it('types AutoPaginatable over the rest options when one path param is destructured', () => {
591
+ const services: Service[] = [
592
+ {
593
+ name: 'AuditLogs',
594
+ operations: [
595
+ {
596
+ name: 'listActionSchemas',
597
+ httpMethod: 'get',
598
+ path: '/audit_logs/actions/{actionName}/schemas',
599
+ pathParams: [{ name: 'actionName', type: { kind: 'primitive', type: 'string' }, required: true }],
600
+ queryParams: paginationQueryParams,
601
+ headerParams: [],
602
+ response: { kind: 'array', items: { kind: 'model', name: 'AuditLogSchema' } },
603
+ pagination: cursorPagination,
604
+ errors: [],
605
+ injectIdempotencyKey: false,
606
+ },
607
+ ],
608
+ },
609
+ ];
610
+
611
+ const spec: ApiSpec = { ...emptySpec, services, models: [schemaModel] };
612
+ const result = generateResources(services, { ...ctx, spec });
613
+ const resourceFile = result.find((f) => f.path.includes('audit-logs.ts'));
614
+ expect(resourceFile).toBeDefined();
615
+ const content = resourceFile!.content;
616
+
617
+ // The declaration, the constructed value, and the re-fetch lambda must all
618
+ // agree on the rest type actually passed (paginationOptions).
619
+ const expectedMethod = [
620
+ " async listActionSchemas(options: ListActionSchemasOptions): Promise<AutoPaginatable<AuditLogSchema, Omit<ListActionSchemasOptions, 'actionName'>>> {",
621
+ ' const { actionName, ...paginationOptions } = options;',
622
+ ' return new AutoPaginatable(',
623
+ ' await fetchAndDeserialize<AuditLogSchemaResponse, AuditLogSchema>(',
624
+ ' this.workos,',
625
+ ' `/audit_logs/actions/${encodeURIComponent(actionName)}/schemas`,',
626
+ ' deserializeAuditLogSchema,',
627
+ ' paginationOptions,',
628
+ ' ),',
629
+ ' (params) =>',
630
+ ' fetchAndDeserialize<AuditLogSchemaResponse, AuditLogSchema>(',
631
+ ' this.workos,',
632
+ ' `/audit_logs/actions/${encodeURIComponent(actionName)}/schemas`,',
633
+ ' deserializeAuditLogSchema,',
634
+ ' params,',
635
+ ' ),',
636
+ ' paginationOptions,',
637
+ ' );',
638
+ ' }',
639
+ ].join('\n');
640
+ expect(content).toContain(expectedMethod);
641
+ // The full options interface (which requires actionName) must never be the
642
+ // second AutoPaginatable type argument — that is the TS2322 shape.
643
+ expect(content).not.toContain('AutoPaginatable<AuditLogSchema, ListActionSchemasOptions>');
644
+ });
645
+
646
+ it('keeps the full options type when no path params are destructured (regression)', () => {
647
+ const services: Service[] = [
648
+ {
649
+ name: 'AuditLogs',
650
+ operations: [
651
+ {
652
+ name: 'listActions',
653
+ httpMethod: 'get',
654
+ path: '/audit_logs/actions',
655
+ pathParams: [],
656
+ queryParams: paginationQueryParams,
657
+ headerParams: [],
658
+ response: { kind: 'array', items: { kind: 'model', name: 'AuditLogSchema' } },
659
+ pagination: cursorPagination,
660
+ errors: [],
661
+ injectIdempotencyKey: false,
662
+ },
663
+ ],
664
+ },
665
+ ];
666
+
667
+ const spec: ApiSpec = { ...emptySpec, services, models: [schemaModel] };
668
+ const result = generateResources(services, { ...ctx, spec });
669
+ const resourceFile = result.find((f) => f.path.includes('audit-logs.ts'));
670
+ expect(resourceFile).toBeDefined();
671
+
672
+ // Byte-identical to the pre-fix output: no Omit, no path destructure.
673
+ const expectedMethod = [
674
+ ' async listActions(options?: ListActionsOptions): Promise<AutoPaginatable<AuditLogSchema, ListActionsOptions>> {',
675
+ ' const paginationOptions = options;',
676
+ ' return new AutoPaginatable(',
677
+ ' await fetchAndDeserialize<AuditLogSchemaResponse, AuditLogSchema>(',
678
+ ' this.workos,',
679
+ " '/audit_logs/actions',",
680
+ ' deserializeAuditLogSchema,',
681
+ ' paginationOptions,',
682
+ ' ),',
683
+ ' (params) =>',
684
+ ' fetchAndDeserialize<AuditLogSchemaResponse, AuditLogSchema>(',
685
+ ' this.workos,',
686
+ " '/audit_logs/actions',",
687
+ ' deserializeAuditLogSchema,',
688
+ ' params,',
689
+ ' ),',
690
+ ' paginationOptions,',
691
+ ' );',
692
+ ' }',
693
+ ].join('\n');
694
+ expect(resourceFile!.content).toContain(expectedMethod);
695
+ expect(resourceFile!.content).not.toContain('Omit<');
696
+ });
697
+
698
+ it('omits every destructured path param when there are multiple', () => {
699
+ const memberModel: Model = {
700
+ name: 'GroupMember',
701
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
702
+ };
703
+ const services: Service[] = [
704
+ {
705
+ name: 'Groups',
706
+ operations: [
707
+ {
708
+ name: 'listGroupMembers',
709
+ httpMethod: 'get',
710
+ path: '/organizations/{organizationId}/groups/{groupId}/members',
711
+ pathParams: [
712
+ { name: 'organizationId', type: { kind: 'primitive', type: 'string' }, required: true },
713
+ { name: 'groupId', type: { kind: 'primitive', type: 'string' }, required: true },
714
+ ],
715
+ queryParams: paginationQueryParams,
716
+ headerParams: [],
717
+ response: { kind: 'array', items: { kind: 'model', name: 'GroupMember' } },
718
+ pagination: {
719
+ strategy: 'cursor',
720
+ param: 'after',
721
+ itemType: { kind: 'model', name: 'GroupMember' },
722
+ },
723
+ errors: [],
724
+ injectIdempotencyKey: false,
725
+ },
726
+ ],
727
+ },
728
+ ];
729
+
730
+ const spec: ApiSpec = { ...emptySpec, services, models: [memberModel] };
731
+ const result = generateResources(services, { ...ctx, spec });
732
+ const resourceFile = result.find((f) => f.path.includes('groups.ts'));
733
+ expect(resourceFile).toBeDefined();
734
+ const content = resourceFile!.content;
735
+
736
+ expect(content).toContain(
737
+ 'async listGroupMembers(options: ListGroupMembersOptions): ' +
738
+ "Promise<AutoPaginatable<GroupMember, Omit<ListGroupMembersOptions, 'organizationId' | 'groupId'>>> {",
739
+ );
740
+ expect(content).toContain('const { organizationId, groupId, ...paginationOptions } = options;');
741
+ expect(content).toContain(
742
+ '`/organizations/${encodeURIComponent(organizationId)}/groups/${encodeURIComponent(groupId)}/members`',
743
+ );
744
+ expect(content).not.toContain('AutoPaginatable<GroupMember, ListGroupMembersOptions>');
745
+ });
746
+ });
747
+
748
+ describe('inline object-literal baseline parameter types', () => {
749
+ // The hand-written workos-node AdminPortal method uses an inline object-literal
750
+ // parameter TYPE (`generateLink({ ... }: { intent: GenerateLinkIntent; ... })`).
751
+ // When the baseline surface reports that literal text as the param "type name",
752
+ // the emitter must keep it inline in the signature and must NOT slugify it into
753
+ // an interface filename or emit a named import of a brace-expression.
754
+ it('keeps the literal type inline and never imports it', () => {
755
+ const literalType = '{ intent: GenerateLinkIntent; organization: string; returnUrl?: string }';
756
+ const service: Service = {
757
+ name: 'AdminPortal',
758
+ operations: [
759
+ {
760
+ name: 'generateLink',
761
+ httpMethod: 'post',
762
+ path: '/portal/generate_link',
763
+ pathParams: [],
764
+ queryParams: [],
765
+ headerParams: [],
766
+ requestBody: { kind: 'model', name: 'GenerateLinkBody' },
767
+ response: { kind: 'model', name: 'PortalLink' },
768
+ errors: [],
769
+ injectIdempotencyKey: false,
770
+ },
771
+ ],
772
+ };
773
+ const spec: ApiSpec = {
774
+ ...emptySpec,
775
+ services: [service],
776
+ models: [
777
+ {
778
+ name: 'GenerateLinkBody',
779
+ fields: [
780
+ { name: 'intent', type: { kind: 'primitive', type: 'string' }, required: true },
781
+ { name: 'organization', type: { kind: 'primitive', type: 'string' }, required: true },
782
+ ],
783
+ },
784
+ {
785
+ name: 'PortalLink',
786
+ fields: [{ name: 'link', type: { kind: 'primitive', type: 'string' }, required: true }],
787
+ },
788
+ ],
789
+ };
790
+ const ctxWithBaseline: EmitterContext = {
791
+ ...ctx,
792
+ spec,
793
+ emitterOptions: { ownedServices: ['AdminPortal'] },
794
+ apiSurface: {
795
+ classes: {
796
+ AdminPortal: {
797
+ constructorParams: [{ name: 'workos', type: 'WorkOS' }],
798
+ methods: {
799
+ generateLink: [
800
+ {
801
+ name: 'generateLink',
802
+ params: [{ name: 'options', type: literalType, passingStyle: 'options_object' }],
803
+ returnType: 'Promise<{ link: string }>',
804
+ async: true,
805
+ },
806
+ ],
807
+ },
808
+ },
809
+ },
810
+ } as any,
811
+ };
812
+
813
+ const result = generateResources([service], ctxWithBaseline);
814
+ const resourceFile = result.find((f) => f.path === 'src/admin-portal/admin-portal.ts');
815
+ expect(resourceFile).toBeDefined();
816
+ const content = resourceFile!.content;
817
+
818
+ // The literal type stays inline in the method signature.
819
+ expect(content).toContain(`async generateLink(options: ${literalType})`);
820
+ // No named import of a brace-expression…
821
+ expect(content).not.toContain('import type { {');
822
+ // …and no import path derived from slugifying the literal type's text.
823
+ expect(content).not.toContain('intent-generate-link-intent');
824
+ // No interface file is emitted for the literal type either.
825
+ expect(result.some((f) => f.path.includes('intent-generate-link-intent'))).toBe(false);
826
+ });
827
+ });
828
+
829
+ describe('@oagen-ignore region method filtering', () => {
830
+ // `ignoredResourceMethodNames` scans @oagen-ignore-start/end regions in the
831
+ // existing on-disk resource file and the plan filter drops matching method
832
+ // names so user-preserved legacy methods are not re-emitted as duplicates.
833
+ // Generic methods (`name<T>(...)`, including multi-line type-parameter lists
834
+ // with constraints/defaults and nested angle brackets) must be caught too —
835
+ // on the SSO pass, region-protected getProfile<T>/getProfileAndToken<T> were
836
+ // re-appended as duplicates on every regen.
837
+ const ssoOp = (name: string, opPath: string) =>
838
+ ({
839
+ name,
840
+ httpMethod: 'get',
841
+ path: opPath,
842
+ pathParams: [],
843
+ queryParams: [],
844
+ headerParams: [],
845
+ response: { kind: 'model', name: 'Profile' },
846
+ errors: [],
847
+ injectIdempotencyKey: false,
848
+ }) as Service['operations'][number];
849
+
850
+ it('filters region-protected generic methods (single-line and multi-line type params)', () => {
851
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-ignore-region-'));
852
+ try {
853
+ fs.mkdirSync(path.join(tmpRoot, 'src', 'sso'), { recursive: true });
854
+ fs.writeFileSync(
855
+ path.join(tmpRoot, 'src', 'sso', 'sso.ts'),
856
+ [
857
+ "import type { WorkOS } from '../workos';",
858
+ '',
859
+ 'export class Sso {',
860
+ ' constructor(private readonly workos: WorkOS) {}',
861
+ '',
862
+ ' // @oagen-ignore-start',
863
+ ' async getProfile<T extends Record<string, unknown> = Record<string, unknown>>(accessToken: string): Promise<T> {',
864
+ ' return {} as T;',
865
+ ' }',
866
+ ' // @oagen-ignore-end',
867
+ '',
868
+ ' // @oagen-ignore-start',
869
+ ' async getProfileAndToken<',
870
+ ' T extends Record<string, unknown> = Record<string, unknown>,',
871
+ ' >(payload: { code: string }): Promise<T> {',
872
+ ' return {} as T;',
873
+ ' }',
874
+ ' // @oagen-ignore-end',
875
+ '',
876
+ ' // @oagen-ignore-start',
877
+ ' getAuthorizationUrl(options: { provider: string }): string {',
878
+ " return '';",
879
+ ' }',
880
+ ' // @oagen-ignore-end',
881
+ '}',
882
+ '',
883
+ ].join('\n'),
884
+ );
885
+
886
+ const service: Service = {
887
+ name: 'Sso',
888
+ operations: [
889
+ ssoOp('getProfile', '/sso/profile'),
890
+ ssoOp('getProfileAndToken', '/sso/token'),
891
+ ssoOp('getAuthorizationUrl', '/sso/authorize'),
892
+ {
893
+ name: 'deleteConnection',
894
+ httpMethod: 'delete',
895
+ path: '/connections/{id}',
896
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
897
+ queryParams: [],
898
+ headerParams: [],
899
+ response: { kind: 'primitive', type: 'unknown' },
900
+ errors: [],
901
+ injectIdempotencyKey: false,
902
+ },
903
+ ],
904
+ };
905
+ const profileModel: Model = {
906
+ name: 'Profile',
907
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
908
+ };
909
+ const spec: ApiSpec = { ...emptySpec, services: [service], models: [profileModel] };
910
+
911
+ const result = generateResources([service], {
912
+ ...ctx,
913
+ spec,
914
+ outputDir: tmpRoot,
915
+ emitterOptions: { ownedServices: ['Sso'] },
916
+ } as EmitterContext);
917
+
918
+ const resourceFile = result.find((f) => f.path === 'src/sso/sso.ts');
919
+ expect(resourceFile).toBeDefined();
920
+ const content = resourceFile!.content;
921
+ // The non-protected method is still emitted…
922
+ expect(content).toContain('async deleteConnection');
923
+ // …but region-protected methods are not re-emitted, generic or not.
924
+ expect(content).not.toContain('async getProfile(');
925
+ expect(content).not.toContain('async getProfileAndToken(');
926
+ expect(content).not.toContain('getAuthorizationUrl(');
927
+ } finally {
928
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
929
+ }
930
+ });
931
+ });
932
+
469
933
  describe('resolveResourceClassName', () => {
470
934
  it('uses overlay name when baseline has compatible constructor', () => {
471
935
  const service: Service = { name: 'Organizations', operations: [] };