@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.
@@ -1,20 +1,26 @@
1
1
  // @oagen-ignore: Operation.async — all TypeScript SDK methods are async by nature
2
2
 
3
3
  import type { Service, Operation, EmitterContext, GeneratedFile, TypeRef, Model } from '@workos/oagen';
4
- import { planOperation, toPascalCase } from '@workos/oagen';
4
+ import { planOperation, toPascalCase, toCamelCase } from '@workos/oagen';
5
5
  import type { OperationPlan } from '@workos/oagen';
6
6
  import { mapTypeRef } from './type-map.js';
7
7
  import {
8
8
  fieldName,
9
9
  wireFieldName,
10
10
  fileName,
11
- serviceDirName,
11
+ resolveServiceDir,
12
12
  resolveMethodName,
13
13
  resolveInterfaceName,
14
14
  resolveServiceName,
15
15
  wireInterfaceName,
16
16
  } from './naming.js';
17
- import { docComment, createServiceDirResolver, isServiceCoveredByExisting, uncoveredOperations } from './utils.js';
17
+ import {
18
+ docComment,
19
+ createServiceDirResolver,
20
+ isServiceCoveredByExisting,
21
+ hasMethodsAbsentFromBaseline,
22
+ uncoveredOperations,
23
+ } from './utils.js';
18
24
  import { assignEnumsToServices } from './enums.js';
19
25
  import { unwrapListModel } from './fixtures.js';
20
26
 
@@ -85,27 +91,334 @@ function httpMethodNeedsBody(method: string): boolean {
85
91
  return method === 'post' || method === 'put' || method === 'patch';
86
92
  }
87
93
 
94
+ // ---------------------------------------------------------------------------
95
+ // Method-name reconciliation helpers
96
+ // ---------------------------------------------------------------------------
97
+
98
+ /** Map HTTP methods to expected CRUD verb prefixes for method name matching. */
99
+ const HTTP_VERB_PREFIXES: Record<string, string[]> = {
100
+ get: ['list', 'get', 'fetch', 'retrieve', 'find'],
101
+ post: ['create', 'add', 'insert', 'send'],
102
+ put: ['set', 'update', 'replace', 'put'],
103
+ patch: ['update', 'patch', 'modify'],
104
+ delete: ['delete', 'remove', 'revoke'],
105
+ };
106
+
107
+ /** Split a camelCase/PascalCase name into lowercase word parts. */
108
+ function splitCamelWords(name: string): string[] {
109
+ const parts: string[] = [];
110
+ let start = 0;
111
+ for (let i = 1; i < name.length; i++) {
112
+ if (name[i] >= 'A' && name[i] <= 'Z') {
113
+ parts.push(name.slice(start, i).toLowerCase());
114
+ start = i;
115
+ }
116
+ }
117
+ parts.push(name.slice(start).toLowerCase());
118
+ return parts;
119
+ }
120
+
121
+ /** Naive singularize: strip trailing 's' unless it ends in 'ss'. */
122
+ function singularize(word: string): string {
123
+ return word.endsWith('s') && !word.endsWith('ss') ? word.slice(0, -1) : word;
124
+ }
125
+
126
+ /**
127
+ * Batch-reconcile generated method names against the api-surface class methods.
128
+ *
129
+ * When the overlay doesn't map an operation (missing from previous manifest or
130
+ * fuzzy-matching failed), the emitter falls back to the spec-derived name which
131
+ * often doesn't match the hand-written SDK. This function uses three passes
132
+ * with progressive exclusion to find the best surface match:
133
+ *
134
+ * 1. **Overlay-resolved** — already correct, mark as taken.
135
+ * 2. **Word-set match** — same content words regardless of order (handles
136
+ * `listRolesOrganizations` ↔ `listOrganizationRoles`).
137
+ * 3. **Path-context match** — all surface-method content words appear in the
138
+ * operation's URL path segments (handles `findById` ↔ `getResource`).
139
+ *
140
+ * After each pass, matched surface methods are removed from the pool so that
141
+ * ambiguous cases (e.g., `listEnvironmentRoles` vs `listOrganizationRoles`)
142
+ * resolve by elimination.
143
+ */
144
+ function reconcileMethodNames(
145
+ plans: Array<{ op: Operation; plan: OperationPlan; method: string }>,
146
+ service: Service,
147
+ ctx: EmitterContext,
148
+ ): void {
149
+ const className = resolveResourceClassName(service, ctx);
150
+ const classMethods = ctx.apiSurface?.classes?.[className]?.methods;
151
+ if (!classMethods) return;
152
+
153
+ const available = new Set(Object.keys(classMethods));
154
+ const resolved = new Map<(typeof plans)[number], string>();
155
+
156
+ // Exclude surface methods that are overlay-mapped by OTHER services'
157
+ // operations. This prevents the reconciliation from stealing methods
158
+ // that belong to a different service (e.g., `createPermission` belongs
159
+ // to the Permissions service, not the Authorization service).
160
+ const thisServicePaths = new Set(service.operations.map((op) => op.path));
161
+ if (ctx.overlayLookup?.methodByOperation) {
162
+ for (const [httpKey, info] of ctx.overlayLookup.methodByOperation) {
163
+ if (info.className !== className) continue; // different class
164
+ const path = httpKey.split(' ')[1];
165
+ if (thisServicePaths.has(path)) continue; // same service
166
+ // This method is mapped to an operation in a different service
167
+ available.delete(info.methodName);
168
+ }
169
+ }
170
+
171
+ // Determine which plans are already overlay-resolved
172
+ const overlayResolved = new Set<(typeof plans)[number]>();
173
+ for (const plan of plans) {
174
+ const httpKey = `${plan.op.httpMethod.toUpperCase()} ${plan.op.path}`;
175
+ if (ctx.overlayLookup?.methodByOperation?.get(httpKey)) {
176
+ overlayResolved.add(plan);
177
+ if (available.has(plan.method)) {
178
+ resolved.set(plan, plan.method);
179
+ available.delete(plan.method);
180
+ }
181
+ }
182
+ }
183
+
184
+ // Helper: check verb compatibility.
185
+ // When specVerb is provided, require the surface method to use the same verb
186
+ // subgroup (e.g., "list" operations only match "list" methods, not "get").
187
+ const verbMatches = (methodName: string, httpMethod: string, specVerb?: string): boolean => {
188
+ const prefixes = HTTP_VERB_PREFIXES[httpMethod] ?? [];
189
+ const lower = methodName.toLowerCase();
190
+ if (!prefixes.some((p) => lower.startsWith(p))) return false;
191
+ if (specVerb) {
192
+ const surfaceVerb = splitCamelWords(methodName)[0];
193
+ // "list" is a distinct verb subgroup from "get/find/fetch/retrieve"
194
+ if (specVerb === 'list' && surfaceVerb !== 'list') return false;
195
+ if (specVerb !== 'list' && surfaceVerb === 'list') return false;
196
+ }
197
+ return true;
198
+ };
199
+
200
+ // Pass 1: Word-set matching (handles word-order differences)
201
+ for (const plan of plans) {
202
+ if (resolved.has(plan)) continue;
203
+ const specVerb = splitCamelWords(plan.method)[0];
204
+ const specWords = splitCamelWords(plan.method).slice(1).map(singularize); // skip verb
205
+ const specSet = new Set(specWords);
206
+ if (specSet.size === 0) continue;
207
+
208
+ let match: string | null = null;
209
+ for (const name of available) {
210
+ if (!verbMatches(name, plan.op.httpMethod, specVerb)) continue;
211
+ const methodWords = splitCamelWords(name).slice(1).map(singularize);
212
+ if (methodWords.length !== specWords.length) continue;
213
+ const methodSet = new Set(methodWords);
214
+ if (specSet.size === methodSet.size && [...specSet].every((w) => methodSet.has(w))) {
215
+ if (match !== null) {
216
+ match = null; // ambiguous — more than one match
217
+ break;
218
+ }
219
+ match = name;
220
+ }
221
+ }
222
+ if (match) {
223
+ resolved.set(plan, match);
224
+ available.delete(match);
225
+ }
226
+ }
227
+
228
+ // Pass 2: Path-context matching (handles different naming e.g., findById → getResource)
229
+ // To avoid false matches (e.g., `createPermission` matching a role-permission path
230
+ // just because "permission" appears somewhere in the URL), require that the method's
231
+ // content words reference the LEAF resource of the path, not just any segment.
232
+ for (const plan of plans) {
233
+ if (resolved.has(plan)) continue;
234
+ const specVerb = splitCamelWords(plan.method)[0];
235
+ const pathSegments = plan.op.path.split('/').filter((s) => s && !s.startsWith('{'));
236
+ const pathWords = new Set(pathSegments.flatMap((s) => s.split('_')).map(singularize));
237
+ // The "leaf" words come from the last non-param segment — the most specific resource.
238
+ let bestMatch: string | null = null;
239
+ let bestLen = 0;
240
+ let ambiguous = false;
241
+ for (const name of available) {
242
+ if (!verbMatches(name, plan.op.httpMethod, specVerb)) continue;
243
+ const methodWords = splitCamelWords(name).slice(1).map(singularize);
244
+ if (methodWords.length === 0) continue;
245
+ // All method content words must appear in path context
246
+ if (!methodWords.every((w) => pathWords.has(w))) continue;
247
+ // For paths with 3+ non-param segments (nested sub-resources like
248
+ // /authorization/roles/{slug}/permissions), require at least 2 content
249
+ // words. A single-word method like `createPermission` should only match
250
+ // a top-level path like /authorization/permissions, not a nested one.
251
+ if (methodWords.length < 2 && pathSegments.length > 2) continue;
252
+ if (methodWords.length > bestLen) {
253
+ bestMatch = name;
254
+ bestLen = methodWords.length;
255
+ ambiguous = false;
256
+ } else if (methodWords.length === bestLen) {
257
+ ambiguous = true;
258
+ }
259
+ }
260
+ if (bestMatch && !ambiguous) {
261
+ resolved.set(plan, bestMatch);
262
+ available.delete(bestMatch);
263
+ }
264
+ }
265
+
266
+ // Pass 3: Retry word-set and path-context for still-unresolved plans
267
+ // (earlier passes may have eliminated ambiguous candidates)
268
+ for (const plan of plans) {
269
+ if (resolved.has(plan)) continue;
270
+ const specVerb = splitCamelWords(plan.method)[0];
271
+
272
+ // Retry word-set
273
+ const specWords = splitCamelWords(plan.method).slice(1).map(singularize);
274
+ const specSet = new Set(specWords);
275
+ let match: string | null = null;
276
+ for (const name of available) {
277
+ if (!verbMatches(name, plan.op.httpMethod, specVerb)) continue;
278
+ const methodWords = splitCamelWords(name).slice(1).map(singularize);
279
+ if (methodWords.length !== specWords.length) continue;
280
+ const methodSet = new Set(methodWords);
281
+ if (specSet.size === methodSet.size && [...specSet].every((w) => methodSet.has(w))) {
282
+ if (match !== null) {
283
+ match = null;
284
+ break;
285
+ }
286
+ match = name;
287
+ }
288
+ }
289
+ if (match) {
290
+ resolved.set(plan, match);
291
+ available.delete(match);
292
+ continue;
293
+ }
294
+
295
+ // Retry path-context (with leaf-word check)
296
+ const pathSegments = plan.op.path.split('/').filter((s) => s && !s.startsWith('{'));
297
+ const pathWords = new Set(pathSegments.flatMap((s) => s.split('_')).map(singularize));
298
+ let bestMatch: string | null = null;
299
+ let bestLen = 0;
300
+ let ambiguous = false;
301
+ for (const name of available) {
302
+ if (!verbMatches(name, plan.op.httpMethod, specVerb)) continue;
303
+ const methodWords = splitCamelWords(name).slice(1).map(singularize);
304
+ if (methodWords.length === 0) continue;
305
+ if (!methodWords.every((w) => pathWords.has(w))) continue;
306
+ if (methodWords.length < 2 && pathSegments.length > 2) continue;
307
+ if (methodWords.length > bestLen) {
308
+ bestMatch = name;
309
+ bestLen = methodWords.length;
310
+ ambiguous = false;
311
+ } else if (methodWords.length === bestLen) {
312
+ ambiguous = true;
313
+ }
314
+ }
315
+ if (bestMatch && !ambiguous) {
316
+ resolved.set(plan, bestMatch);
317
+ available.delete(bestMatch);
318
+ }
319
+ }
320
+
321
+ // Apply resolved names (only for non-overlay plans)
322
+ for (const plan of plans) {
323
+ if (overlayResolved.has(plan)) continue;
324
+ const name = resolved.get(plan);
325
+ if (name) plan.method = name;
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Deduplicate method names within the plans array.
331
+ *
332
+ * When `disambiguateOperationNames()` in `@workos/oagen` fails (e.g., for
333
+ * single-segment paths like `/organizations`), two operations can resolve to
334
+ * the same method name. Disambiguate by appending a path-derived suffix.
335
+ */
336
+ function deduplicateMethodNames(
337
+ plans: Array<{ op: Operation; plan: OperationPlan; method: string }>,
338
+ _ctx: EmitterContext,
339
+ ): void {
340
+ const nameCount = new Map<string, number>();
341
+ for (const p of plans) {
342
+ nameCount.set(p.method, (nameCount.get(p.method) ?? 0) + 1);
343
+ }
344
+
345
+ for (const [name, count] of nameCount) {
346
+ if (count <= 1) continue;
347
+ const dupes = plans.filter((p) => p.method === name);
348
+
349
+ // If all duplicates are on the SAME base path (different HTTP methods),
350
+ // trust the names — they represent the same resource.
351
+ const basePaths = new Set(dupes.map((d) => d.op.path.replace(/\/\{[^}]+\}$/, '')));
352
+ if (basePaths.size <= 1) continue;
353
+
354
+ // Disambiguate: keep the name for the plan whose path best matches,
355
+ // append path suffix for the others.
356
+ // Score: how many words in the method name appear in the path segments
357
+ const nameWords = new Set(splitCamelWords(name).map(singularize));
358
+ const scored = dupes.map((d) => {
359
+ const pathWords = d.op.path
360
+ .split('/')
361
+ .filter((s) => s && !s.startsWith('{'))
362
+ .flatMap((s) => s.split('_'))
363
+ .map(singularize);
364
+ const overlap = pathWords.filter((w) => nameWords.has(w)).length;
365
+ return { plan: d, score: overlap };
366
+ });
367
+ scored.sort((a, b) => b.score - a.score);
368
+
369
+ // The best-scoring plan keeps the name; others get disambiguated
370
+ for (let i = 1; i < scored.length; i++) {
371
+ const dupe = scored[i].plan;
372
+ const segments = dupe.op.path.split('/').filter((s) => s && !s.startsWith('{'));
373
+ // Use first segment as suffix (the resource collection name)
374
+ const suffix = segments[0] ?? '';
375
+ if (suffix) {
376
+ dupe.method = toCamelCase(`${name}_${suffix}`);
377
+ }
378
+ }
379
+
380
+ // If still colliding after suffix, append index
381
+ const stillDuped = new Map<string, typeof dupes>();
382
+ for (const dupe of dupes) {
383
+ const group = stillDuped.get(dupe.method) ?? [];
384
+ group.push(dupe);
385
+ stillDuped.set(dupe.method, group);
386
+ }
387
+ for (const [, group] of stillDuped) {
388
+ if (group.length <= 1) continue;
389
+ for (let i = 1; i < group.length; i++) {
390
+ group[i].method = `${group[i].method}${i + 1}`;
391
+ }
392
+ }
393
+ }
394
+ }
395
+
88
396
  export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
89
397
  if (services.length === 0) return [];
90
398
  const files: GeneratedFile[] = [];
91
399
 
92
400
  for (const service of services) {
93
401
  if (isServiceCoveredByExisting(service, ctx)) {
94
- // Fully covered skip entirely
402
+ // Fully covered: generate with ALL operations so the merger's docstring
403
+ // refresh pass can update JSDoc on existing methods.
404
+ const file = generateResourceClass(service, ctx);
405
+ // When the baseline class is missing methods for some operations,
406
+ // remove skipIfExists so the merger adds the new methods.
407
+ if (hasMethodsAbsentFromBaseline(service, ctx)) {
408
+ delete file.skipIfExists;
409
+ }
410
+ files.push(file);
95
411
  continue;
96
412
  }
97
413
 
98
- // Check for partial coverage: some operations covered, some not.
99
- // Generate methods only for uncovered operations.
100
414
  const ops = uncoveredOperations(service, ctx);
101
415
  if (ops.length === 0) continue;
102
416
 
103
417
  if (ops.length < service.operations.length) {
104
- // Partial coverage: create a service with only uncovered operations.
105
- // Remove skipIfExists so the merger can add these new methods to the
106
- // existing class file (otherwise uncovered operations are silently lost).
107
- const partialService = { ...service, operations: ops };
108
- const file = generateResourceClass(partialService, ctx);
418
+ // Partial coverage: generate with ALL operations so JSDoc is available
419
+ // for both covered and uncovered methods. Remove skipIfExists so the
420
+ // merger adds new methods AND refreshes existing JSDoc.
421
+ const file = generateResourceClass(service, ctx);
109
422
  delete file.skipIfExists;
110
423
  files.push(file);
111
424
  } else {
@@ -118,7 +431,7 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
118
431
 
119
432
  function generateResourceClass(service: Service, ctx: EmitterContext): GeneratedFile {
120
433
  const resolvedName = resolveResourceClassName(service, ctx);
121
- const serviceDir = serviceDirName(resolvedName);
434
+ const serviceDir = resolveServiceDir(resolvedName);
122
435
  const serviceClass = resolvedName;
123
436
  const resourcePath = `src/${serviceDir}/${fileName(resolvedName)}.ts`;
124
437
 
@@ -128,6 +441,15 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
128
441
  method: resolveMethodName(op, service, ctx),
129
442
  }));
130
443
 
444
+ // Reconcile method names against the api-surface class methods.
445
+ // This fixes cases where the overlay is missing mappings and the
446
+ // spec-derived name doesn't match the hand-written SDK name.
447
+ reconcileMethodNames(plans, service, ctx);
448
+
449
+ // Deduplicate method names within the class (e.g., two operations both
450
+ // resolving to "create" for different paths).
451
+ deduplicateMethodNames(plans, ctx);
452
+
131
453
  // Sort plans to match the existing file's method order.
132
454
  // When the merger integrates generated content with existing files, its
133
455
  // URL-fingerprint fallback (pass 2) matches by position among methods that
@@ -192,11 +514,10 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
192
514
  requestModels.add(name);
193
515
  }
194
516
  } else {
195
- // Non-discriminated union: import variant models as domain types only.
196
- // Without a discriminator we can't statically dispatch serialization,
197
- // so the payload is passed through as-is.
517
+ // Non-discriminated union: import variant models with serializers so we
518
+ // can dispatch to the correct serializer at runtime via field guards.
198
519
  for (const name of bodyInfo.modelNames) {
199
- paramModels.add(name);
520
+ requestModels.add(name);
200
521
  }
201
522
  }
202
523
  }
@@ -649,7 +970,7 @@ function renderDeleteWithBodyMethod(
649
970
  if (bodyInfo.discriminator) {
650
971
  bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
651
972
  } else {
652
- bodyExpr = 'payload';
973
+ bodyExpr = renderNonDiscriminatedUnionBodySerializer(bodyInfo.modelNames, ctx);
653
974
  }
654
975
  } else {
655
976
  requestType = 'Record<string, unknown>';
@@ -688,12 +1009,9 @@ function renderBodyMethod(
688
1009
  } else if (bodyInfo?.kind === 'union') {
689
1010
  requestType = bodyInfo.typeStr;
690
1011
  if (bodyInfo.discriminator) {
691
- // Discriminated union: dispatch to the correct serializer at runtime.
692
1012
  bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
693
1013
  } else {
694
- // Non-discriminated union: cannot statically dispatch —
695
- // pass the payload directly (caller provides the correct shape).
696
- bodyExpr = 'payload';
1014
+ bodyExpr = renderNonDiscriminatedUnionBodySerializer(bodyInfo.modelNames, ctx);
697
1015
  }
698
1016
  } else {
699
1017
  requestType = 'Record<string, unknown>';
@@ -818,7 +1136,7 @@ function renderVoidMethod(
818
1136
  if (bodyInfo.discriminator) {
819
1137
  bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
820
1138
  } else {
821
- bodyExpr = 'payload';
1139
+ bodyExpr = renderNonDiscriminatedUnionBodySerializer(bodyInfo.modelNames, ctx);
822
1140
  }
823
1141
  } else {
824
1142
  bodyParam = 'payload: Record<string, unknown>';
@@ -940,6 +1258,122 @@ function renderUnionBodySerializer(
940
1258
  return `(() => { switch ((payload as any).${prop}) { ${cases.join('; ')}; default: return payload } })()`;
941
1259
  }
942
1260
 
1261
+ /**
1262
+ * Generate an IIFE expression that dispatches to the correct serializer for a
1263
+ * non-discriminated union request body. Inspects model fields to find a
1264
+ * required field unique to each variant and uses `'field' in payload` guards.
1265
+ * Falls back to `payload` only when no variant can be distinguished.
1266
+ */
1267
+ function renderNonDiscriminatedUnionBodySerializer(modelNames: string[], ctx: EmitterContext): string {
1268
+ const modelMap = new Map(ctx.spec.models.map((m) => [m.name, m]));
1269
+
1270
+ // Try to detect an implicit discriminator: a required field present in all
1271
+ // variants whose type is `kind: 'literal'` with a distinct value per variant.
1272
+ // This covers oneOf unions where each variant has e.g. `grant_type: 'authorization_code'`.
1273
+ const implicitDisc = detectImplicitDiscriminator(modelNames, modelMap);
1274
+ if (implicitDisc) {
1275
+ return renderUnionBodySerializer(implicitDisc, ctx);
1276
+ }
1277
+
1278
+ // Collect required field names per model (using camelCase domain names).
1279
+ const requiredFieldsByModel = new Map<string, Set<string>>();
1280
+ for (const name of modelNames) {
1281
+ const model = modelMap.get(name);
1282
+ if (!model) return 'payload';
1283
+ requiredFieldsByModel.set(name, new Set(model.fields.filter((f) => f.required).map((f) => fieldName(f.name))));
1284
+ }
1285
+
1286
+ // For each model, find a required field that no other model has.
1287
+ const guards: Array<{ modelName: string; field: string }> = [];
1288
+ let fallbackModel: string | undefined;
1289
+
1290
+ for (const name of modelNames) {
1291
+ const myFields = requiredFieldsByModel.get(name)!;
1292
+ let uniqueField: string | undefined;
1293
+ for (const field of myFields) {
1294
+ const isUnique = modelNames.every((other) => other === name || !requiredFieldsByModel.get(other)?.has(field));
1295
+ if (isUnique) {
1296
+ uniqueField = field;
1297
+ break;
1298
+ }
1299
+ }
1300
+ if (uniqueField) {
1301
+ guards.push({ modelName: name, field: uniqueField });
1302
+ } else if (!fallbackModel) {
1303
+ fallbackModel = name;
1304
+ } else {
1305
+ // Multiple models with no unique field — can't dispatch
1306
+ return 'payload';
1307
+ }
1308
+ }
1309
+
1310
+ if (guards.length === 0) return 'payload';
1311
+
1312
+ const parts: string[] = [];
1313
+ for (const { modelName, field } of guards) {
1314
+ const resolved = resolveInterfaceName(modelName, ctx);
1315
+ parts.push(`if ('${field}' in payload) return serialize${resolved}(payload as any)`);
1316
+ }
1317
+ if (fallbackModel) {
1318
+ const resolved = resolveInterfaceName(fallbackModel, ctx);
1319
+ parts.push(`return serialize${resolved}(payload as any)`);
1320
+ } else {
1321
+ parts.push('return payload');
1322
+ }
1323
+
1324
+ return `(() => { ${parts.join('; ')} })()`;
1325
+ }
1326
+
1327
+ /**
1328
+ * Detect an implicit discriminator from literal-typed fields.
1329
+ * Returns a discriminator descriptor if all variants share a required field
1330
+ * whose type is `kind: 'literal'` with a distinct value per variant.
1331
+ */
1332
+ function detectImplicitDiscriminator(
1333
+ modelNames: string[],
1334
+ modelMap: Map<string, Model>,
1335
+ ): { property: string; mapping: Record<string, string> } | null {
1336
+ if (modelNames.length < 2) return null;
1337
+
1338
+ const firstModel = modelMap.get(modelNames[0]);
1339
+ if (!firstModel) return null;
1340
+
1341
+ // Candidate fields: required fields with literal type in the first model.
1342
+ const candidates = firstModel.fields.filter((f) => f.required && f.type.kind === 'literal');
1343
+
1344
+ for (const candidate of candidates) {
1345
+ const mapping: Record<string, string> = {};
1346
+ const values = new Set<string | number | boolean | null>();
1347
+ let valid = true;
1348
+
1349
+ for (const name of modelNames) {
1350
+ const model = modelMap.get(name);
1351
+ if (!model) {
1352
+ valid = false;
1353
+ break;
1354
+ }
1355
+ const field = model.fields.find((f) => f.name === candidate.name);
1356
+ if (!field || !field.required || field.type.kind !== 'literal') {
1357
+ valid = false;
1358
+ break;
1359
+ }
1360
+ const val = field.type.value;
1361
+ if (values.has(val)) {
1362
+ valid = false;
1363
+ break;
1364
+ } // duplicate value
1365
+ values.add(val);
1366
+ mapping[String(val)] = name;
1367
+ }
1368
+
1369
+ if (valid && Object.keys(mapping).length === modelNames.length) {
1370
+ return { property: candidate.name, mapping };
1371
+ }
1372
+ }
1373
+
1374
+ return null;
1375
+ }
1376
+
943
1377
  /** Return type for extractRequestBodyType when the body is a union. */
944
1378
  interface UnionBodyInfo {
945
1379
  kind: 'union';
@@ -961,7 +1395,12 @@ function extractRequestBodyType(
961
1395
  }
962
1396
  if (modelNames.length > 0) {
963
1397
  const typeStr = modelNames.map((n) => resolveInterfaceName(n, ctx)).join(' | ');
964
- return { kind: 'union', typeStr, modelNames, discriminator: op.requestBody.discriminator };
1398
+ return {
1399
+ kind: 'union',
1400
+ typeStr,
1401
+ modelNames,
1402
+ discriminator: op.requestBody.discriminator,
1403
+ };
965
1404
  }
966
1405
  }
967
1406
  return null;
@@ -641,7 +641,9 @@ function defaultForType(ref: TypeRef): string | null {
641
641
  function serializerHasBaselineIncompatibility(
642
642
  model: Model,
643
643
  baselineResponse: { fields?: Record<string, { type: string; optional: boolean }> } | undefined,
644
- baselineDomain?: { fields?: Record<string, { type: string; optional: boolean }> },
644
+ baselineDomain?: {
645
+ fields?: Record<string, { type: string; optional: boolean }>;
646
+ },
645
647
  ctx?: EmitterContext,
646
648
  ): boolean {
647
649
  if (!baselineResponse?.fields) return false;