@workos/oagen-emitters 0.6.6 → 0.6.8

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.
package/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as workosEmittersPlugin } from "./plugin-BgVrq-hM.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-Cmg_LFtm.mjs";
2
2
  export { workosEmittersPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/oagen-emitters",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -103,7 +103,14 @@ export const dotnetEmitter: Emitter = {
103
103
  return m;
104
104
  });
105
105
 
106
- const discCtx: DiscriminatorContext = { discriminatorBases, variantToBase };
106
+ // Build a map of base model name → discriminator wire-property name so the
107
+ // model emitter can mark the discriminator field as internal-set.
108
+ const discriminatorProperties = new Map<string, string>();
109
+ for (const [baseName, disc] of modelDiscriminators) {
110
+ discriminatorProperties.set(baseName, disc.property);
111
+ }
112
+
113
+ const discCtx: DiscriminatorContext = { discriminatorBases, variantToBase, discriminatorProperties };
107
114
  const files = generateModels(dotnetModels, c, discCtx);
108
115
 
109
116
  // Generate discriminator converters for oneOf unions with discriminator
@@ -181,6 +188,8 @@ export const dotnetEmitter: Emitter = {
181
188
  lines.push(` /// </summary>`);
182
189
  lines.push(` public class ${converterName} : Newtonsoft.Json.JsonConverter`);
183
190
  lines.push(' {');
191
+ lines.push(' public override bool CanWrite => false;');
192
+ lines.push('');
184
193
  lines.push(
185
194
  ` public override bool CanConvert(Type objectType) => typeof(${baseClass}).IsAssignableFrom(objectType);`,
186
195
  );
@@ -206,8 +215,6 @@ export const dotnetEmitter: Emitter = {
206
215
  lines.push(' return target;');
207
216
  lines.push(' }');
208
217
  lines.push('');
209
- lines.push(' public override bool CanWrite => false;');
210
- lines.push('');
211
218
  lines.push(
212
219
  ' public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object? value, Newtonsoft.Json.JsonSerializer serializer)',
213
220
  );
@@ -31,6 +31,8 @@ export interface DiscriminatorContext {
31
31
  discriminatorBases: Set<string>;
32
32
  /** Maps variant model name → base model name. */
33
33
  variantToBase: Map<string, string>;
34
+ /** Maps base model name → wire name of the discriminator property. */
35
+ discriminatorProperties?: Map<string, string>;
34
36
  }
35
37
 
36
38
  /**
@@ -161,6 +163,12 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
161
163
  let initializer = '';
162
164
  let setterModifier = '';
163
165
 
166
+ // On a discriminated union base, the discriminator property (e.g. "event")
167
+ // should be non-public-settable even though it lacks a single const value
168
+ // (each variant has a different value). Consumers must never mutate it.
169
+ const discProp = isDiscBase ? discCtx?.discriminatorProperties?.get(model.name) : undefined;
170
+ const isDiscriminatorField = discProp !== undefined && field.name === discProp;
171
+
164
172
  if (constInit !== null && !isOptional) {
165
173
  // Discriminator-style single-value enum/literal: emit with a const
166
174
  // initializer and a non-public setter so callers can't drift the
@@ -170,6 +178,14 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
170
178
  csType = baseType;
171
179
  initializer = ` = ${constInit};`;
172
180
  setterModifier = 'internal ';
181
+ } else if (isDiscriminatorField) {
182
+ // Discriminator property on the base class: varies per variant but
183
+ // should still be non-public-settable so consumers can't change it.
184
+ csType = baseType;
185
+ if (!isAlreadyNullable && !isValueTypeRef(field.type)) {
186
+ initializer = ' = default!;';
187
+ }
188
+ setterModifier = 'internal ';
173
189
  } else if (isOptional) {
174
190
  if (isAlreadyNullable) {
175
191
  csType = baseType;
@@ -219,6 +235,14 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
219
235
  lines.push(` /// <paramref name="key"/> coerced to <typeparamref name="T"/>, or the default`);
220
236
  lines.push(` /// value when the key is missing or the value is not convertible.`);
221
237
  lines.push(` /// </summary>`);
238
+ if (isDiscBase) {
239
+ lines.push(` /// <remarks>`);
240
+ lines.push(` /// Variant subclasses provide strongly-typed <c>${dict.csName}</c> properties that`);
241
+ lines.push(` /// shadow this dictionary. This accessor is intended for forward-compatible handling`);
242
+ lines.push(` /// of types not yet known to this SDK version. For recognized types, cast to the`);
243
+ lines.push(` /// specific subclass and access its typed <c>${dict.csName}</c> property directly.`);
244
+ lines.push(` /// </remarks>`);
245
+ }
222
246
  lines.push(` /// <typeparam name="T">Expected value type.</typeparam>`);
223
247
  lines.push(` /// <param name="key">The key to look up.</param>`);
224
248
  lines.push(` public T? Get${dict.csName}Attribute<T>(string key)`);
package/src/go/models.ts CHANGED
@@ -166,10 +166,27 @@ function makeOptional(goType: string): string {
166
166
  }
167
167
 
168
168
  function structuralHash(model: Model): string {
169
- return model.fields
169
+ const fieldHash = model.fields
170
170
  .map((f) => `${f.name}:${JSON.stringify(f.type)}:${f.required}`)
171
171
  .sort()
172
172
  .join('|');
173
+ // Include entity domain for CRUD-prefixed models to prevent cross-domain
174
+ // aliasing (e.g. UpdateGroup vs UpdateAuthorizationPermission have identical
175
+ // fields but belong to different API domains and should stay separate types).
176
+ const domain = crudEntityDomain(model.name);
177
+ return domain ? `${domain}::${fieldHash}` : fieldHash;
178
+ }
179
+
180
+ const CRUD_PREFIXES = ['Create', 'Update', 'Delete', 'Get', 'List'];
181
+
182
+ /** Strip CRUD verb prefix to get the entity name, or null if no prefix matches. */
183
+ function crudEntityDomain(name: string): string | null {
184
+ for (const prefix of CRUD_PREFIXES) {
185
+ if (name.startsWith(prefix) && name.length > prefix.length) {
186
+ return name.slice(prefix.length);
187
+ }
188
+ }
189
+ return null;
173
190
  }
174
191
 
175
192
  /** Known acronyms to preserve as single tokens during humanization. */
package/src/go/tests.ts CHANGED
@@ -307,7 +307,10 @@ function generateServiceTest(
307
307
  // Success test
308
308
  const respModel = plan.responseModelName;
309
309
  const isArrayResponse = !isPaginated && op.response?.kind === 'array';
310
- const fixturePath = `testdata/${fileName(respModel)}.json`;
310
+ let fixturePath = `testdata/${fileName(respModel)}.json`;
311
+ if (fixtureRewrites.has(fixturePath)) {
312
+ fixturePath = fixtureRewrites.get(fixturePath)!;
313
+ }
311
314
  const expectedPath = buildExpectedPath(op);
312
315
 
313
316
  const httpMethodUpper = op.httpMethod.toUpperCase();
@@ -411,7 +414,10 @@ function generateServiceTest(
411
414
  const wrapperParamsStruct = paramsStructName(resolvedName, wrapperMethod);
412
415
  const responseType = wrapper.responseModelName;
413
416
  const testName = `Test${accessorName}_${wrapperMethod}`;
414
- const fixturePath = responseType ? `testdata/${fileName(responseType)}.json` : null;
417
+ let fixturePath = responseType ? `testdata/${fileName(responseType)}.json` : null;
418
+ if (fixturePath && fixtureRewrites.has(fixturePath)) {
419
+ fixturePath = fixtureRewrites.get(fixturePath)!;
420
+ }
415
421
 
416
422
  const wrapperCallArgs: string[] = ['context.Background()'];
417
423
  for (const p of sortPathParamsByTemplateOrder(op)) {
package/src/ruby/index.ts CHANGED
@@ -30,7 +30,8 @@ export const rubyEmitter: Emitter = {
30
30
  language: 'ruby',
31
31
 
32
32
  generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
33
- return ensureTrailingNewlines(generateModels(models, ctx));
33
+ const modelFiles = generateModels(models, ctx);
34
+ return ensureTrailingNewlines(modelFiles);
34
35
  },
35
36
 
36
37
  generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
@@ -54,7 +55,8 @@ export const rubyEmitter: Emitter = {
54
55
  },
55
56
 
56
57
  generateTypeSignatures(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
57
- return ensureTrailingNewlines(generateRbiFiles(spec, ctx));
58
+ const rbiFiles = generateRbiFiles(spec, ctx);
59
+ return ensureTrailingNewlines(rbiFiles);
58
60
  },
59
61
 
60
62
  generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
@@ -117,6 +117,29 @@ export function moduleName(name: string): string {
117
117
  return toSnakeCase(name);
118
118
  }
119
119
 
120
+ /**
121
+ * PascalCase class name for a parameter-group variant. Mirrors the Python
122
+ * convention: group "password" + variant "plaintext" → `PasswordPlaintext`.
123
+ * Used as the Ruby constant under the WorkOS module.
124
+ */
125
+ export function groupVariantClassName(groupName: string, variantName: string): string {
126
+ return className(`${groupName}_${variantName}`);
127
+ }
128
+
129
+ /** snake_case Zeitwerk-compatible filename for a parameter-group variant. */
130
+ export function groupVariantFileName(groupName: string, variantName: string): string {
131
+ return fileName(`${groupName}_${variantName}`);
132
+ }
133
+
134
+ /**
135
+ * Fully-qualified Ruby constant for a parameter-group variant scoped under
136
+ * its owning resource module — e.g. "WorkOS::UserManagement::PasswordPlaintext".
137
+ * Mirrors Python's `workos.user_management.PasswordPlaintext` namespacing.
138
+ */
139
+ export function scopedGroupVariantClassName(mountTarget: string, groupName: string, variantName: string): string {
140
+ return `WorkOS::${className(mountTarget)}::${groupVariantClassName(groupName, variantName)}`;
141
+ }
142
+
120
143
  /** snake_case property name for service accessors on the client. */
121
144
  export function servicePropertyName(name: string): string {
122
145
  return toSnakeCase(name);
@@ -0,0 +1,221 @@
1
+ import type { EmitterContext, TypeRef, Model } from '@workos/oagen';
2
+ import { className, fieldName, groupVariantClassName } from './naming.js';
3
+ import { mapTypeRefForYard } from './type-map.js';
4
+ import { collectBodyFieldTypes, groupByMount } from '../shared/resolved-ops.js';
5
+
6
+ /**
7
+ * Sorbet type string for a TypeRef. Mirrors `mapSorbetType` in rbi.ts but
8
+ * lives here so the parameter-groups module is self-contained.
9
+ */
10
+ function mapSorbetType(ref: TypeRef): string {
11
+ switch (ref.kind) {
12
+ case 'primitive':
13
+ switch (ref.type) {
14
+ case 'string':
15
+ return 'String';
16
+ case 'integer':
17
+ return 'Integer';
18
+ case 'number':
19
+ return 'Float';
20
+ case 'boolean':
21
+ return 'T::Boolean';
22
+ case 'unknown':
23
+ return 'T.untyped';
24
+ }
25
+ break;
26
+ case 'array':
27
+ return `T::Array[${mapSorbetType(ref.items)}]`;
28
+ case 'model':
29
+ return `WorkOS::${className(ref.name)}`;
30
+ case 'enum':
31
+ return 'String';
32
+ case 'nullable':
33
+ return `T.nilable(${mapSorbetType(ref.inner)})`;
34
+ case 'literal':
35
+ if (typeof ref.value === 'string') return 'String';
36
+ if (ref.value === null) return 'NilClass';
37
+ if (typeof ref.value === 'number') return Number.isInteger(ref.value) ? 'Integer' : 'Float';
38
+ return 'T::Boolean';
39
+ case 'union': {
40
+ const variants = ref.variants.map((v) => mapSorbetType(v));
41
+ const unique = [...new Set(variants)];
42
+ if (unique.length === 1) return unique[0];
43
+ return `T.any(${unique.join(', ')})`;
44
+ }
45
+ case 'map':
46
+ return `T::Hash[String, ${mapSorbetType(ref.valueType)}]`;
47
+ }
48
+ return 'T.untyped';
49
+ }
50
+
51
+ export interface CollectedVariant {
52
+ className: string;
53
+ groupName: string;
54
+ variantName: string;
55
+ /** PascalCase mount target this variant is scoped under (e.g. "UserManagement"). */
56
+ mountTarget: string;
57
+ parameters: { name: string; type: TypeRef }[];
58
+ }
59
+
60
+ /**
61
+ * Build a stable groupName -> mountTarget map. Each parameter group is owned
62
+ * by exactly one resource module — Ruby variant classes are inlined into
63
+ * `WorkOS::<MountTarget>::<Variant>` (matching Python's per-resource layout),
64
+ * so a dispatcher in another resource that references the same group still
65
+ * resolves to a single canonical class.
66
+ *
67
+ * Mount targets are visited in alphabetical order so first-wins is
68
+ * deterministic across runs. In the current spec no group is shared across
69
+ * mount targets; if one ever is, the alphabetically-first owner gets the
70
+ * class and other dispatchers reference it by full path.
71
+ */
72
+ export function buildGroupOwnerMap(ctx: EmitterContext): Map<string, string> {
73
+ const owner = new Map<string, string>();
74
+ const groups = groupByMount(ctx);
75
+ const sortedTargets = [...groups.keys()].sort();
76
+ for (const target of sortedTargets) {
77
+ const g = groups.get(target);
78
+ if (!g) continue;
79
+ for (const op of g.operations) {
80
+ for (const grp of op.parameterGroups ?? []) {
81
+ if (!owner.has(grp.name)) owner.set(grp.name, target);
82
+ }
83
+ }
84
+ }
85
+ return owner;
86
+ }
87
+
88
+ /**
89
+ * Collect all variant classes a given mount target owns. Variants are
90
+ * inlined into the resource file (and its RBI counterpart) — Zeitwerk's
91
+ * collapse convention means subdirectories under `lib/workos/<service>/`
92
+ * don't add a namespace level, so files there can't define
93
+ * `WorkOS::<Service>::<Variant>`. Inline definitions sidestep that.
94
+ *
95
+ * Variant parameter types are taken from the IR's leaf type. When the IR's
96
+ * leaf is a bare primitive but the request body model has a richer type
97
+ * (array/enum/model/map), we fall back to the body type to recover fidelity
98
+ * the IR drops. Body nullability is stripped — when a parameter group is
99
+ * optional, the body field for the group becomes nullable, but within a
100
+ * variant the leaf is always required (selecting the variant means passing it).
101
+ */
102
+ export function collectVariantsForMountTarget(
103
+ ctx: EmitterContext,
104
+ models: Model[],
105
+ mountTarget: string,
106
+ ): CollectedVariant[] {
107
+ const owner = buildGroupOwnerMap(ctx);
108
+ const seen = new Set<string>();
109
+ const out: CollectedVariant[] = [];
110
+ const groups = groupByMount(ctx);
111
+ const g = groups.get(mountTarget);
112
+ if (!g) return out;
113
+ for (const op of g.operations) {
114
+ const bodyFieldTypes = collectBodyFieldTypes(op, models);
115
+ for (const group of op.parameterGroups ?? []) {
116
+ if (owner.get(group.name) !== mountTarget) continue;
117
+ for (const variant of group.variants) {
118
+ const cls = groupVariantClassName(group.name, variant.name);
119
+ if (seen.has(cls)) continue;
120
+ seen.add(cls);
121
+ out.push({
122
+ className: cls,
123
+ groupName: group.name,
124
+ variantName: variant.name,
125
+ mountTarget,
126
+ parameters: variant.parameters.map((p) => ({
127
+ name: p.name,
128
+ type: pickVariantParamType(p.type, bodyFieldTypes.get(p.name)),
129
+ })),
130
+ });
131
+ }
132
+ }
133
+ }
134
+ return out;
135
+ }
136
+
137
+ /**
138
+ * Pick the type for a variant leaf parameter.
139
+ *
140
+ * Prefer the IR's leaf type. Use the body model's type only when the IR is a
141
+ * bare primitive but the body has a structured type — that's the original
142
+ * fidelity-recovery case the body fallback was added for. Strip any outer
143
+ * nullable from the body type, since body nullability reflects the parent
144
+ * group's optionality, not the leaf's required-ness within the variant.
145
+ *
146
+ * Exported so the test emitter can recover the same type the variant class
147
+ * declares — IR primitives for fields like `role_slugs` would otherwise stub
148
+ * as `"stub"` strings instead of the `["stub"]` arrays the class accepts.
149
+ */
150
+ export function pickVariantParamType(irType: TypeRef, bodyType: TypeRef | undefined): TypeRef {
151
+ if (!bodyType) return irType;
152
+ const unwrappedBody = bodyType.kind === 'nullable' ? bodyType.inner : bodyType;
153
+ const bodyIsStructured =
154
+ unwrappedBody.kind === 'array' ||
155
+ unwrappedBody.kind === 'enum' ||
156
+ unwrappedBody.kind === 'model' ||
157
+ unwrappedBody.kind === 'map';
158
+ if (irType.kind === 'primitive' && bodyIsStructured) return unwrappedBody;
159
+ return irType;
160
+ }
161
+
162
+ function readableName(name: string): string {
163
+ return name.replace(/_/g, ' ');
164
+ }
165
+
166
+ /**
167
+ * Render the inline `Data.define` block for a single variant, indented for
168
+ * inclusion inside a `class <Service>` body. Returns an array of lines with
169
+ * 4-space indent (the resource file's class members are 4-space indented).
170
+ */
171
+ export function emitInlineVariantClass(v: CollectedVariant): string[] {
172
+ const lines: string[] = [];
173
+ lines.push(` # Identifies the ${readableName(v.groupName)} (${readableName(v.variantName)} variant).`);
174
+ lines.push(' #');
175
+ for (const p of v.parameters) {
176
+ const yardType = mapTypeRefForYard(p.type);
177
+ lines.push(` # @!attribute [r] ${fieldName(p.name)}`);
178
+ lines.push(` # @return [${yardType}]`);
179
+ }
180
+ if (v.parameters.length === 0) {
181
+ lines.push(` ${v.className} = Data.define`);
182
+ } else {
183
+ const fields = v.parameters.map((p) => `:${fieldName(p.name)}`).join(', ');
184
+ lines.push(` ${v.className} = Data.define(${fields})`);
185
+ }
186
+ return lines;
187
+ }
188
+
189
+ /**
190
+ * Render the inline RBI `class` block for a single variant, indented for
191
+ * inclusion inside a `class <Service>` body in a service .rbi file. Returns
192
+ * lines with 4-space indent.
193
+ */
194
+ export function emitInlineVariantRbi(v: CollectedVariant): string[] {
195
+ const lines: string[] = [];
196
+ const fqcn = `WorkOS::${v.mountTarget}::${v.className}`;
197
+ lines.push(` class ${v.className}`);
198
+ for (const p of v.parameters) {
199
+ lines.push(` sig { returns(${mapSorbetType(p.type)}) }`);
200
+ lines.push(` def ${fieldName(p.name)}; end`);
201
+ lines.push('');
202
+ }
203
+ if (v.parameters.length === 0) {
204
+ lines.push(` sig { returns(${fqcn}) }`);
205
+ lines.push(` def self.new; end`);
206
+ } else {
207
+ lines.push(' sig do');
208
+ lines.push(' params(');
209
+ for (let i = 0; i < v.parameters.length; i++) {
210
+ const p = v.parameters[i];
211
+ const sep = i === v.parameters.length - 1 ? '' : ',';
212
+ lines.push(` ${fieldName(p.name)}: ${mapSorbetType(p.type)}${sep}`);
213
+ }
214
+ lines.push(` ).returns(${fqcn})`);
215
+ lines.push(' end');
216
+ const kwargs = v.parameters.map((p) => `${fieldName(p.name)}:`).join(', ');
217
+ lines.push(` def self.new(${kwargs}); end`);
218
+ }
219
+ lines.push(' end');
220
+ return lines;
221
+ }
package/src/ruby/rbi.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  import type { ApiSpec, EmitterContext, GeneratedFile, TypeRef, Model } from '@workos/oagen';
2
2
  import { mapTypeRef as irMapTypeRef } from '@workos/oagen';
3
- import { className, fieldName, fileName, safeParamName, resolveMethodName } from './naming.js';
3
+ import {
4
+ className,
5
+ fieldName,
6
+ fileName,
7
+ safeParamName,
8
+ scopedGroupVariantClassName,
9
+ resolveMethodName,
10
+ } from './naming.js';
4
11
  import {
5
12
  buildResolvedLookup,
6
13
  groupByMount,
@@ -9,6 +16,7 @@ import {
9
16
  collectGroupedParamNames,
10
17
  } from '../shared/resolved-ops.js';
11
18
  import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
19
+ import { buildGroupOwnerMap, collectVariantsForMountTarget, emitInlineVariantRbi } from './parameter-groups.js';
12
20
 
13
21
  /**
14
22
  * Map an IR TypeRef to a Sorbet type string for RBI files.
@@ -120,6 +128,7 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
120
128
  for (const m of spec.models as Model[]) {
121
129
  if (isListWrapperModel(m)) listWrapperModels.set(m.name, m);
122
130
  }
131
+ const groupOwners = buildGroupOwnerMap(ctx);
123
132
 
124
133
  for (const [mountTarget, group] of groups) {
125
134
  const cls = className(mountTarget);
@@ -129,6 +138,15 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
129
138
  lines.push('module WorkOS');
130
139
  lines.push(` class ${cls}`);
131
140
 
141
+ // Inline parameter-group variant RBI blocks owned by this mount target.
142
+ // Mirrors the runtime `class ... PasswordPlaintext = Data.define(...) end`
143
+ // layout in lib/workos/<service>.rb.
144
+ const variants = collectVariantsForMountTarget(ctx, spec.models as Model[], mountTarget);
145
+ for (const v of variants) {
146
+ for (const line of emitInlineVariantRbi(v)) lines.push(line);
147
+ lines.push('');
148
+ }
149
+
132
150
  lines.push(' sig { params(client: WorkOS::BaseClient).void }');
133
151
  lines.push(' def initialize(client); end');
134
152
  lines.push('');
@@ -153,9 +171,26 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
153
171
  const hiddenParams = buildHiddenParams(resolved);
154
172
  const groupedParamNames = collectGroupedParamNames(op);
155
173
  const queryParams = (op.queryParams ?? []).filter((q) => !groupedParamNames.has(q.name));
156
- const bodyFields = getRequestBodyFieldsFlat(op, hiddenParams, modelByName);
174
+ // Drop body fields that collide with a parameter-group name; the group
175
+ // dispatcher kwarg handles them. See ruby/resources.ts for the matching
176
+ // filter on the runtime side.
177
+ const bodyFields = getRequestBodyFieldsFlat(op, hiddenParams, modelByName).filter(
178
+ (f) => !groupedParamNames.has(f.name),
179
+ );
180
+ const parameterGroups = op.parameterGroups ?? [];
181
+ const groupSorbetType = (group: (typeof parameterGroups)[number]): string => {
182
+ const owner = groupOwners.get(group.name);
183
+ if (!owner) {
184
+ throw new Error(`No owner mount target found for parameter group '${group.name}'`);
185
+ }
186
+ const variants = group.variants.map((v) => scopedGroupVariantClassName(owner, group.name, v.name));
187
+ if (variants.length === 1) return variants[0];
188
+ return `T.any(${variants.join(', ')})`;
189
+ };
157
190
 
158
- // Build parameter list for sig
191
+ // Build parameter list for sig. Order mirrors the runtime emitter:
192
+ // path → required body → required query → required groups → optional
193
+ // body → optional query → optional groups → request_options.
159
194
  const sigParams: string[] = [];
160
195
  const seen = new Set<string>();
161
196
 
@@ -181,6 +216,13 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
181
216
  seen.add(n);
182
217
  sigParams.push(`${n}: ${mapSorbetType(q.type)}`);
183
218
  }
219
+ for (const group of parameterGroups) {
220
+ if (group.optional) continue;
221
+ const n = fieldName(group.name);
222
+ if (seen.has(n)) continue;
223
+ seen.add(n);
224
+ sigParams.push(`${n}: ${groupSorbetType(group)}`);
225
+ }
184
226
  for (const f of bodyFields) {
185
227
  if (hiddenParams.has(f.name)) continue;
186
228
  if (f.required) continue;
@@ -197,6 +239,13 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
197
239
  seen.add(n);
198
240
  sigParams.push(`${n}: T.nilable(${unwrapNilable(mapSorbetType(q.type))})`);
199
241
  }
242
+ for (const group of parameterGroups) {
243
+ if (!group.optional) continue;
244
+ const n = fieldName(group.name);
245
+ if (seen.has(n)) continue;
246
+ seen.add(n);
247
+ sigParams.push(`${n}: T.nilable(${groupSorbetType(group)})`);
248
+ }
200
249
  sigParams.push('request_options: T::Hash[Symbol, T.untyped]');
201
250
 
202
251
  // Return type