@workos/oagen-emitters 0.3.0 → 0.5.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 (128) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint.yml +1 -1
  3. package/.github/workflows/release-please.yml +2 -2
  4. package/.github/workflows/release.yml +1 -1
  5. package/.husky/pre-push +11 -0
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +15 -0
  9. package/README.md +35 -224
  10. package/dist/index.d.mts +12 -1
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -12737
  13. package/dist/plugin-BSop9f9z.mjs +21471 -0
  14. package/dist/plugin-BSop9f9z.mjs.map +1 -0
  15. package/dist/plugin.d.mts +7 -0
  16. package/dist/plugin.d.mts.map +1 -0
  17. package/dist/plugin.mjs +2 -0
  18. package/docs/sdk-architecture/dotnet.md +336 -0
  19. package/oagen.config.ts +5 -343
  20. package/package.json +10 -34
  21. package/smoke/sdk-dotnet.ts +45 -12
  22. package/src/dotnet/client.ts +89 -0
  23. package/src/dotnet/enums.ts +323 -0
  24. package/src/dotnet/fixtures.ts +236 -0
  25. package/src/dotnet/index.ts +248 -0
  26. package/src/dotnet/manifest.ts +36 -0
  27. package/src/dotnet/models.ts +320 -0
  28. package/src/dotnet/naming.ts +368 -0
  29. package/src/dotnet/resources.ts +943 -0
  30. package/src/dotnet/tests.ts +713 -0
  31. package/src/dotnet/type-map.ts +228 -0
  32. package/src/dotnet/wrappers.ts +197 -0
  33. package/src/go/client.ts +35 -3
  34. package/src/go/enums.ts +4 -0
  35. package/src/go/index.ts +15 -7
  36. package/src/go/models.ts +6 -1
  37. package/src/go/naming.ts +5 -17
  38. package/src/go/resources.ts +534 -73
  39. package/src/go/tests.ts +39 -3
  40. package/src/go/type-map.ts +8 -3
  41. package/src/go/wrappers.ts +79 -21
  42. package/src/index.ts +15 -0
  43. package/src/kotlin/client.ts +58 -0
  44. package/src/kotlin/enums.ts +189 -0
  45. package/src/kotlin/index.ts +92 -0
  46. package/src/kotlin/manifest.ts +55 -0
  47. package/src/kotlin/models.ts +486 -0
  48. package/src/kotlin/naming.ts +229 -0
  49. package/src/kotlin/overrides.ts +25 -0
  50. package/src/kotlin/resources.ts +998 -0
  51. package/src/kotlin/tests.ts +1133 -0
  52. package/src/kotlin/type-map.ts +123 -0
  53. package/src/kotlin/wrappers.ts +168 -0
  54. package/src/node/client.ts +84 -7
  55. package/src/node/field-plan.ts +12 -14
  56. package/src/node/fixtures.ts +39 -3
  57. package/src/node/index.ts +1 -0
  58. package/src/node/models.ts +281 -37
  59. package/src/node/resources.ts +319 -95
  60. package/src/node/tests.ts +108 -29
  61. package/src/node/type-map.ts +1 -31
  62. package/src/node/utils.ts +96 -6
  63. package/src/node/wrappers.ts +31 -1
  64. package/src/php/client.ts +11 -3
  65. package/src/php/models.ts +0 -33
  66. package/src/php/naming.ts +2 -21
  67. package/src/php/resources.ts +275 -19
  68. package/src/php/tests.ts +118 -18
  69. package/src/php/type-map.ts +16 -2
  70. package/src/php/wrappers.ts +7 -2
  71. package/src/plugin.ts +50 -0
  72. package/src/python/client.ts +50 -32
  73. package/src/python/enums.ts +35 -10
  74. package/src/python/index.ts +35 -27
  75. package/src/python/models.ts +139 -2
  76. package/src/python/naming.ts +2 -22
  77. package/src/python/resources.ts +234 -17
  78. package/src/python/tests.ts +260 -16
  79. package/src/python/type-map.ts +16 -2
  80. package/src/ruby/client.ts +238 -0
  81. package/src/ruby/enums.ts +149 -0
  82. package/src/ruby/index.ts +93 -0
  83. package/src/ruby/manifest.ts +35 -0
  84. package/src/ruby/models.ts +360 -0
  85. package/src/ruby/naming.ts +187 -0
  86. package/src/ruby/rbi.ts +313 -0
  87. package/src/ruby/resources.ts +799 -0
  88. package/src/ruby/tests.ts +459 -0
  89. package/src/ruby/type-map.ts +97 -0
  90. package/src/ruby/wrappers.ts +161 -0
  91. package/src/shared/model-utils.ts +357 -16
  92. package/src/shared/naming-utils.ts +83 -0
  93. package/src/shared/non-spec-services.ts +13 -0
  94. package/src/shared/resolved-ops.ts +75 -1
  95. package/src/shared/wrapper-utils.ts +12 -1
  96. package/test/dotnet/client.test.ts +121 -0
  97. package/test/dotnet/enums.test.ts +193 -0
  98. package/test/dotnet/errors.test.ts +9 -0
  99. package/test/dotnet/manifest.test.ts +82 -0
  100. package/test/dotnet/models.test.ts +258 -0
  101. package/test/dotnet/resources.test.ts +387 -0
  102. package/test/dotnet/tests.test.ts +202 -0
  103. package/test/entrypoint.test.ts +89 -0
  104. package/test/go/client.test.ts +6 -6
  105. package/test/go/resources.test.ts +156 -7
  106. package/test/kotlin/models.test.ts +135 -0
  107. package/test/kotlin/resources.test.ts +210 -0
  108. package/test/kotlin/tests.test.ts +176 -0
  109. package/test/node/client.test.ts +74 -0
  110. package/test/node/models.test.ts +134 -1
  111. package/test/node/resources.test.ts +343 -34
  112. package/test/node/utils.test.ts +140 -0
  113. package/test/php/client.test.ts +2 -1
  114. package/test/php/models.test.ts +5 -4
  115. package/test/php/resources.test.ts +103 -0
  116. package/test/php/tests.test.ts +67 -0
  117. package/test/plugin.test.ts +50 -0
  118. package/test/python/client.test.ts +56 -0
  119. package/test/python/models.test.ts +99 -0
  120. package/test/python/resources.test.ts +294 -0
  121. package/test/python/tests.test.ts +91 -0
  122. package/test/ruby/client.test.ts +81 -0
  123. package/test/ruby/resources.test.ts +386 -0
  124. package/test/shared/resolved-ops.test.ts +122 -0
  125. package/tsdown.config.ts +1 -1
  126. package/dist/index.mjs.map +0 -1
  127. package/scripts/generate-php.js +0 -13
  128. package/scripts/git-push-with-published-oagen.sh +0 -21
@@ -0,0 +1,161 @@
1
+ import type { EmitterContext, Operation, ResolvedOperation, ResolvedWrapper } from '@workos/oagen';
2
+ import { className, fieldName, safeParamName } from './naming.js';
3
+ import { resolveWrapperParams, formatWrapperDescription } from '../shared/wrapper-utils.js';
4
+ import { mapTypeRefForYard } from './type-map.js';
5
+
6
+ /**
7
+ * Generate Ruby wrapper method lines for union split operations.
8
+ *
9
+ * Each wrapper is a typed convenience method that:
10
+ * - Accepts only the exposed params (keyword args)
11
+ * - Injects constant defaults (e.g., grant_type)
12
+ * - Reads inferred fields from client config (e.g., client_id)
13
+ * - Delegates to the same HTTP runtime as the main method
14
+ */
15
+ export function generateWrapperMethods(
16
+ resolvedOp: ResolvedOperation,
17
+ ctx: EmitterContext,
18
+ modelNames: Set<string>,
19
+ requires: Set<string>,
20
+ ): string[] {
21
+ if (!resolvedOp.wrappers || resolvedOp.wrappers.length === 0) return [];
22
+ const out: string[] = [];
23
+ for (const wrapper of resolvedOp.wrappers) {
24
+ const body = emitWrapperMethod(resolvedOp.operation, wrapper, ctx, modelNames, requires);
25
+ out.push(body);
26
+ }
27
+ return out;
28
+ }
29
+
30
+ /** Collect response model filenames needed for wrapper imports. */
31
+ export function collectWrapperResponseModels(resolvedOp: ResolvedOperation): Set<string> {
32
+ const models = new Set<string>();
33
+ for (const w of resolvedOp.wrappers ?? []) {
34
+ if (w.responseModelName) models.add(w.responseModelName);
35
+ }
36
+ return models;
37
+ }
38
+
39
+ function emitWrapperMethod(
40
+ op: Operation,
41
+ wrapper: ResolvedWrapper,
42
+ ctx: EmitterContext,
43
+ modelNames: Set<string>,
44
+ requires: Set<string>,
45
+ ): string {
46
+ void requires;
47
+ const method = wrapper.name; // already snake_case
48
+ const wrapperParams = resolveWrapperParams(wrapper, ctx);
49
+ const lines: string[] = [];
50
+
51
+ // YARD doc
52
+ lines.push(` # ${formatWrapperDescription(wrapper.name)}.`);
53
+ for (const p of op.pathParams ?? []) {
54
+ const pyType = mapTypeRefForYard(p.type);
55
+ lines.push(` # @param ${safeParamName(p.name)} [${pyType}]`);
56
+ }
57
+ for (const wp of wrapperParams) {
58
+ const type = wp.field ? mapTypeRefForYard(wp.field.type) : 'String';
59
+ const alreadyNilable = type.split(', ').includes('nil');
60
+ const suffix = wp.isOptional && !alreadyNilable ? ', nil' : '';
61
+ lines.push(` # @param ${fieldName(wp.paramName)} [${type}${suffix}]`);
62
+ }
63
+ lines.push(` # @param request_options [Hash] Per-request overrides.`);
64
+ if (wrapper.responseModelName) {
65
+ lines.push(` # @return [WorkOS::${className(wrapper.responseModelName)}]`);
66
+ } else {
67
+ lines.push(` # @return [nil]`);
68
+ }
69
+
70
+ // Signature
71
+ const sigParts: string[] = [];
72
+ const seen = new Set<string>();
73
+ for (const p of op.pathParams ?? []) {
74
+ const n = safeParamName(p.name);
75
+ if (seen.has(n)) continue;
76
+ seen.add(n);
77
+ sigParts.push(`${n}:`);
78
+ }
79
+ // Required first, then optional
80
+ for (const wp of wrapperParams) {
81
+ if (wp.isOptional) continue;
82
+ const n = fieldName(wp.paramName);
83
+ if (seen.has(n)) continue;
84
+ seen.add(n);
85
+ sigParts.push(`${n}:`);
86
+ }
87
+ for (const wp of wrapperParams) {
88
+ if (!wp.isOptional) continue;
89
+ const n = fieldName(wp.paramName);
90
+ if (seen.has(n)) continue;
91
+ seen.add(n);
92
+ sigParts.push(`${n}: nil`);
93
+ }
94
+ sigParts.push('request_options: {}');
95
+
96
+ if (sigParts.length === 1 && sigParts[0].length < 60) {
97
+ lines.push(` def ${method}(${sigParts[0]})`);
98
+ } else {
99
+ lines.push(` def ${method}(`);
100
+ for (let i = 0; i < sigParts.length; i++) {
101
+ const sep = i === sigParts.length - 1 ? '' : ',';
102
+ lines.push(` ${sigParts[i]}${sep}`);
103
+ }
104
+ lines.push(' )');
105
+ }
106
+
107
+ // Body hash
108
+ const bodyEntries: string[] = [];
109
+ for (const [k, v] of Object.entries(wrapper.defaults)) {
110
+ const lit = typeof v === 'string' ? rubyStringLit(v) : String(v);
111
+ bodyEntries.push(`${rubyStringLit(k)} => ${lit}`);
112
+ }
113
+ for (const fc of wrapper.inferFromClient) {
114
+ const clientProp = fc === 'client_secret' ? 'api_key' : fc;
115
+ bodyEntries.push(`${rubyStringLit(fc)} => @client.${clientProp}`);
116
+ }
117
+ for (const wp of wrapperParams) {
118
+ bodyEntries.push(`${rubyStringLit(wp.paramName)} => ${fieldName(wp.paramName)}`);
119
+ }
120
+ lines.push(' body = {');
121
+ for (let i = 0; i < bodyEntries.length; i++) {
122
+ const sep = i === bodyEntries.length - 1 ? '' : ',';
123
+ lines.push(` ${bodyEntries[i]}${sep}`);
124
+ }
125
+ lines.push(' }.compact');
126
+
127
+ // Path string — use the unified @client.request helper.
128
+ const rubyPath = interpolateRubyPath(op.path, op.pathParams ?? []);
129
+ const verbSym = op.httpMethod.toLowerCase();
130
+ lines.push(' response = @client.request(');
131
+ lines.push(` method: :${verbSym},`);
132
+ lines.push(` path: ${rubyPath},`);
133
+ lines.push(' auth: true,');
134
+ lines.push(' body: body,');
135
+ lines.push(' request_options: request_options');
136
+ lines.push(' )');
137
+
138
+ // Response handling
139
+ if (wrapper.responseModelName && modelNames.has(wrapper.responseModelName)) {
140
+ lines.push(` WorkOS::${className(wrapper.responseModelName)}.new(response.body)`);
141
+ } else {
142
+ lines.push(' JSON.parse(response.body)');
143
+ }
144
+
145
+ lines.push(' end');
146
+
147
+ return lines.join('\n');
148
+ }
149
+
150
+ function interpolateRubyPath(path: string, pathParams: Operation['pathParams']): string {
151
+ if (!pathParams || pathParams.length === 0) return `'${path}'`;
152
+ let result = path;
153
+ for (const p of pathParams) {
154
+ result = result.split(`{${p.name}}`).join(`#{WorkOS::Util.encode_path(${safeParamName(p.name)})}`);
155
+ }
156
+ return `"${result}"`;
157
+ }
158
+
159
+ function rubyStringLit(s: string): string {
160
+ return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
161
+ }
@@ -1,4 +1,5 @@
1
- import type { Model, Field, TypeRef } from '@workos/oagen';
1
+ import type { Model, Field, TypeRef, Enum } from '@workos/oagen';
2
+ import { toSnakeCase } from '@workos/oagen';
2
3
  import { readFileSync, existsSync } from 'node:fs';
3
4
  import { resolve } from 'node:path';
4
5
  // @ts-ignore -- js-yaml has no type declarations in this project
@@ -103,13 +104,80 @@ function lookupRawSchema(name: string): Record<string, any> | null {
103
104
  return spec?.components?.schemas?.[name] ?? null;
104
105
  }
105
106
 
106
- /** Convert a raw OpenAPI type+format to an IR TypeRef. */
107
- function rawSchemaToTypeRef(schema: Record<string, any>): TypeRef {
107
+ // ---------------------------------------------------------------------------
108
+ // Synthetic model / enum collection
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /**
112
+ * Accumulator for synthetic models and enums generated from inline
113
+ * definitions encountered during oneOf flattening.
114
+ */
115
+ interface SyntheticCollector {
116
+ models: Model[];
117
+ enums: Array<{ name: string; values: Array<{ value: string; description?: string }> }>;
118
+ /** Track names already used to avoid duplicates. */
119
+ usedNames: Set<string>;
120
+ }
121
+
122
+ function createCollector(): SyntheticCollector {
123
+ return { models: [], enums: [], usedNames: new Set() };
124
+ }
125
+
126
+ /**
127
+ * Singularize a snake_case name for use as an array-item model name.
128
+ * `redirect_uris` -> `redirect_uri`, `scopes` -> `scope`.
129
+ */
130
+ function singularizeSnake(name: string): string {
131
+ if (name.endsWith('ies') && name.length > 3) {
132
+ return `${name.slice(0, -3)}y`;
133
+ }
134
+ if (name.endsWith('s') && !name.endsWith('ss')) {
135
+ return name.slice(0, -1);
136
+ }
137
+ return name;
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // rawSchemaToTypeRef -- with synthetic model/enum generation
142
+ // ---------------------------------------------------------------------------
143
+
144
+ /**
145
+ * Convert a raw OpenAPI type+format to an IR TypeRef.
146
+ *
147
+ * When `parentModelName` and `fieldName` are provided, inline objects and
148
+ * enums generate synthetic models/enums instead of degrading to `unknown`
149
+ * or `string`.
150
+ */
151
+ function rawSchemaToTypeRef(
152
+ schema: Record<string, any>,
153
+ parentModelName?: string,
154
+ fName?: string,
155
+ collector?: SyntheticCollector,
156
+ ): TypeRef {
108
157
  if (schema.const !== undefined) {
109
158
  return { kind: 'literal', value: schema.const };
110
159
  }
160
+ if (schema.enum && collector && parentModelName && fName) {
161
+ // Generate a synthetic enum
162
+ const syntheticName = `${parentModelName}_${fName}`;
163
+ if (!collector.usedNames.has(syntheticName) && !collector.usedNames.has(toSnakeCase(syntheticName))) {
164
+ collector.usedNames.add(syntheticName);
165
+ collector.enums.push({
166
+ name: syntheticName,
167
+ values: (schema.enum as string[]).map((v: string) => ({
168
+ value: v,
169
+ description: undefined,
170
+ })),
171
+ });
172
+ }
173
+ return {
174
+ kind: 'enum',
175
+ name: syntheticName,
176
+ values: schema.enum as string[],
177
+ } as TypeRef;
178
+ }
111
179
  if (schema.enum) {
112
- // Simple string enum -- represent as primitive string
180
+ // Simple string enum -- represent as primitive string (no collector)
113
181
  return { kind: 'primitive', type: 'string' } as TypeRef;
114
182
  }
115
183
  if (schema.$ref) {
@@ -127,11 +195,39 @@ function rawSchemaToTypeRef(schema: Record<string, any>): TypeRef {
127
195
  }
128
196
 
129
197
  let ref: TypeRef;
130
- if (baseType === 'object' && schema.properties) {
131
- // Inline object -- treat as unknown
198
+ if (baseType === 'object' && schema.properties && collector && parentModelName && fName) {
199
+ // Inline object -- generate a synthetic model
200
+ const syntheticName = `${parentModelName}_${fName}`;
201
+ if (!collector.usedNames.has(syntheticName) && !collector.usedNames.has(toSnakeCase(syntheticName))) {
202
+ collector.usedNames.add(syntheticName);
203
+ const fields: Field[] = [];
204
+ const requiredSet = new Set<string>(schema.required ?? []);
205
+ for (const [propName, propSchema] of Object.entries(schema.properties) as [string, Record<string, any>][]) {
206
+ fields.push({
207
+ name: propName,
208
+ type: rawSchemaToTypeRef(propSchema, syntheticName, propName, collector),
209
+ required: requiredSet.has(propName),
210
+ description: propSchema.description,
211
+ deprecated: propSchema.deprecated,
212
+ });
213
+ }
214
+ collector.models.push({
215
+ name: syntheticName,
216
+ fields,
217
+ description: schema.description,
218
+ } as Model);
219
+ }
220
+ ref = { kind: 'model', name: syntheticName } as TypeRef;
221
+ } else if (baseType === 'object' && schema.properties) {
222
+ // Inline object -- treat as unknown (no collector)
132
223
  ref = { kind: 'primitive', type: 'unknown' } as TypeRef;
133
224
  } else if (baseType === 'array' && schema.items) {
134
- ref = { kind: 'array', items: rawSchemaToTypeRef(schema.items) } as TypeRef;
225
+ // For array items that are inline objects, use the singular field name
226
+ const itemFieldName = fName ? singularizeSnake(fName) : undefined;
227
+ ref = {
228
+ kind: 'array',
229
+ items: rawSchemaToTypeRef(schema.items, parentModelName, itemFieldName, collector),
230
+ } as TypeRef;
135
231
  } else if (baseType === 'boolean') {
136
232
  ref = { kind: 'primitive', type: 'boolean' } as TypeRef;
137
233
  } else if (baseType === 'integer' || baseType === 'number') {
@@ -151,13 +247,17 @@ function rawSchemaToTypeRef(schema: Record<string, any>): TypeRef {
151
247
  * All fields are returned as optional (not required) since they come from
152
248
  * oneOf variants where only one variant is active at a time.
153
249
  */
154
- function extractFieldsFromRawSchema(schema: Record<string, any>): Field[] {
250
+ function extractFieldsFromRawSchema(
251
+ schema: Record<string, any>,
252
+ parentModelName?: string,
253
+ collector?: SyntheticCollector,
254
+ ): Field[] {
155
255
  const fields: Field[] = [];
156
256
  const props = schema.properties ?? {};
157
257
  for (const [name, propSchema] of Object.entries(props) as [string, Record<string, any>][]) {
158
258
  fields.push({
159
259
  name,
160
- type: rawSchemaToTypeRef(propSchema),
260
+ type: rawSchemaToTypeRef(propSchema, parentModelName, name, collector),
161
261
  required: false, // All oneOf variant fields are optional
162
262
  description: propSchema.description,
163
263
  deprecated: propSchema.deprecated,
@@ -170,14 +270,18 @@ function extractFieldsFromRawSchema(schema: Record<string, any>): Field[] {
170
270
  * Recursively collect all fields from a oneOf schema, flattening nested
171
271
  * allOf+oneOf compositions. All fields are marked optional.
172
272
  */
173
- function collectOneOfFields(schema: Record<string, any>): Field[] {
273
+ function collectOneOfFields(
274
+ schema: Record<string, any>,
275
+ parentModelName?: string,
276
+ collector?: SyntheticCollector,
277
+ ): Field[] {
174
278
  const allFields: Field[] = [];
175
279
  const seenFieldNames = new Set<string>();
176
280
 
177
281
  function walkSchema(s: Record<string, any>): void {
178
282
  // Direct properties
179
283
  if (s.properties) {
180
- for (const f of extractFieldsFromRawSchema(s)) {
284
+ for (const f of extractFieldsFromRawSchema(s, parentModelName, collector)) {
181
285
  if (!seenFieldNames.has(f.name)) {
182
286
  seenFieldNames.add(f.name);
183
287
  allFields.push(f);
@@ -208,6 +312,179 @@ function collectOneOfFields(schema: Record<string, any>): Field[] {
208
312
  return allFields;
209
313
  }
210
314
 
315
+ // ---------------------------------------------------------------------------
316
+ // Array-item type upgrade
317
+ // ---------------------------------------------------------------------------
318
+
319
+ /**
320
+ * Check if a TypeRef is `unknown` (the degraded type for inline objects).
321
+ */
322
+ function isUnknownType(ref: TypeRef): boolean {
323
+ return ref.kind === 'primitive' && (ref as any).type === 'unknown';
324
+ }
325
+
326
+ /**
327
+ * If a field is an `array<unknown>` (or `nullable<array<unknown>>`) and the
328
+ * raw spec defines inline object/enum items, replace the item type with a
329
+ * synthetic model/enum. Returns the original field unchanged when no upgrade
330
+ * is needed.
331
+ */
332
+ function upgradeArrayItemType(
333
+ field: Field,
334
+ rawSchema: Record<string, any>,
335
+ parentModelName: string,
336
+ collector: SyntheticCollector,
337
+ ): Field {
338
+ // Unwrap nullable to find the array
339
+ let arrayRef: TypeRef | null = null;
340
+ let isNullableWrapper = false;
341
+ if (field.type.kind === 'array') {
342
+ arrayRef = field.type;
343
+ } else if (field.type.kind === 'nullable' && field.type.inner.kind === 'array') {
344
+ arrayRef = field.type.inner;
345
+ isNullableWrapper = true;
346
+ }
347
+ if (!arrayRef || arrayRef.kind !== 'array') return field;
348
+ if (!isUnknownType(arrayRef.items)) return field;
349
+
350
+ // Look up the raw spec for this field
351
+ const rawProp = rawSchema.properties?.[field.name];
352
+ if (!rawProp) return field;
353
+
354
+ // Handle the case where the raw property is inside a nullable type array
355
+ let rawArraySchema = rawProp;
356
+ if (Array.isArray(rawProp.type)) {
357
+ const nonNull = rawProp.type.filter((t: string) => t !== 'null');
358
+ if (nonNull[0] === 'array') {
359
+ rawArraySchema = rawProp;
360
+ }
361
+ }
362
+ if (rawArraySchema.type !== 'array' && !(Array.isArray(rawArraySchema.type) && rawArraySchema.type.includes('array')))
363
+ return field;
364
+ if (!rawArraySchema.items) return field;
365
+
366
+ // Generate a proper TypeRef from the raw items schema
367
+ const itemFieldName = singularizeSnake(field.name);
368
+ const newItemRef = rawSchemaToTypeRef(rawArraySchema.items, parentModelName, itemFieldName, collector);
369
+ if (isUnknownType(newItemRef)) return field;
370
+
371
+ const newArrayRef: TypeRef = { kind: 'array', items: newItemRef } as TypeRef;
372
+ const newType: TypeRef = isNullableWrapper ? ({ kind: 'nullable', inner: newArrayRef } as TypeRef) : newArrayRef;
373
+
374
+ return { ...field, type: newType };
375
+ }
376
+
377
+ // ---------------------------------------------------------------------------
378
+ // Module-level store for synthetic enums produced during enrichment.
379
+ // Consumed by `getSyntheticEnums()` after `enrichModelsFromSpec` runs.
380
+ // ---------------------------------------------------------------------------
381
+ let _lastSyntheticEnums: Enum[] = [];
382
+
383
+ /**
384
+ * Return the synthetic enums generated during the last call to
385
+ * `enrichModelsFromSpec`. Call this after enrichment to merge them into the
386
+ * enum generation phase.
387
+ */
388
+ export function getSyntheticEnums(): Enum[] {
389
+ return _lastSyntheticEnums;
390
+ }
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // Implicit discriminator detection
394
+ // ---------------------------------------------------------------------------
395
+
396
+ /**
397
+ * Find a property name that has a `const` value in ALL oneOf variants.
398
+ * Returns null if no shared const property is found.
399
+ */
400
+ function findSharedConstProperty(oneOfSchemas: Record<string, any>[]): string | null {
401
+ if (oneOfSchemas.length === 0) return null;
402
+
403
+ const first = oneOfSchemas[0];
404
+ if (!first.properties) return null;
405
+
406
+ // Candidate properties from the first variant that have const values
407
+ const candidates = Object.keys(first.properties).filter((name) => first.properties[name].const !== undefined);
408
+
409
+ // Return the first candidate that has const values in ALL variants
410
+ for (const candidate of candidates) {
411
+ const allHaveConst = oneOfSchemas.every((variant) => variant.properties?.[candidate]?.const !== undefined);
412
+ if (allHaveConst) return candidate;
413
+ }
414
+
415
+ return null;
416
+ }
417
+
418
+ /**
419
+ * Build a discriminator mapping from const values to IR model names.
420
+ * For each oneOf variant's const value on `discProperty`, find the IR model
421
+ * whose field with the same name is a Literal type with that value.
422
+ */
423
+ function buildDiscriminatorMapping(
424
+ discProperty: string,
425
+ oneOfSchemas: Record<string, any>[],
426
+ models: Model[],
427
+ parentModelName: string,
428
+ ): Record<string, string> {
429
+ const mapping: Record<string, string> = {};
430
+
431
+ for (const variant of oneOfSchemas) {
432
+ const constValue = variant.properties?.[discProperty]?.const;
433
+ if (constValue === undefined) continue;
434
+
435
+ const variantModel = models.find(
436
+ (m) =>
437
+ m.name !== parentModelName &&
438
+ m.fields.some(
439
+ (f) => f.name === discProperty && f.type.kind === 'literal' && (f.type as any).value === constValue,
440
+ ),
441
+ );
442
+ if (variantModel) {
443
+ mapping[String(constValue)] = variantModel.name;
444
+ }
445
+ }
446
+
447
+ return mapping;
448
+ }
449
+
450
+ /**
451
+ * Detect implicit discriminators on models without full oneOf flattening.
452
+ * Returns a new array with discriminator annotations; models without
453
+ * discriminators are returned as-is. Use this when you need discriminator
454
+ * info but don't want the side-effects of full enrichment (synthetic
455
+ * models/enums, field flattening).
456
+ */
457
+ export function detectDiscriminators(models: Model[]): Model[] {
458
+ const spec = loadRawSpec();
459
+ if (!spec) return models;
460
+
461
+ let changed = false;
462
+ const result = models.map((model) => {
463
+ if ((model as any).discriminator) return model;
464
+
465
+ const rawSchema = lookupRawSchema(model.name);
466
+ if (!rawSchema) return model;
467
+
468
+ const oneOfContainer = rawSchema.allOf?.find((s: any) => s.oneOf);
469
+ if (!oneOfContainer?.oneOf || oneOfContainer.oneOf.length === 0) return model;
470
+
471
+ const discProperty = findSharedConstProperty(oneOfContainer.oneOf);
472
+ if (!discProperty) return model;
473
+
474
+ const mapping = buildDiscriminatorMapping(discProperty, oneOfContainer.oneOf, models, model.name);
475
+ if (Object.keys(mapping).length === 0) return model;
476
+
477
+ changed = true;
478
+ return {
479
+ ...model,
480
+ fields: [],
481
+ discriminator: { property: discProperty, mapping },
482
+ };
483
+ });
484
+
485
+ return changed ? result : models;
486
+ }
487
+
211
488
  /**
212
489
  * Enrich IR models by flattening oneOf/allOf+oneOf variant fields from the raw spec.
213
490
  *
@@ -217,28 +494,64 @@ function collectOneOfFields(schema: Record<string, any>): Field[] {
217
494
  * For models whose raw spec schema has allOf containing a oneOf:
218
495
  * - Collect the missing variant fields and add them as optional.
219
496
  *
497
+ * Inline objects and enums in oneOf branches are promoted to synthetic
498
+ * models/enums instead of degrading to `object` / `string`.
499
+ *
220
500
  * Returns a new array of enriched models (original models are not mutated).
501
+ * Synthetic enums are stored internally; retrieve them via `getSyntheticEnums()`.
221
502
  */
222
503
  export function enrichModelsFromSpec(models: Model[]): Model[] {
223
504
  const spec = loadRawSpec();
224
- if (!spec) return models;
505
+ if (!spec) {
506
+ _lastSyntheticEnums = [];
507
+ return models;
508
+ }
225
509
 
226
- return models.map((model) => {
510
+ const collector = createCollector();
511
+ // Avoid name collisions with existing models (check both PascalCase and
512
+ // snake_case to prevent synthetic models from shadowing existing ones when
513
+ // they share a file name, e.g. FooBar vs Foo_bar -> foo_bar).
514
+ for (const m of models) {
515
+ collector.usedNames.add(m.name);
516
+ collector.usedNames.add(toSnakeCase(m.name));
517
+ }
518
+
519
+ const enriched = models.map((model) => {
227
520
  const rawSchema = lookupRawSchema(model.name);
228
521
  if (!rawSchema) return model;
229
522
 
230
523
  const hasOneOf = rawSchema.oneOf || rawSchema.allOf?.some((s: any) => s.oneOf);
231
524
  if (!hasOneOf) return model;
232
525
 
233
- // Skip schemas with discriminator -- those are intentional unions
526
+ // Skip schemas with explicit discriminator -- those are intentional unions
234
527
  const hasDiscriminator =
235
528
  rawSchema.discriminator ||
236
529
  rawSchema.oneOf?.some((v: any) => v.discriminator) ||
237
530
  rawSchema.allOf?.some((s: any) => s.discriminator || s.oneOf?.some((v: any) => v.discriminator));
238
531
  if (hasDiscriminator) return model;
239
532
 
240
- // Collect all variant fields from the raw schema
241
- const variantFields = collectOneOfFields(rawSchema);
533
+ // Detect implicit discriminators: allOf+oneOf where all variants share a
534
+ // property with const values (e.g., EventSchema with event: const: "user.created").
535
+ // When found, attach a discriminator mapping and clear fields so the emitter
536
+ // generates a dispatcher class instead of a flat dataclass.
537
+ const oneOfContainer = rawSchema.allOf?.find((s: any) => s.oneOf);
538
+ if (oneOfContainer?.oneOf && oneOfContainer.oneOf.length > 0) {
539
+ const discProperty = findSharedConstProperty(oneOfContainer.oneOf);
540
+ if (discProperty) {
541
+ const mapping = buildDiscriminatorMapping(discProperty, oneOfContainer.oneOf, models, model.name);
542
+ if (Object.keys(mapping).length > 0) {
543
+ return {
544
+ ...model,
545
+ fields: [],
546
+ discriminator: { property: discProperty, mapping },
547
+ };
548
+ }
549
+ }
550
+ }
551
+
552
+ // Collect all variant fields from the raw schema, generating synthetic
553
+ // models/enums for inline definitions along the way.
554
+ const variantFields = collectOneOfFields(rawSchema, model.name, collector);
242
555
  if (variantFields.length === 0) return model;
243
556
 
244
557
  // Merge variant fields into the existing model, skipping duplicates
@@ -252,4 +565,32 @@ export function enrichModelsFromSpec(models: Model[]): Model[] {
252
565
  fields: [...model.fields, ...newFields],
253
566
  };
254
567
  });
568
+
569
+ // Second pass: fix array fields whose items degraded to `unknown` in the
570
+ // IR but are actually inline objects or enums in the raw spec.
571
+ const enriched2 = enriched.map((model) => {
572
+ const rawSchema = lookupRawSchema(model.name);
573
+ if (!rawSchema?.properties) return model;
574
+
575
+ let modified = false;
576
+ const newFields = model.fields.map((field) => {
577
+ const upgraded = upgradeArrayItemType(field, rawSchema, model.name, collector);
578
+ if (upgraded !== field) modified = true;
579
+ return upgraded;
580
+ });
581
+
582
+ return modified ? { ...model, fields: newFields } : model;
583
+ });
584
+
585
+ // Convert synthetic enum collector entries to proper Enum objects
586
+ _lastSyntheticEnums = collector.enums.map((e) => ({
587
+ name: e.name,
588
+ values: e.values.map((v) => ({ value: v.value, description: v.description })),
589
+ })) as Enum[];
590
+
591
+ // Append synthetic models, skipping those whose snake_case name collides
592
+ // with an existing model (prevents broken TypeAlias self-imports).
593
+ const existingSnakeNames = new Set(enriched2.map((m) => toSnakeCase(m.name)));
594
+ const filteredSynthetic = collector.models.filter((m) => !existingSnakeNames.has(toSnakeCase(m.name)));
595
+ return [...enriched2, ...filteredSynthetic];
255
596
  }