@workos/oagen-emitters 0.14.4 → 0.15.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.
Files changed (43) 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-BGVaMGqe.mjs → plugin-C2Hp2Vs2.mjs} +1039 -274
  6. package/dist/plugin-C2Hp2Vs2.mjs.map +1 -0
  7. package/dist/plugin.mjs +1 -1
  8. package/package.json +7 -7
  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 +158 -2
  14. package/src/node/discriminated-models.ts +68 -24
  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 +553 -89
  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/rust/resources.ts +10 -7
  36. package/src/shared/non-spec-services.ts +0 -5
  37. package/test/go/enums.test.ts +24 -0
  38. package/test/node/resources.test.ts +11 -1
  39. package/test/node/tests.test.ts +3 -3
  40. package/test/php/client.test.ts +0 -1
  41. package/test/php/resources.test.ts +50 -0
  42. package/test/rust/resources.test.ts +9 -0
  43. package/dist/plugin-BGVaMGqe.mjs.map +0 -1
package/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as workosEmittersPlugin } from "./plugin-BGVaMGqe.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-C2Hp2Vs2.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.4",
3
+ "version": "0.15.1",
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
45
  "oxfmt": "^0.52.0",
46
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.21.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,19 @@ 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
+ const dir = resolveDir(service.name);
195
+ ownedDirNames.add(dir);
196
+ // Ensure owned directories always get a barrel entry, even if no
197
+ // model interfaces are generated (hand-written files still need it).
198
+ if (!dirExports.has(dir)) {
199
+ dirExports.set(dir, []);
200
+ if (!dirSymbols.has(dir)) dirSymbols.set(dir, new Set());
201
+ }
202
+ }
203
+ }
149
204
 
150
205
  // Pre-seed dirSymbols with names already exported by existing interface files.
151
206
  // When the existing SDK has an interface file in a directory that already
@@ -160,6 +215,7 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
160
215
  const match = sourceFile.match(/^src\/([^/]+)\/interfaces\/(.+)\.ts$/);
161
216
  if (!match) return;
162
217
  const dirName = match[1];
218
+ if (ownedDirNames.has(dirName)) return;
163
219
  const fileStem = match[2];
164
220
  if (!dirSymbols.has(dirName)) dirSymbols.set(dirName, new Set());
165
221
  dirSymbols.get(dirName)!.add(name);
@@ -260,14 +316,82 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
260
316
  dirExports.get(dirName)!.push(`export * from './${fileName(enumDef.name)}.interface';`);
261
317
  }
262
318
 
319
+ const overrideWildcardSources = new Set<string>();
320
+ const overrideNamedExports = new Set<string>();
321
+ const addOverrideTypeExport = (typeName: string | undefined) => {
322
+ if (!typeName) return;
323
+ const sourceFile = sourceFileForExportedType(ctx, typeName);
324
+ if (!sourceFile) return;
325
+ const match = sourceFile?.match(/^src\/([^/]+)\/interfaces\/(.+)\.ts$/);
326
+ if (!match) return;
327
+ const dirName = match[1];
328
+ const stem = match[2].replace(/\.ts$/, '');
329
+ if (!dirExports.has(dirName)) {
330
+ dirExports.set(dirName, []);
331
+ if (!dirSymbols.has(dirName)) {
332
+ dirSymbols.set(dirName, new Set());
333
+ }
334
+ }
335
+ const symbols = dirSymbols.get(dirName)!;
336
+ const sourceKey = `${dirName}/${stem}`;
337
+ if (overrideWildcardSources.has(sourceKey)) return;
338
+
339
+ const sourceNames = exportedNamesForSource(ctx, sourceFile);
340
+ const hasConflictingNeighbor = sourceNames.some((name) => name !== typeName && globalExistingSymbols.has(name));
341
+ if (hasConflictingNeighbor) {
342
+ const exportKey = `${sourceKey}:${typeName}`;
343
+ if (overrideNamedExports.has(exportKey)) return;
344
+ dirExports.get(dirName)!.push(`export type { ${typeName} } from './${stem}';`);
345
+ overrideNamedExports.add(exportKey);
346
+ symbols.add(typeName);
347
+ globalExistingSymbols.add(typeName);
348
+ return;
349
+ }
350
+
351
+ dirExports.get(dirName)!.push(`export * from './${stem}';`);
352
+ overrideWildcardSources.add(sourceKey);
353
+ const namesToRegister = sourceNames.length > 0 ? sourceNames : [typeName];
354
+ for (const name of namesToRegister) {
355
+ symbols.add(name);
356
+ globalExistingSymbols.add(name);
357
+ }
358
+ };
359
+
360
+ for (const override of Object.values(nodeOptions(ctx).operationOverrides ?? {})) {
361
+ addOverrideTypeExport(override.optionsType);
362
+ for (const typeName of override.returnTypeImports ?? []) {
363
+ addOverrideTypeExport(typeName);
364
+ }
365
+ }
366
+
367
+ const generatedOptionExports = (ctx as any)._nodeGeneratedOptionInterfaceExports as
368
+ | Map<string, Array<{ stem: string; typeName: string }>>
369
+ | undefined;
370
+ for (const [dirName, options] of generatedOptionExports ?? []) {
371
+ if (!dirExports.has(dirName)) {
372
+ dirExports.set(dirName, []);
373
+ if (!dirSymbols.has(dirName)) {
374
+ dirSymbols.set(dirName, new Set());
375
+ }
376
+ }
377
+ const symbols = dirSymbols.get(dirName)!;
378
+ for (const { stem, typeName } of options) {
379
+ if (globalExistingSymbols.has(typeName)) continue;
380
+ symbols.add(typeName);
381
+ globalExistingSymbols.add(typeName);
382
+ dirExports.get(dirName)!.push(`export * from './${stem}';`);
383
+ }
384
+ }
385
+
263
386
  for (const [dirName, exports] of dirExports) {
264
387
  const exportSet = new Set(exports);
388
+ const isDirOwned = ownedDirNames.has(dirName);
265
389
 
266
390
  // When integrating into an existing SDK, include baseline exports from
267
391
  // the api-surface so the barrel is comprehensive. This ensures stale
268
392
  // entries (e.g., renamed files from previous generations) are removed
269
393
  // when overwriteExisting replaces the barrel.
270
- if (ctx.apiSurface) {
394
+ if (ctx.apiSurface && !isDirOwned) {
271
395
  const addBaselineExports = (items: Record<string, any> | undefined) => {
272
396
  if (!items) return;
273
397
  for (const item of Object.values(items)) {
@@ -288,7 +412,7 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
288
412
  // corresponding file still exists on disk. This prevents dropping
289
413
  // hand-written types (e.g., Factor in multi-factor-auth) when a
290
414
  // generated model in the same file causes a symbol collision.
291
- if (ctx.targetDir) {
415
+ if (ctx.targetDir && !isDirOwned) {
292
416
  const interfacesDir = path.join(ctx.targetDir, 'src', dirName, 'interfaces');
293
417
  try {
294
418
  const barrelPath = path.join(interfacesDir, 'index.ts');
@@ -349,6 +473,38 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
349
473
  }
350
474
  }
351
475
 
476
+ // For owned directories, scan the interfaces directory for hand-written
477
+ // files that still need to be in the barrel (e.g., options interfaces
478
+ // preserved from the baseline).
479
+ const ownedScanRoot = ctx.targetDir ?? ctx.outputDir;
480
+ if (ownedScanRoot && isDirOwned) {
481
+ const interfacesDir = path.join(ownedScanRoot, 'src', dirName, 'interfaces');
482
+ const symbols = dirSymbols.get(dirName) ?? new Set<string>();
483
+ try {
484
+ for (const entry of fs.readdirSync(interfacesDir)) {
485
+ if (entry === 'index.ts') continue;
486
+ if (!entry.endsWith('.ts')) continue;
487
+ const stem = entry.replace(/\.ts$/, '');
488
+ const exportLine = `export * from './${stem}';`;
489
+ if (exportSet.has(exportLine)) continue;
490
+ const content = fs.readFileSync(path.join(interfacesDir, entry), 'utf-8');
491
+ const exportedNames: string[] = [];
492
+ for (const m of content.matchAll(/export\s+(?:interface|type|enum|class|const|function)\s+(\w+)/g)) {
493
+ exportedNames.push(m[1]);
494
+ }
495
+ const hasCollision = exportedNames.some((name) => globalExistingSymbols.has(name));
496
+ if (hasCollision) continue;
497
+ for (const name of exportedNames) {
498
+ symbols.add(name);
499
+ globalExistingSymbols.add(name);
500
+ }
501
+ exportSet.add(exportLine);
502
+ }
503
+ } catch {
504
+ // Directory doesn't exist in target
505
+ }
506
+ }
507
+
352
508
  // Deduplicate and sort
353
509
  const uniqueExports = [...exportSet];
354
510
  uniqueExports.sort();
@@ -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
 
@@ -133,11 +135,14 @@ export function detectDiscriminatedShape(
133
135
 
134
136
  const baseFields = baseObject ? collectObjectFields(baseObject, modelName) : [];
135
137
 
138
+ const discriminatorDescription = flattenedVariants[0].alwaysProperties.get(discProp)?.description;
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
  }
@@ -451,6 +456,8 @@ function resolveRef(schema: RawSchema, rawSchemas: Record<string, RawSchema>): R
451
456
  export interface DiscriminatedPlan {
452
457
  shape: DiscriminatedShape;
453
458
  modelDir: string;
459
+ /** Maps raw spec schema names to their resolved service directories. */
460
+ depDirMap: Map<string, string>;
454
461
  }
455
462
 
456
463
  export function planDiscriminatedModels(models: Model[], ctx: EmitterContext): Map<string, DiscriminatedPlan> {
@@ -459,11 +466,47 @@ export function planDiscriminatedModels(models: Model[], ctx: EmitterContext): M
459
466
  if (!spec?.components?.schemas) return plans;
460
467
  const rawSchemas = spec.components.schemas as Record<string, RawSchema>;
461
468
  const { modelToService, resolveDir } = createServiceDirResolver(models, ctx.spec.services, ctx);
469
+
470
+ // Build a lookup from IR model names to their resolved service directories.
471
+ const irModelDir = new Map<string, string>();
472
+ for (const model of models) {
473
+ irModelDir.set(model.name, resolveDir(modelToService.get(model.name)));
474
+ }
475
+
476
+ // Map raw spec schema names to service directories so discriminated model
477
+ // imports can point to the correct cross-service path. Raw names may differ
478
+ // from IR names due to schemaNameTransform (e.g. Dto stripping).
479
+ const depDirMap = new Map<string, string>();
480
+ for (const rawName of Object.keys(rawSchemas)) {
481
+ if (irModelDir.has(rawName)) {
482
+ depDirMap.set(rawName, irModelDir.get(rawName)!);
483
+ continue;
484
+ }
485
+ const stripped = rawName.replace(/Dto/g, '').replace(/DTO/g, '').replace(/Json$/, '');
486
+ if (stripped !== rawName && irModelDir.has(stripped)) {
487
+ depDirMap.set(rawName, irModelDir.get(stripped)!);
488
+ }
489
+ }
490
+
462
491
  for (const model of models) {
463
492
  const shape = detectDiscriminatedShape(model.name, rawSchemas);
464
493
  if (!shape) continue;
494
+ // Skip models whose variant field dependencies can't all be resolved to
495
+ // existing interface files. EventSchema, for instance, references models
496
+ // from many services that may not have generated files yet.
497
+ const allDeps = new Set<string>();
498
+ for (const field of shape.baseFields) {
499
+ for (const d of field.modelDeps) allDeps.add(d);
500
+ }
501
+ for (const variant of shape.variants) {
502
+ for (const field of variant.fields) {
503
+ for (const d of field.modelDeps) allDeps.add(d);
504
+ }
505
+ }
506
+ const hasUnresolvableDeps = [...allDeps].some((dep) => !depDirMap.has(dep) && !irModelDir.has(dep));
507
+ if (hasUnresolvableDeps) continue;
465
508
  const modelDir = resolveDir(modelToService.get(model.name));
466
- plans.set(model.name, { shape, modelDir });
509
+ plans.set(model.name, { shape, modelDir, depDirMap });
467
510
  }
468
511
  return plans;
469
512
  }
@@ -521,12 +564,20 @@ function buildInterfaceFile(plan: DiscriminatedPlan, _ctx: EmitterContext): Gene
521
564
  function buildInterfaceBody(name: string, shape: DiscriminatedShape, variant: VariantSpec, isWire: boolean): string[] {
522
565
  const lines: string[] = [];
523
566
  lines.push(`export interface ${name} {`);
524
- // Base fields
567
+ // Variant fields override base fields when both define the same property
568
+ // (variants have narrower types, e.g. `event: 'foo'` vs base `event: string`).
569
+ // The discriminator is also emitted separately as a const literal below.
570
+ const variantFieldNames = new Set(variant.fields.map((f) => f.name));
525
571
  for (const field of shape.baseFields) {
572
+ if (variantFieldNames.has(field.name)) continue;
573
+ if (field.name === shape.discriminatorProperty) continue;
526
574
  pushFieldLine(lines, field, isWire);
527
575
  }
528
576
  // Discriminator (typed as the variant's const value)
529
577
  const discKey = isWire ? shape.discriminatorProperty : shape.discriminatorPropertyDomain;
578
+ if (shape.discriminatorDescription) {
579
+ lines.push(` /** ${shape.discriminatorDescription} */`);
580
+ }
530
581
  lines.push(` ${discKey}: '${variant.discriminatorValue}';`);
531
582
  // Variant-specific fields
532
583
  for (const field of variant.fields) {
@@ -561,31 +612,24 @@ function collectImports(plan: DiscriminatedPlan): ImportSpec[] {
561
612
  for (const d of field.modelDeps) deps.add(d);
562
613
  }
563
614
  }
564
- // Group by directory — all deps under the same modelDir get one import.
565
- // We assume all deps live in the same service for now (same dir as this
566
- // model). Cross-service imports would need ctx.spec.services lookups; the
567
- // current discriminated-shape cases (ConnectApplication) are all
568
- // intra-service.
569
- const symbols: string[] = [];
615
+ const result: ImportSpec[] = [];
570
616
  for (const dep of [...deps].sort()) {
571
617
  const domain = toPascalCase(dep);
572
- symbols.push(domain);
573
618
  const wire = wireInterfaceName(domain);
574
- if (wire !== domain) symbols.push(wire);
575
- }
576
- if (symbols.length === 0) return [];
577
- // Single import block from sibling files in the same interfaces directory.
578
- return symbols
579
- .map((sym) => {
580
- const fname = fileName(toSnakeFromPascal(sym.replace(/Response$/, '')));
581
- return { path: `./${fname}.interface`, symbols: [sym] };
582
- })
583
- .reduce((acc, cur) => {
584
- const existing = acc.find((a) => a.path === cur.path);
585
- if (existing) existing.symbols.push(...cur.symbols);
586
- else acc.push(cur);
587
- return acc;
588
- }, [] as ImportSpec[]);
619
+ const symbols = wire !== domain ? [domain, wire] : [domain];
620
+ const depDir = plan.depDirMap.get(dep);
621
+ const baseName = fileName(toSnakeFromPascal(domain));
622
+ let importPath: string;
623
+ if (!depDir || depDir === plan.modelDir) {
624
+ importPath = `./${baseName}.interface`;
625
+ } else {
626
+ importPath = `../../${depDir}/interfaces/${baseName}.interface`;
627
+ }
628
+ const existing = result.find((a) => a.path === importPath);
629
+ if (existing) existing.symbols.push(...symbols);
630
+ else result.push({ path: importPath, symbols });
631
+ }
632
+ return result;
589
633
  }
590
634
 
591
635
  function toSnakeFromPascal(s: string): string {