@workos/oagen-emitters 0.18.3 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +16 -0
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/dist/{plugin-1ckLMpgo.mjs → plugin-BXDPA9pJ.mjs} +581 -172
  6. package/dist/plugin-BXDPA9pJ.mjs.map +1 -0
  7. package/dist/plugin.mjs +1 -1
  8. package/docs/sdk-architecture/rust.md +2 -2
  9. package/package.json +5 -5
  10. package/src/dotnet/enums.ts +11 -5
  11. package/src/dotnet/fixtures.ts +5 -2
  12. package/src/dotnet/index.ts +2 -1
  13. package/src/dotnet/models.ts +41 -10
  14. package/src/dotnet/naming.ts +10 -0
  15. package/src/dotnet/resources.ts +3 -3
  16. package/src/dotnet/tests.ts +8 -4
  17. package/src/go/fixtures.ts +4 -2
  18. package/src/go/index.ts +4 -0
  19. package/src/go/models.ts +4 -2
  20. package/src/go/naming.ts +10 -0
  21. package/src/go/resources.ts +22 -9
  22. package/src/go/tests.ts +3 -3
  23. package/src/kotlin/enums.ts +21 -11
  24. package/src/kotlin/index.ts +2 -1
  25. package/src/kotlin/models.ts +24 -9
  26. package/src/kotlin/naming.ts +11 -0
  27. package/src/kotlin/resources.ts +2 -2
  28. package/src/kotlin/tests.ts +7 -3
  29. package/src/node/enums.ts +8 -5
  30. package/src/node/field-plan.ts +3 -3
  31. package/src/node/index.ts +2 -1
  32. package/src/node/models.ts +69 -22
  33. package/src/node/naming.ts +10 -0
  34. package/src/node/options.ts +45 -1
  35. package/src/node/resources.ts +67 -18
  36. package/src/node/tests.ts +302 -31
  37. package/src/php/enums.ts +18 -5
  38. package/src/php/index.ts +13 -4
  39. package/src/php/models.ts +22 -10
  40. package/src/php/naming.ts +10 -0
  41. package/src/php/resources.ts +6 -4
  42. package/src/php/tests.ts +17 -5
  43. package/src/python/enums.ts +39 -28
  44. package/src/python/fixtures.ts +4 -3
  45. package/src/python/index.ts +2 -1
  46. package/src/python/models.ts +39 -24
  47. package/src/python/naming.ts +10 -0
  48. package/src/python/resources.ts +3 -3
  49. package/src/python/tests.ts +14 -9
  50. package/src/ruby/enums.ts +28 -19
  51. package/src/ruby/index.ts +2 -1
  52. package/src/ruby/models.ts +33 -19
  53. package/src/ruby/naming.ts +10 -0
  54. package/src/ruby/rbi.ts +20 -7
  55. package/src/ruby/resources.ts +2 -2
  56. package/src/ruby/tests.ts +6 -3
  57. package/src/rust/enums.ts +9 -1
  58. package/src/rust/index.ts +2 -1
  59. package/src/rust/models.ts +100 -15
  60. package/src/rust/naming.ts +10 -0
  61. package/src/rust/resources.ts +14 -3
  62. package/src/rust/tests.ts +2 -2
  63. package/src/shared/file-header.ts +13 -0
  64. package/src/shared/resolved-ops.ts +47 -0
  65. package/test/rust/models.test.ts +49 -0
  66. package/test/shared/synthetic-enum-seed.test.ts +79 -0
  67. package/dist/plugin-1ckLMpgo.mjs.map +0 -1
@@ -34,7 +34,7 @@ import {
34
34
  import {
35
35
  buildResolvedLookup,
36
36
  lookupResolved,
37
- groupByMount,
37
+ scopedMountGroups,
38
38
  buildHiddenParams,
39
39
  getOpDefaults,
40
40
  getOpInferFromClient,
@@ -97,7 +97,7 @@ function promoteFieldType(f: Field): Field {
97
97
  export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
98
98
  if (services.length === 0) return [];
99
99
 
100
- const mountGroups = groupByMount(ctx);
100
+ const mountGroups = scopedMountGroups(ctx);
101
101
  if (mountGroups.size === 0) return [];
102
102
 
103
103
  const files: GeneratedFile[] = [];
@@ -17,11 +17,12 @@ import {
17
17
  ktStringLiteral,
18
18
  className,
19
19
  propertyName,
20
+ domainPropertyName,
20
21
  buildExportedClassNameSet,
21
22
  } from './naming.js';
22
23
  import { mapTypeRef } from './type-map.js';
23
24
  import {
24
- groupByMount,
25
+ scopedMountGroups,
25
26
  lookupResolved,
26
27
  buildResolvedLookup,
27
28
  buildHiddenParams,
@@ -75,7 +76,7 @@ function promoteIso8601TypeRef(type: TypeRef, description: string | undefined):
75
76
  */
76
77
  export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
77
78
  const files: GeneratedFile[] = [];
78
- const mountGroups = groupByMount(ctx);
79
+ const mountGroups = scopedMountGroups(ctx);
79
80
  const resolvedLookup = buildResolvedLookup(ctx);
80
81
 
81
82
  const exportedClasses = buildExportedClassNameSet(ctx);
@@ -713,7 +714,10 @@ function buildResponseAssertions(
713
714
  for (const field of model.fields) {
714
715
  if (!field.required) continue;
715
716
  if (assertions.length >= MAX_RESPONSE_ASSERTIONS) break;
716
- const ktProp = propertyName(field.name);
717
+ // DOMAIN identifier: the property accessor on the deserialized model.
718
+ // Honors a `domainName` override; the synthesized JSON above keys off
719
+ // `field.name` (the wire key).
720
+ const ktProp = domainPropertyName(field);
717
721
  const type = field.type;
718
722
  if (type.kind === 'primitive') {
719
723
  if (type.format === 'date-time') continue;
package/src/node/enums.ts CHANGED
@@ -5,6 +5,7 @@ import { docComment, assignModelsToEmittableServices } from './utils.js';
5
5
  import { isInlineEnum } from './type-map.js';
6
6
  import { isNodeOwnedService } from './options.js';
7
7
  import { liveSurfaceConstEnumMembers, liveSurfaceInterfacePath } from './live-surface.js';
8
+ import { isEnumInScope } from '../shared/resolved-ops.js';
8
9
 
9
10
  export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
10
11
  if (enums.length === 0) return [];
@@ -120,11 +121,13 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
120
121
  lines.push(` (typeof ${enumDef.name})[keyof typeof ${enumDef.name}];`);
121
122
  }
122
123
 
123
- files.push({
124
- path: `src/${dirName}/interfaces/${fileName(enumDef.name)}.interface.ts`,
125
- content: lines.join('\n'),
126
- skipIfExists: !hasNewValues,
127
- });
124
+ if (isEnumInScope(enumDef.name, ctx)) {
125
+ files.push({
126
+ path: `src/${dirName}/interfaces/${fileName(enumDef.name)}.interface.ts`,
127
+ content: lines.join('\n'),
128
+ skipIfExists: !hasNewValues,
129
+ });
130
+ }
128
131
  }
129
132
 
130
133
  return files;
@@ -388,7 +388,7 @@ export function serializerHasBaselineIncompatibility(
388
388
  const irDomainFields = new Set<string>();
389
389
  for (const field of model.fields) {
390
390
  irWireFields.add(wireFieldName(field.name));
391
- irDomainFields.add(fieldName(field.name));
391
+ irDomainFields.add(fieldName(field.domainName ?? field.name));
392
392
  }
393
393
 
394
394
  for (const [wireField2, fieldDef] of Object.entries(baselineResponse.fields)) {
@@ -463,7 +463,7 @@ export function planDeserializeField(
463
463
  skipFormatFields: Set<string>,
464
464
  ctx: EmitterContext,
465
465
  ): { line: string; skip: boolean } {
466
- const domain = fieldName(field.name);
466
+ const domain = fieldName(field.domainName ?? field.name);
467
467
  const wire = wireFieldName(field.name);
468
468
  const wireAccess = `response.${wire}`;
469
469
  const skip = skipFormatFields.has(field.name);
@@ -543,7 +543,7 @@ export function planSerializeField(
543
543
  ctx: EmitterContext,
544
544
  ): { line: string; skip: boolean } {
545
545
  const wire = wireFieldName(field.name);
546
- const domain = fieldName(field.name);
546
+ const domain = fieldName(field.domainName ?? field.name);
547
547
  const domainAccess = `model.${domain}`;
548
548
  const skip = skipFormatFields.has(field.name);
549
549
 
package/src/node/index.ts CHANGED
@@ -40,6 +40,7 @@ import { withNodeOperationOverrides } from './node-overrides.js';
40
40
  import { isNodeOwnedService, nodeOptions } from './options.js';
41
41
  import { setInlineEnumUnions, setDomainNameResolver } from './type-map.js';
42
42
  import { groupByMount } from '../shared/resolved-ops.js';
43
+ import { AUTOGEN_NOTICE } from '../shared/file-header.js';
43
44
  import { assignModelsToServices, createServiceDirResolver, relativeImport } from './utils.js';
44
45
  import { fileName } from './naming.js';
45
46
 
@@ -869,7 +870,7 @@ export const nodeEmitter: Emitter = {
869
870
  },
870
871
 
871
872
  fileHeader(): string {
872
- return '// This file is auto-generated by oagen. Do not edit.';
873
+ return `// ${AUTOGEN_NOTICE}`;
873
874
  },
874
875
 
875
876
  formatCommand(targetDir: string): FormatCommand | null {
@@ -45,7 +45,7 @@ import {
45
45
  import { liveSurfaceHasExistingSdk, liveSurfaceHasManagedFile, liveSurfaceInterfacePath } from './live-surface.js';
46
46
  import { isNodeOwnedService, isHandOwnedType } from './options.js';
47
47
  import { unwrapListModel } from './fixtures.js';
48
- import { groupByMount, buildResolvedLookup, lookupResolved } from '../shared/resolved-ops.js';
48
+ import { groupByMount, buildResolvedLookup, lookupResolved, isModelInScope } from '../shared/resolved-ops.js';
49
49
  import { resolveWrapperParams } from '../shared/wrapper-utils.js';
50
50
  import { collectWrapperResponseModels } from './wrappers.js';
51
51
  import { resolveResourceClassName } from './resources.js';
@@ -308,11 +308,13 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
308
308
  const aliasLines = importSymbols
309
309
  ? [`import type { ${importSymbols} } from '${canonRelPath}';`, '', ...aliasExports]
310
310
  : [...aliasExports];
311
- files.push({
312
- path: aliasPath,
313
- content: aliasLines.join('\n'),
314
- overwriteExisting: true,
315
- });
311
+ if (isModelInScope(model.name, ctx)) {
312
+ files.push({
313
+ path: aliasPath,
314
+ content: aliasLines.join('\n'),
315
+ overwriteExisting: true,
316
+ });
317
+ }
316
318
  continue;
317
319
  }
318
320
 
@@ -527,7 +529,9 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
527
529
  } else {
528
530
  lines.push(`export interface ${domainName}${typeParams} {`);
529
531
  for (const field of model.fields) {
530
- const domainFieldName = fieldName(field.name);
532
+ // Domain identifier honors a `fieldHints` override (e.g. connection_type
533
+ // → type); the wire name below still derives from `field.name`.
534
+ const domainFieldName = fieldName(field.domainName ?? field.name);
531
535
  if (seenDomainFields.has(domainFieldName)) continue;
532
536
  seenDomainFields.add(domainFieldName);
533
537
  if (field.description || field.deprecated || field.readOnly || field.writeOnly || field.default !== undefined) {
@@ -546,6 +550,19 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
546
550
  baselineField && !baselineField.optional && responseBaselineField && responseBaselineField.optional;
547
551
  const readonlyPrefix = field.readOnly ? 'readonly ' : '';
548
552
  if (
553
+ genericDefaults.has(model.name) &&
554
+ baselineField &&
555
+ typeReferencesUnresolvable(baselineField.type, unresolvableNames)
556
+ ) {
557
+ // Baseline typed this field with the model's generic param (e.g.
558
+ // `customAttributes?: CustomAttributesType`). The emitted interface
559
+ // renames the param to `GenericType`; remap the field so the param is
560
+ // actually used — otherwise it trips TS6133 (declared, never read).
561
+ const opt = baselineField.optional ? '?' : '';
562
+ lines.push(
563
+ ` ${readonlyPrefix}${domainFieldName}${opt}: ${substituteGenericParam(baselineField.type, unresolvableNames)};`,
564
+ );
565
+ } else if (
549
566
  baselineField &&
550
567
  !domainResponseOptionalMismatch &&
551
568
  !hasDateTimeConversion(field.type) &&
@@ -597,6 +614,15 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
597
614
  seenWireFields.add(wireField);
598
615
  const baselineField = baselineResponse?.fields?.[wireField];
599
616
  if (
617
+ genericDefaults.has(model.name) &&
618
+ baselineField &&
619
+ typeReferencesUnresolvable(baselineField.type, unresolvableNames)
620
+ ) {
621
+ // Mirror the domain side: keep the generic param wired on the
622
+ // response interface so `GenericType` is used, not orphaned.
623
+ const opt = baselineField.optional ? '?' : '';
624
+ lines.push(` ${wireField}${opt}: ${substituteGenericParam(baselineField.type, unresolvableNames)};`);
625
+ } else if (
600
626
  baselineField &&
601
627
  baselineTypeResolvable(baselineField.type, importableNames) &&
602
628
  baselineFieldCompatible(baselineField, field)
@@ -721,11 +747,13 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
721
747
  }
722
748
  }
723
749
 
724
- files.push({
725
- path: filePath,
726
- content: pruneUnusedImports(lines).join('\n'),
727
- overwriteExisting: true,
728
- });
750
+ if (isModelInScope(model.name, ctx)) {
751
+ files.push({
752
+ path: filePath,
753
+ content: pruneUnusedImports(lines).join('\n'),
754
+ overwriteExisting: true,
755
+ });
756
+ }
729
757
  }
730
758
 
731
759
  return files;
@@ -919,11 +947,13 @@ export function generateSerializers(
919
947
  parts.push(`serialize${canonDomainName} as serialize${domainName}`);
920
948
  }
921
949
  const reexportContent = `export { ${parts.join(', ')} } from '${rel}';`;
922
- files.push({
923
- path: serializerPath,
924
- content: reexportContent,
925
- overwriteExisting: true,
926
- });
950
+ if (isModelInScope(model.name, ctx)) {
951
+ files.push({
952
+ path: serializerPath,
953
+ content: reexportContent,
954
+ overwriteExisting: true,
955
+ });
956
+ }
927
957
  continue;
928
958
  }
929
959
  // The alias is response-reachable, but the canonical model is
@@ -973,11 +1003,13 @@ export function generateSerializers(
973
1003
  ),
974
1004
  ];
975
1005
 
976
- files.push({
977
- path: serializerPath,
978
- content: pruneUnusedImports(lines).join('\n'),
979
- overwriteExisting: true,
980
- });
1006
+ if (isModelInScope(model.name, ctx)) {
1007
+ files.push({
1008
+ path: serializerPath,
1009
+ content: pruneUnusedImports(lines).join('\n'),
1010
+ overwriteExisting: true,
1011
+ });
1012
+ }
981
1013
  }
982
1014
 
983
1015
  (ctx as any)._skippedSerializeModels = skippedSerializeModels;
@@ -1301,6 +1333,21 @@ function hasSpecificIRType(ref: TypeRef): boolean {
1301
1333
  }
1302
1334
  }
1303
1335
 
1336
+ /** True when a baseline field type references one of the model's generic
1337
+ * param identifiers (collected as `unresolvableNames` — bare type names that
1338
+ * resolve to nothing importable, which for a generic model are its params). */
1339
+ function typeReferencesUnresolvable(type: string, unresolvable: Set<string>): boolean {
1340
+ if (unresolvable.size === 0) return false;
1341
+ const names = type.match(/\b[A-Z][a-zA-Z0-9]*\b/g);
1342
+ return !!names && names.some((n) => unresolvable.has(n));
1343
+ }
1344
+
1345
+ /** Rewrite a baseline field type so references to the model's (renamed)
1346
+ * generic param resolve to the emitted `GenericType` param. */
1347
+ function substituteGenericParam(type: string, unresolvable: Set<string>): string {
1348
+ return type.replace(/\b[A-Z][a-zA-Z0-9]*\b/g, (name) => (unresolvable.has(name) ? 'GenericType' : name));
1349
+ }
1350
+
1304
1351
  function renderTypeParams(model: Model, genericDefaults?: Map<string, string>): string {
1305
1352
  if (!model.typeParams?.length) {
1306
1353
  if (genericDefaults?.has(model.name)) {
@@ -32,6 +32,16 @@ export function fieldName(name: string): string {
32
32
  return toCamelCase(name);
33
33
  }
34
34
 
35
+ /**
36
+ * camelCase domain field name for a model field, honoring a `domainName`
37
+ * override (set via the `fieldHints` config) so a wire field can surface under
38
+ * a friendlier name. The wire name (see {@link wireFieldName}) still derives
39
+ * from `field.name`.
40
+ */
41
+ export function domainFieldName(field: { name: string; domainName?: string }): string {
42
+ return toCamelCase(field.domainName ?? field.name);
43
+ }
44
+
35
45
  /** snake_case field name for wire/response interfaces. */
36
46
  export function wireFieldName(name: string): string {
37
47
  return toSnakeCase(name);
@@ -1,14 +1,35 @@
1
- import type { EmitterContext } from '@workos/oagen';
1
+ import type { EmitterContext, Operation, OperationPlan } from '@workos/oagen';
2
+ import { planOperation } from '@workos/oagen';
2
3
 
3
4
  export interface OperationOverride {
4
5
  methodName?: string;
5
6
  mountOn?: string;
6
7
  optionsType?: string;
7
8
  bodyFieldMap?: Record<string, string>;
9
+ /**
10
+ * Rename spec path parameters to the SDK options-object field they should be
11
+ * exposed as. Keys are the camelCase identifier the param resolves to via
12
+ * `fieldName(param.name)` — NOT the raw (possibly snake_case) spec key — since
13
+ * the lookup is keyed on that camelCase form (e.g. `{ resourceId: 'targetId' }`,
14
+ * even for a `resource_id` spec param). Applied to the destructure, the URL
15
+ * template binding, and generated tests so a published SDK field name can
16
+ * diverge from the spec path-param name without a global spec rewrite (which
17
+ * would ripple across every language).
18
+ */
19
+ pathFieldMap?: Record<string, string>;
8
20
  returnType?: string;
9
21
  returnDataProperty?: string;
10
22
  returnTypeImports?: string[];
11
23
  returnExpression?: string;
24
+ /**
25
+ * Override the response model the operation deserializes, by model name.
26
+ * Replaces the spec-derived response model so the resource (and its test)
27
+ * reference a different wire type / deserializer — e.g. mapping
28
+ * Authorization's role responses to the full `OrganizationRole` instead of
29
+ * the slim `Role`/`RoleResponse` shape shared with SSO and UserManagement.
30
+ * Node-only; never affects the global spec or other SDKs.
31
+ */
32
+ responseModel?: string;
12
33
  }
13
34
 
14
35
  export interface NodeEmitterOptions {
@@ -90,3 +111,26 @@ export function isHandOwnedType(ctx: EmitterContext, name: string | undefined):
90
111
  if (!configured || configured.length === 0) return false;
91
112
  return configured.includes(name);
92
113
  }
114
+
115
+ /**
116
+ * Resolve the Node operation override for an operation, keyed by "METHOD /path".
117
+ */
118
+ export function operationOverrideFor(ctx: EmitterContext, op: Operation): OperationOverride | undefined {
119
+ return nodeOptions(ctx).operationOverrides?.[`${op.httpMethod.toUpperCase()} ${op.path}`];
120
+ }
121
+
122
+ /**
123
+ * `planOperation` plus the Node `responseModel` override. When an operation
124
+ * override supplies `responseModel`, the resolved response model name is
125
+ * replaced so the resource and its generated test reference the desired wire
126
+ * type and deserializer. Use this everywhere the Node emitter would otherwise
127
+ * call `planOperation(op)` directly so resource and test stay in lockstep.
128
+ */
129
+ export function planOperationFor(op: Operation, ctx: EmitterContext): OperationPlan {
130
+ const plan = planOperation(op);
131
+ const responseModel = operationOverrideFor(ctx, op)?.responseModel;
132
+ if (responseModel && responseModel !== plan.responseModelName) {
133
+ return { ...plan, responseModelName: responseModel, isModelResponse: true };
134
+ }
135
+ return plan;
136
+ }
@@ -11,7 +11,7 @@ import type {
11
11
  Model,
12
12
  ResolvedOperation,
13
13
  } from '@workos/oagen';
14
- import { planOperation, toPascalCase, toCamelCase } from '@workos/oagen';
14
+ import { toPascalCase, toCamelCase } from '@workos/oagen';
15
15
  import type { OperationPlan } from '@workos/oagen';
16
16
  import { mapTypeRef, isInlineEnum } from './type-map.js';
17
17
  import {
@@ -78,13 +78,14 @@ import {
78
78
  buildResolvedLookup,
79
79
  lookupResolved,
80
80
  groupByMount,
81
+ isMountInScope,
81
82
  getOpDefaults,
82
83
  getOpInferFromClient,
83
84
  } from '../shared/resolved-ops.js';
84
85
  import { generateWrapperMethods, collectWrapperResponseModels } from './wrappers.js';
85
86
  import { buildNodePathExpression } from './path-expression.js';
86
87
  import { resolveWrapperParams } from '../shared/wrapper-utils.js';
87
- import { isNodeOwnedService, nodeOptions } from './options.js';
88
+ import { isNodeOwnedService, operationOverrideFor, planOperationFor } from './options.js';
88
89
 
89
90
  /**
90
91
  * Check whether the baseline (hand-written) class has a constructor compatible
@@ -202,16 +203,12 @@ function existingInterfaceBarrelExports(ctx: EmitterContext, serviceDir: string,
202
203
  return new RegExp(`export\\s+(?:type\\s+)?(?:\\*|\\{[^}]+\\})\\s+from\\s+['"]\\./${escapedStem}['"]`).test(content);
203
204
  }
204
205
 
205
- function operationOverrideFor(ctx: EmitterContext, op: Operation) {
206
- return nodeOptions(ctx).operationOverrides?.[`${op.httpMethod.toUpperCase()} ${op.path}`];
207
- }
208
-
209
206
  function baselineMethodFor(service: Service, method: string, ctx: EmitterContext): BaselineMethod | undefined {
210
207
  const serviceClass = resolveResourceClassName(service, ctx);
211
208
  return ctx.apiSurface?.classes?.[serviceClass]?.methods?.[method]?.[0] as BaselineMethod | undefined;
212
209
  }
213
210
 
214
- function ignoredResourceMethodNames(ctx: EmitterContext, resourcePath: string): Set<string> {
211
+ export function ignoredResourceMethodNames(ctx: EmitterContext, resourcePath: string): Set<string> {
215
212
  const root = ctx.outputDir ?? ctx.targetDir;
216
213
  if (!root) return new Set();
217
214
 
@@ -242,8 +239,13 @@ function optionsObjectParam(method: BaselineMethod | undefined): OptionsObjectPa
242
239
  const [param] = method.params;
243
240
  if (param.name !== 'options') return undefined;
244
241
  if (param.passingStyle && param.passingStyle !== 'options_object') return undefined;
245
- if (!param.type || /^(Record|object|any|unknown)\b/.test(param.type)) return undefined;
246
- return { name: 'options', type: param.type, optional: param.optional === true, generated: false };
242
+ // An optional param's surface type is `Options | undefined`; the optionality
243
+ // is carried by `param.optional`, so strip the nullable arm to recover the
244
+ // bare type NAME (used for `serialize${type}` + imports). Leaving it in emits
245
+ // `serializeOptions | undefined` — a syntax error.
246
+ const type = param.type?.replace(/(?:\s*\|\s*(?:undefined|null))+\s*$/, '').trim();
247
+ if (!type || /^(Record|object|any|unknown)\b/.test(type)) return undefined;
248
+ return { name: 'options', type, optional: param.optional === true, generated: false };
247
249
  }
248
250
 
249
251
  function methodOptionsName(method: string, resolvedServiceName: string): string {
@@ -568,7 +570,7 @@ function generateOptionsInterfaces(service: Service, ctx: EmitterContext, specEn
568
570
 
569
571
  const plans = service.operations.map((op) => ({
570
572
  op,
571
- plan: planOperation(op),
573
+ plan: planOperationFor(op, ctx),
572
574
  method: resolveMethodName(op, service, ctx),
573
575
  }));
574
576
 
@@ -577,7 +579,13 @@ function generateOptionsInterfaces(service: Service, ctx: EmitterContext, specEn
577
579
  const baselineMethod = baselineMethodFor(service, method, ctx);
578
580
  const optionInfo = optionsObjectInfo(service, method, op, plan, ctx, baselineMethod, resolvedOp);
579
581
  if (!optionInfo?.generated) continue;
580
- if (baselineTypeSourceFile(ctx, optionInfo.type)) continue;
582
+ // A baseline type of the same name normally means "hand-owned / preserved —
583
+ // do not regenerate" (guards the alias-feedback loop). But when the op has
584
+ // path params, the modernized resource folds them INTO the options object
585
+ // (`const { organizationId, ...payload } = options`), so a body-only
586
+ // baseline interface (from a legacy `(pathParam, options)` signature) is
587
+ // definitionally incompatible. Regenerate it to include the path params.
588
+ if (op.pathParams.length === 0 && baselineTypeSourceFile(ctx, optionInfo.type)) continue;
581
589
 
582
590
  const optionsName = optionInfo.type;
583
591
  const optionFileStem = `${fileName(optionsName)}.interface`;
@@ -645,9 +653,10 @@ function generateOptionsInterfaces(service: Service, ctx: EmitterContext, specEn
645
653
  headerParts.push(` ${name}${opt}: ${type};`);
646
654
  };
647
655
 
656
+ const optionsPathFieldMap = operationOverrideFor(ctx, op)?.pathFieldMap;
648
657
  for (const param of op.pathParams) {
649
658
  pushField(
650
- fieldName(param.name),
659
+ optionsPathFieldMap?.[fieldName(param.name)] ?? fieldName(param.name),
651
660
  true,
652
661
  mapParamType(param.type, specEnumNames),
653
662
  param.description,
@@ -710,11 +719,18 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
710
719
  // multiple IR services mount to the same resource class.
711
720
  const mountGroups = groupByMount(ctx);
712
721
  const mergedServices: Service[] =
713
- mountGroups.size > 0 ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations })) : services;
722
+ mountGroups.size > 0 || ctx.scopedServices?.size
723
+ ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
724
+ : services;
714
725
 
715
726
  const topLevelEnumNames = new Set(ctx.spec.enums.map((e) => e.name));
716
727
 
717
728
  for (const service of mergedServices) {
729
+ // Scope gate: in a scoped (`--services`) run, only emit per-service resource
730
+ // files for the selected post-mount names. `service.name` is the mount-group
731
+ // key (the POST-MOUNT name that matches `ctx.scopedServices`). Applied as an
732
+ // additional early continue ahead of the node-owned/coverage skip logic.
733
+ if (!isMountInScope(service.name, ctx)) continue;
718
734
  const isOwnedService = isNodeOwnedService(ctx, service.name, resolveResourceClassName(service, ctx));
719
735
  if (!isOwnedService && isServiceCoveredByExisting(service, ctx)) {
720
736
  if (!hasMethodsAbsentFromBaseline(service, ctx)) {
@@ -762,6 +778,9 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
762
778
  // stable. Placing them under `interfaces/` lets the per-service barrel
763
779
  // pick them up automatically.
764
780
  for (const service of mergedServices) {
781
+ // Scope gate: keep the per-service options interfaces aligned with the
782
+ // resource files emitted above — only the selected post-mount names.
783
+ if (!isMountInScope(service.name, ctx)) continue;
765
784
  const isOwnedService = isNodeOwnedService(ctx, service.name, resolveResourceClassName(service, ctx));
766
785
  if (!isOwnedService && isServiceCoveredByExisting(service, ctx) && !hasMethodsAbsentFromBaseline(service, ctx))
767
786
  continue;
@@ -779,7 +798,7 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
779
798
 
780
799
  let plans = service.operations.map((op) => ({
781
800
  op,
782
- plan: planOperation(op),
801
+ plan: planOperationFor(op, ctx),
783
802
  method: resolveMethodName(op, service, ctx),
784
803
  }));
785
804
 
@@ -1038,6 +1057,10 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
1038
1057
  }
1039
1058
 
1040
1059
  const importedTypeNames = new Set<string>();
1060
+ // `PaginationOptions` is already imported once above when any method
1061
+ // paginates; a method whose options type IS `PaginationOptions` would
1062
+ // otherwise re-import it here (TS2300 duplicate identifier).
1063
+ if (needsPaginationOptionsImport) importedTypeNames.add('PaginationOptions');
1041
1064
  for (const optionType of optionObjectTypes) {
1042
1065
  if (isValidTypeIdentifier(optionType)) {
1043
1066
  if (importedTypeNames.has(optionType)) continue;
@@ -1954,7 +1977,20 @@ function renderOptionsObjectMethod(
1954
1977
  return true;
1955
1978
  }
1956
1979
 
1957
- return false;
1980
+ // Body-less, response-less mutation with path params and/or query folded into
1981
+ // the options object (e.g. POST /feature-flags/{slug}/targets/{targetId} -> 204).
1982
+ // The DELETE equivalent is handled above; this covers POST/PUT/PATCH (and any
1983
+ // other verb) that returns no content. Without this branch such operations
1984
+ // fall through to the positional renderVoidMethod, which is inconsistent with
1985
+ // the options-object surface the rest of an owned service uses.
1986
+ {
1987
+ lines.push(` async ${method}(${renderOptionsParam(optionParam)}): Promise<void> {`);
1988
+ renderOptionsObjectDestructure(lines, pathBindings);
1989
+ const emptyBodyArg = httpMethodNeedsBody(op.httpMethod) ? ', {}' : '';
1990
+ lines.push(` await this.workos.${op.httpMethod}(${pathStr}${emptyBodyArg}${queryOptionsArg});`);
1991
+ lines.push(' }');
1992
+ return true;
1993
+ }
1958
1994
  }
1959
1995
 
1960
1996
  function renderOptionsObjectDestructure(lines: string[], pathBindings: string[], restName?: string): void {
@@ -1988,7 +2024,8 @@ function buildOptionsObjectPathBindings(op: Operation, optionType: string, ctx:
1988
2024
  // Return resolved SDK field names directly — the URL template uses these
1989
2025
  // names too (via the param-name map threaded into buildNodePathExpression),
1990
2026
  // so the destructure no longer needs `optionField: localName` renames.
1991
- return op.pathParams.map((param) => resolveOptionsObjectField(fieldName(param.name), optionType, ctx));
2027
+ const pathFieldMap = operationOverrideFor(ctx, op)?.pathFieldMap;
2028
+ return op.pathParams.map((param) => resolveOptionsObjectField(fieldName(param.name), optionType, ctx, pathFieldMap));
1992
2029
  }
1993
2030
 
1994
2031
  /**
@@ -2000,15 +2037,27 @@ function buildOptionsObjectPathBindings(op: Operation, optionType: string, ctx:
2000
2037
  */
2001
2038
  function buildOptionsObjectPathParamMap(op: Operation, optionType: string, ctx: EmitterContext): Map<string, string> {
2002
2039
  const map = new Map<string, string>();
2040
+ const pathFieldMap = operationOverrideFor(ctx, op)?.pathFieldMap;
2003
2041
  for (const param of op.pathParams) {
2004
2042
  const localName = fieldName(param.name);
2005
- const sdkField = resolveOptionsObjectField(localName, optionType, ctx);
2043
+ const sdkField = resolveOptionsObjectField(localName, optionType, ctx, pathFieldMap);
2006
2044
  if (sdkField !== localName) map.set(param.name, sdkField);
2007
2045
  }
2008
2046
  return map;
2009
2047
  }
2010
2048
 
2011
- function resolveOptionsObjectField(localName: string, optionType: string, ctx: EmitterContext): string {
2049
+ function resolveOptionsObjectField(
2050
+ localName: string,
2051
+ optionType: string,
2052
+ ctx: EmitterContext,
2053
+ pathFieldMap?: Record<string, string>,
2054
+ ): string {
2055
+ // Operation-override rename (Node-scoped) wins unconditionally: an explicit
2056
+ // pathFieldMap is honored even when the (freshly generated) options interface
2057
+ // isn't in the baseline surface yet — generateOptionsInterfaces applies the
2058
+ // same map to the emitted field, so destructure and interface stay in lockstep.
2059
+ const mapped = pathFieldMap?.[localName];
2060
+ if (mapped) return mapped;
2012
2061
  const fields = ctx.apiSurface?.interfaces?.[optionType]?.fields;
2013
2062
  if (!fields) return localName;
2014
2063
  if (fields[localName]) return localName;