@workos/oagen-emitters 0.14.3 → 0.15.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 (44) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +19 -0
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/dist/{plugin-D0qLBiGv.mjs → plugin-CO4RFgAW.mjs} +983 -270
  6. package/dist/plugin-CO4RFgAW.mjs.map +1 -0
  7. package/dist/plugin.mjs +1 -1
  8. package/package.json +9 -9
  9. package/renovate.json +1 -61
  10. package/src/go/client.ts +1 -1
  11. package/src/go/enums.ts +77 -0
  12. package/src/kotlin/enums.ts +11 -4
  13. package/src/node/client.ts +119 -2
  14. package/src/node/discriminated-models.ts +20 -28
  15. package/src/node/field-plan.ts +64 -8
  16. package/src/node/index.ts +59 -3
  17. package/src/node/models.ts +73 -30
  18. package/src/node/naming.ts +14 -1
  19. package/src/node/node-overrides.ts +4 -37
  20. package/src/node/options.ts +29 -1
  21. package/src/node/resources.ts +533 -83
  22. package/src/node/tests.ts +108 -7
  23. package/src/php/fixtures.ts +4 -1
  24. package/src/php/models.ts +3 -1
  25. package/src/php/resources.ts +40 -11
  26. package/src/php/tests.ts +22 -12
  27. package/src/python/client.ts +0 -8
  28. package/src/python/enums.ts +41 -15
  29. package/src/python/fixtures.ts +23 -7
  30. package/src/python/models.ts +26 -5
  31. package/src/python/resources.ts +71 -3
  32. package/src/python/tests.ts +70 -12
  33. package/src/python/wrappers.ts +25 -4
  34. package/src/ruby/client.ts +0 -1
  35. package/src/ruby/rbi.ts +12 -6
  36. package/src/rust/resources.ts +10 -7
  37. package/src/shared/non-spec-services.ts +0 -5
  38. package/test/go/enums.test.ts +24 -0
  39. package/test/node/resources.test.ts +11 -1
  40. package/test/node/tests.test.ts +3 -3
  41. package/test/php/client.test.ts +0 -1
  42. package/test/php/resources.test.ts +50 -0
  43. package/test/rust/resources.test.ts +9 -0
  44. package/dist/plugin-D0qLBiGv.mjs.map +0 -1
package/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as workosEmittersPlugin } from "./plugin-D0qLBiGv.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-CO4RFgAW.mjs";
2
2
  export { workosEmittersPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/oagen-emitters",
3
- "version": "0.14.3",
3
+ "version": "0.15.0",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -38,22 +38,22 @@
38
38
  "prepare": "husky"
39
39
  },
40
40
  "devDependencies": {
41
- "@commitlint/cli": "^21.0.1",
42
- "@commitlint/config-conventional": "^21.0.1",
41
+ "@commitlint/cli": "^21.0.2",
42
+ "@commitlint/config-conventional": "^21.0.2",
43
43
  "@types/node": "^25.9.1",
44
44
  "husky": "^9.1.7",
45
- "oxfmt": "^0.51.0",
46
- "oxlint": "^1.66.0",
45
+ "oxfmt": "^0.52.0",
46
+ "oxlint": "^1.67.0",
47
47
  "prettier": "^3.8.3",
48
- "tsdown": "^0.22.0",
49
- "tsx": "^4.22.3",
48
+ "tsdown": "^0.22.1",
49
+ "tsx": "^4.22.4",
50
50
  "typescript": "^6.0.3",
51
- "vitest": "^4.1.7"
51
+ "vitest": "^4.1.8"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=24.10.0"
55
55
  },
56
56
  "dependencies": {
57
- "@workos/oagen": "^0.20.0"
57
+ "@workos/oagen": "^0.21.1"
58
58
  }
59
59
  }
package/renovate.json CHANGED
@@ -1,66 +1,6 @@
1
1
  {
2
2
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3
3
  "extends": [
4
- "github>workos/renovate-config"
5
- ],
6
- "dependencyDashboard": false,
7
- "schedule": [
8
- "on the 15th day of the month before 12pm"
9
- ],
10
- "timezone": "UTC",
11
- "rebaseWhen": "conflicted",
12
- "packageRules": [
13
- {
14
- "matchManagers": [
15
- "github-actions"
16
- ],
17
- "pinDigests": true,
18
- "extractVersion": "^v(?<version>\\d+\\.\\d+\\.\\d+)$"
19
- },
20
- {
21
- "matchUpdateTypes": [
22
- "minor",
23
- "patch"
24
- ],
25
- "automerge": true,
26
- "groupName": "minor and patch updates"
27
- },
28
- {
29
- "matchUpdateTypes": [
30
- "major"
31
- ],
32
- "automerge": false
33
- },
34
- {
35
- "matchUpdateTypes": [
36
- "digest"
37
- ],
38
- "automerge": false
39
- },
40
- {
41
- "matchManagers": [
42
- "github-actions"
43
- ],
44
- "matchUpdateTypes": [
45
- "minor",
46
- "patch",
47
- "digest",
48
- "pinDigest"
49
- ],
50
- "groupName": "github actions non-major",
51
- "groupSlug": "github-actions-non-major",
52
- "automerge": true
53
- },
54
- {
55
- "matchManagers": [
56
- "github-actions"
57
- ],
58
- "matchUpdateTypes": [
59
- "major"
60
- ],
61
- "groupName": "github actions major",
62
- "groupSlug": "github-actions-major",
63
- "automerge": false
64
- }
4
+ "github>workos/renovate-config:public"
65
5
  ]
66
6
  }
package/src/go/client.ts CHANGED
@@ -17,7 +17,7 @@ export function generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFil
17
17
  }
18
18
 
19
19
  /**
20
- * Non-spec services marked with `hasClientAccessor: true` (passwordless, vault)
20
+ * Non-spec services marked with `hasClientAccessor: true` (e.g. passwordless)
21
21
  * are included in the generated Client struct, constructor, and accessor methods
22
22
  * — identical to spec-driven services. Their service type (e.g. PasswordlessService)
23
23
  * is defined in a hand-written @oagen-ignore-file, but the Client wiring is generated.
package/src/go/enums.ts CHANGED
@@ -96,6 +96,8 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
96
96
  content: lines.join('\n'),
97
97
  overwriteExisting: true,
98
98
  });
99
+ const eventConstantsFile = generateEventConstantsFile(enums);
100
+ if (eventConstantsFile) files.push(eventConstantsFile);
99
101
 
100
102
  return files;
101
103
  }
@@ -173,6 +175,81 @@ function collectEnumAliasOf(enums: Enum[]): Map<string, string> {
173
175
  return aliasOf;
174
176
  }
175
177
 
178
+ function generateEventConstantsFile(enums: Enum[]): GeneratedFile | null {
179
+ const enumDef = findWebhookEventEnum(enums);
180
+ if (!enumDef) return null;
181
+
182
+ const lines: string[] = [];
183
+ lines.push('package events');
184
+ lines.push('');
185
+ lines.push('// Event is a WorkOS event type.');
186
+ lines.push('type Event string');
187
+ lines.push('');
188
+ lines.push('const (');
189
+
190
+ const seenValues = new Set<string>();
191
+ const usedNames = new Set<string>();
192
+ for (const value of enumDef.values) {
193
+ const valueStr = String(value.value);
194
+ if (seenValues.has(valueStr)) continue;
195
+ seenValues.add(valueStr);
196
+
197
+ const constName = uniqueEventConstantName(valueStr, usedNames);
198
+ usedNames.add(constName);
199
+
200
+ if (value.description) {
201
+ lines.push(`\t// ${constName} is ${value.description}.`);
202
+ }
203
+ if (value.deprecated) {
204
+ if (value.description) lines.push('\t//');
205
+ lines.push('\t// Deprecated: this value is deprecated.');
206
+ }
207
+ // Keep constants untyped so callers can use them as plain strings,
208
+ // events.Event values, or typed root-package enum values.
209
+ lines.push(`\t${constName} = "${escapeGoString(valueStr)}"`);
210
+ }
211
+
212
+ lines.push(')');
213
+ lines.push('');
214
+
215
+ return {
216
+ path: 'pkg/events/events.go',
217
+ content: lines.join('\n'),
218
+ overwriteExisting: true,
219
+ };
220
+ }
221
+
222
+ function findWebhookEventEnum(enums: Enum[]): Enum | null {
223
+ return (
224
+ enums.find((enumDef) => enumDef.name === 'CreateWebhookEndpointEvents') ??
225
+ enums.find(
226
+ (enumDef) =>
227
+ isWebhookEventEnumName(enumDef.name) &&
228
+ enumDef.values.length > 0 &&
229
+ enumDef.values.every((value) => typeof value.value === 'string' && value.value.includes('.')),
230
+ ) ??
231
+ null
232
+ );
233
+ }
234
+
235
+ function isWebhookEventEnumName(name: string): boolean {
236
+ const normalized = name.toLowerCase();
237
+ return normalized.includes('webhook') && normalized.includes('event');
238
+ }
239
+
240
+ function uniqueEventConstantName(value: string, usedNames: Set<string>): string {
241
+ const base = className(value);
242
+ if (!usedNames.has(base)) return base;
243
+
244
+ let suffix = 2;
245
+ while (usedNames.has(`${base}${suffix}`)) suffix++;
246
+ return `${base}${suffix}`;
247
+ }
248
+
249
+ function escapeGoString(value: string): string {
250
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
251
+ }
252
+
176
253
  export function assignEnumsToServices(enums: Enum[], services: Service[]): Map<string, string> {
177
254
  const enumToService = new Map<string, string>();
178
255
  const enumNames = new Set(enums.map((e) => e.name));
@@ -41,12 +41,19 @@ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFil
41
41
 
42
42
  // Within each group, pick the shortest className as canonical.
43
43
  const aliasOf = new Map<string, string>(); // enum name → canonical enum name
44
+ const sharedSortEmitters = new Set<string>();
44
45
  for (const [, group] of hashGroups) {
45
- if (group.length <= 1) continue;
46
+ if (group.length <= 1) {
47
+ if (group.length === 1 && isSharedSortOrderEnum(group[0])) {
48
+ enumCanonicalMap.set(group[0].name, 'SortOrder');
49
+ sharedSortEmitters.add(group[0].name);
50
+ }
51
+ continue;
52
+ }
46
53
  if (group.every(isSharedSortOrderEnum)) {
47
54
  const [canonical, ...rest] = [...group].sort((a, b) => a.name.localeCompare(b.name));
48
- enumCanonicalMap.set(canonical.name, canonical.name);
49
- for (const enumDef of rest) enumCanonicalMap.set(enumDef.name, 'SortOrder');
55
+ sharedSortEmitters.add(canonical.name);
56
+ for (const enumDef of [canonical, ...rest]) enumCanonicalMap.set(enumDef.name, 'SortOrder');
50
57
  continue;
51
58
  }
52
59
  const sorted = [...group].sort(
@@ -68,7 +75,7 @@ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFil
68
75
  const typeName = canonicalEnumTypeName(enumDef);
69
76
 
70
77
  // Non-canonical enum: emit a typealias instead of a full enum class.
71
- const sharedSortEmitter = isSharedSortOrderEnum(enumDef) && enumCanonicalMap.get(enumDef.name) === enumDef.name;
78
+ const sharedSortEmitter = sharedSortEmitters.has(enumDef.name);
72
79
  const canonicalName = sharedSortEmitter
73
80
  ? undefined
74
81
  : (aliasOf.get(enumDef.name) ?? enumCanonicalMap.get(enumDef.name));
@@ -18,9 +18,11 @@ import {
18
18
  isListWrapperModel,
19
19
  computeNonEventReachable,
20
20
  } from './utils.js';
21
+ import { isNodeOwnedService, nodeOptions } from './options.js';
21
22
  import { resolveResourceClassName, resolveResourceDir } from './resources.js';
22
23
  import { generatedResourceInterfaceModelNames } from './models.js';
23
24
  import { assignEnumsToServices } from './enums.js';
25
+ import { liveSurfaceInterfacePath } from './live-surface.js';
24
26
 
25
27
  export function generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
26
28
  const files: GeneratedFile[] = [];
@@ -129,6 +131,46 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
129
131
  };
130
132
  }
131
133
 
134
+ function sourceFileForExportedType(ctx: EmitterContext, typeName: string): string | undefined {
135
+ return (
136
+ (ctx.apiSurface?.interfaces?.[typeName] as { sourceFile?: string } | undefined)?.sourceFile ??
137
+ (ctx.apiSurface?.typeAliases?.[typeName] as { sourceFile?: string } | undefined)?.sourceFile ??
138
+ (ctx.apiSurface?.enums?.[typeName] as { sourceFile?: string } | undefined)?.sourceFile ??
139
+ liveSurfaceInterfacePath(typeName)
140
+ );
141
+ }
142
+
143
+ function exportedNamesForSource(ctx: EmitterContext, sourceFile: string): string[] {
144
+ const names = new Set<string>();
145
+ const addNamesFromSurface = (items: Record<string, unknown> | undefined) => {
146
+ if (!items) return;
147
+ for (const [name, item] of Object.entries(items)) {
148
+ if ((item as { sourceFile?: string }).sourceFile === sourceFile) {
149
+ names.add(name);
150
+ }
151
+ }
152
+ };
153
+
154
+ addNamesFromSurface(ctx.apiSurface?.interfaces);
155
+ addNamesFromSurface(ctx.apiSurface?.typeAliases);
156
+ addNamesFromSurface(ctx.apiSurface?.enums);
157
+
158
+ const rootDir = ctx.targetDir ?? ctx.outputDir;
159
+ if (rootDir) {
160
+ try {
161
+ const content = fs.readFileSync(path.join(rootDir, sourceFile), 'utf-8');
162
+ for (const match of content.matchAll(/export\s+(?:interface|type|enum|class|const|function)\s+(\w+)/g)) {
163
+ names.add(match[1]);
164
+ }
165
+ } catch {
166
+ // The live source map is best-effort; apiSurface/livesurface names above
167
+ // are enough when the file is not readable.
168
+ }
169
+ }
170
+
171
+ return [...names];
172
+ }
173
+
132
174
  /**
133
175
  * Generate per-service barrel files (interfaces/index.ts) that re-export
134
176
  * all interface and enum files for each service directory. This reduces
@@ -146,6 +188,12 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
146
188
  // from one file and a domain type from another).
147
189
  const dirExports = new Map<string, string[]>();
148
190
  const dirSymbols = new Map<string, Set<string>>();
191
+ const ownedDirNames = new Set<string>();
192
+ for (const service of spec.services) {
193
+ if (isNodeOwnedService(ctx, service.name)) {
194
+ ownedDirNames.add(resolveDir(service.name));
195
+ }
196
+ }
149
197
 
150
198
  // Pre-seed dirSymbols with names already exported by existing interface files.
151
199
  // When the existing SDK has an interface file in a directory that already
@@ -160,6 +208,7 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
160
208
  const match = sourceFile.match(/^src\/([^/]+)\/interfaces\/(.+)\.ts$/);
161
209
  if (!match) return;
162
210
  const dirName = match[1];
211
+ if (ownedDirNames.has(dirName)) return;
163
212
  const fileStem = match[2];
164
213
  if (!dirSymbols.has(dirName)) dirSymbols.set(dirName, new Set());
165
214
  dirSymbols.get(dirName)!.add(name);
@@ -260,14 +309,82 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
260
309
  dirExports.get(dirName)!.push(`export * from './${fileName(enumDef.name)}.interface';`);
261
310
  }
262
311
 
312
+ const overrideWildcardSources = new Set<string>();
313
+ const overrideNamedExports = new Set<string>();
314
+ const addOverrideTypeExport = (typeName: string | undefined) => {
315
+ if (!typeName) return;
316
+ const sourceFile = sourceFileForExportedType(ctx, typeName);
317
+ if (!sourceFile) return;
318
+ const match = sourceFile?.match(/^src\/([^/]+)\/interfaces\/(.+)\.ts$/);
319
+ if (!match) return;
320
+ const dirName = match[1];
321
+ const stem = match[2].replace(/\.ts$/, '');
322
+ if (!dirExports.has(dirName)) {
323
+ dirExports.set(dirName, []);
324
+ if (!dirSymbols.has(dirName)) {
325
+ dirSymbols.set(dirName, new Set());
326
+ }
327
+ }
328
+ const symbols = dirSymbols.get(dirName)!;
329
+ const sourceKey = `${dirName}/${stem}`;
330
+ if (overrideWildcardSources.has(sourceKey)) return;
331
+
332
+ const sourceNames = exportedNamesForSource(ctx, sourceFile);
333
+ const hasConflictingNeighbor = sourceNames.some((name) => name !== typeName && globalExistingSymbols.has(name));
334
+ if (hasConflictingNeighbor) {
335
+ const exportKey = `${sourceKey}:${typeName}`;
336
+ if (overrideNamedExports.has(exportKey)) return;
337
+ dirExports.get(dirName)!.push(`export type { ${typeName} } from './${stem}';`);
338
+ overrideNamedExports.add(exportKey);
339
+ symbols.add(typeName);
340
+ globalExistingSymbols.add(typeName);
341
+ return;
342
+ }
343
+
344
+ dirExports.get(dirName)!.push(`export * from './${stem}';`);
345
+ overrideWildcardSources.add(sourceKey);
346
+ const namesToRegister = sourceNames.length > 0 ? sourceNames : [typeName];
347
+ for (const name of namesToRegister) {
348
+ symbols.add(name);
349
+ globalExistingSymbols.add(name);
350
+ }
351
+ };
352
+
353
+ for (const override of Object.values(nodeOptions(ctx).operationOverrides ?? {})) {
354
+ addOverrideTypeExport(override.optionsType);
355
+ for (const typeName of override.returnTypeImports ?? []) {
356
+ addOverrideTypeExport(typeName);
357
+ }
358
+ }
359
+
360
+ const generatedOptionExports = (ctx as any)._nodeGeneratedOptionInterfaceExports as
361
+ | Map<string, Array<{ stem: string; typeName: string }>>
362
+ | undefined;
363
+ for (const [dirName, options] of generatedOptionExports ?? []) {
364
+ if (!dirExports.has(dirName)) {
365
+ dirExports.set(dirName, []);
366
+ if (!dirSymbols.has(dirName)) {
367
+ dirSymbols.set(dirName, new Set());
368
+ }
369
+ }
370
+ const symbols = dirSymbols.get(dirName)!;
371
+ for (const { stem, typeName } of options) {
372
+ if (globalExistingSymbols.has(typeName)) continue;
373
+ symbols.add(typeName);
374
+ globalExistingSymbols.add(typeName);
375
+ dirExports.get(dirName)!.push(`export * from './${stem}';`);
376
+ }
377
+ }
378
+
263
379
  for (const [dirName, exports] of dirExports) {
264
380
  const exportSet = new Set(exports);
381
+ const isDirOwned = ownedDirNames.has(dirName);
265
382
 
266
383
  // When integrating into an existing SDK, include baseline exports from
267
384
  // the api-surface so the barrel is comprehensive. This ensures stale
268
385
  // entries (e.g., renamed files from previous generations) are removed
269
386
  // when overwriteExisting replaces the barrel.
270
- if (ctx.apiSurface) {
387
+ if (ctx.apiSurface && !isDirOwned) {
271
388
  const addBaselineExports = (items: Record<string, any> | undefined) => {
272
389
  if (!items) return;
273
390
  for (const item of Object.values(items)) {
@@ -288,7 +405,7 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
288
405
  // corresponding file still exists on disk. This prevents dropping
289
406
  // hand-written types (e.g., Factor in multi-factor-auth) when a
290
407
  // generated model in the same file causes a symbol collision.
291
- if (ctx.targetDir) {
408
+ if (ctx.targetDir && !isDirOwned) {
292
409
  const interfacesDir = path.join(ctx.targetDir, 'src', dirName, 'interfaces');
293
410
  try {
294
411
  const barrelPath = path.join(interfacesDir, 'index.ts');
@@ -69,6 +69,8 @@ interface DiscriminatedShape {
69
69
  discriminatorProperty: string;
70
70
  /** Field name in domain (camelCase). */
71
71
  discriminatorPropertyDomain: string;
72
+ /** Description from the OpenAPI spec, if present. */
73
+ discriminatorDescription?: string;
72
74
  variants: VariantSpec[];
73
75
  }
74
76
 
@@ -124,20 +126,23 @@ export function detectDiscriminatedShape(
124
126
  return {
125
127
  nameSuffix: variantNameSuffix(discValue),
126
128
  discriminatorValue: discValue,
127
- fields: variantFields(fv, discProp, modelName, rawSchemas),
129
+ fields: variantFields(fv, discProp, modelName),
128
130
  };
129
131
  })
130
132
  .filter((v): v is VariantSpec => v !== null);
131
133
 
132
134
  if (variants.length !== flattenedVariants.length) return null;
133
135
 
134
- const baseFields = baseObject ? collectObjectFields(baseObject, modelName, rawSchemas) : [];
136
+ const baseFields = baseObject ? collectObjectFields(baseObject, modelName) : [];
137
+
138
+ const discriminatorDescription = flattenedVariants[0].alwaysProperties.get(discProp)?.description;
135
139
 
136
140
  return {
137
141
  modelName,
138
142
  baseFields,
139
143
  discriminatorProperty: discProp,
140
144
  discriminatorPropertyDomain: toCamelCase(discProp),
145
+ discriminatorDescription,
141
146
  variants,
142
147
  };
143
148
  }
@@ -146,7 +151,7 @@ function mergeBase(prev: RawSchema | null, next: RawSchema): RawSchema {
146
151
  if (!prev) return next;
147
152
  return {
148
153
  type: 'object',
149
- properties: { ...(prev.properties ?? {}), ...(next.properties ?? {}) },
154
+ properties: { ...prev.properties, ...next.properties },
150
155
  required: [...new Set([...(prev.required ?? []), ...(next.required ?? [])])],
151
156
  };
152
157
  }
@@ -328,48 +333,33 @@ function variantNameSuffix(constValue: string): string {
328
333
  // Field extraction
329
334
  // ---------------------------------------------------------------------------
330
335
 
331
- function collectObjectFields(
332
- schema: RawSchema,
333
- parentName: string,
334
- rawSchemas: Record<string, RawSchema>,
335
- ): FieldSpec[] {
336
+ function collectObjectFields(schema: RawSchema, parentName: string): FieldSpec[] {
336
337
  const props = schema.properties ?? {};
337
338
  const required = new Set(schema.required ?? []);
338
339
  const fields: FieldSpec[] = [];
339
340
  for (const [name, propSchema] of Object.entries(props)) {
340
- fields.push(buildField(name, propSchema, required.has(name), parentName, rawSchemas));
341
+ fields.push(buildField(name, propSchema, required.has(name), parentName));
341
342
  }
342
343
  return fields;
343
344
  }
344
345
 
345
- function variantFields(
346
- fv: FlattenedVariant,
347
- discriminatorProperty: string,
348
- parentName: string,
349
- rawSchemas: Record<string, RawSchema>,
350
- ): FieldSpec[] {
346
+ function variantFields(fv: FlattenedVariant, discriminatorProperty: string, parentName: string): FieldSpec[] {
351
347
  const fields: FieldSpec[] = [];
352
348
  for (const [name, propSchema] of fv.alwaysProperties) {
353
349
  if (name === discriminatorProperty) continue;
354
- fields.push(buildField(name, propSchema, fv.required.has(name), parentName, rawSchemas));
350
+ fields.push(buildField(name, propSchema, fv.required.has(name), parentName));
355
351
  }
356
352
  for (const [name, propSchema] of fv.optionalProperties) {
357
353
  if (name === discriminatorProperty) continue;
358
- fields.push(buildField(name, propSchema, false, parentName, rawSchemas));
354
+ fields.push(buildField(name, propSchema, false, parentName));
359
355
  }
360
356
  return fields;
361
357
  }
362
358
 
363
- function buildField(
364
- rawName: string,
365
- schema: RawSchema,
366
- required: boolean,
367
- parentName: string,
368
- rawSchemas: Record<string, RawSchema>,
369
- ): FieldSpec {
359
+ function buildField(rawName: string, schema: RawSchema, required: boolean, parentName: string): FieldSpec {
370
360
  const modelDeps = new Set<string>();
371
- const domainType = rawSchemaToTS(schema, parentName, rawName, false, modelDeps, rawSchemas);
372
- const wireType = rawSchemaToTS(schema, parentName, rawName, true, modelDeps, rawSchemas);
361
+ const domainType = rawSchemaToTS(schema, parentName, rawName, false, modelDeps);
362
+ const wireType = rawSchemaToTS(schema, parentName, rawName, true, modelDeps);
373
363
  return {
374
364
  name: rawName,
375
365
  description: schema.description,
@@ -404,7 +394,6 @@ function rawSchemaToTS(
404
394
  fieldName: string,
405
395
  isWire: boolean,
406
396
  modelDeps: Set<string>,
407
- rawSchemas: Record<string, RawSchema>,
408
397
  ): string {
409
398
  if (schema.$ref) {
410
399
  const refName = schema.$ref.split('/').pop()!;
@@ -428,7 +417,7 @@ function rawSchemaToTS(
428
417
  } else if (baseType === 'boolean') {
429
418
  core = 'boolean';
430
419
  } else if (baseType === 'array' && schema.items) {
431
- const items = rawSchemaToTS(schema.items, parentName, singularize(fieldName), isWire, modelDeps, rawSchemas);
420
+ const items = rawSchemaToTS(schema.items, parentName, singularize(fieldName), isWire, modelDeps);
432
421
  core = `${parenthesizeUnion(items)}[]`;
433
422
  } else if (baseType === 'object' && schema.properties) {
434
423
  // Inline object — refer to the synthetic model name that
@@ -543,6 +532,9 @@ function buildInterfaceBody(name: string, shape: DiscriminatedShape, variant: Va
543
532
  }
544
533
  // Discriminator (typed as the variant's const value)
545
534
  const discKey = isWire ? shape.discriminatorProperty : shape.discriminatorPropertyDomain;
535
+ if (shape.discriminatorDescription) {
536
+ lines.push(` /** ${shape.discriminatorDescription} */`);
537
+ }
546
538
  lines.push(` ${discKey}: '${variant.discriminatorValue}';`);
547
539
  // Variant-specific fields
548
540
  for (const field of variant.fields) {