@workos/oagen-emitters 0.14.0 → 0.14.2

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.
@@ -73,6 +73,23 @@ export function isAdoptedModelName(name: string): boolean {
73
73
  return adoptedModelNames.has(name);
74
74
  }
75
75
 
76
+ /**
77
+ * Domain names that `resolveInterfaceName` reached via a structural rename
78
+ * — the resolved name differs from the IR model's own name. `wireInterfaceName`
79
+ * consults this set to decide whether to fire the "single-form wire" case:
80
+ * that case is *only* meant for structurally-renamed models, where the
81
+ * baseline owns a `*Response` interface representing the wire shape with no
82
+ * separate `*Wire` companion. Without this signal, a freshly-emitted model
83
+ * whose IR name already ends in `Response` (e.g. `CreateDataKeyResponse`)
84
+ * would land in the same case as soon as a prior buggy regen wrote the
85
+ * baseline — producing two `export interface CreateDataKeyResponse { ... }`
86
+ * declarations in the same file.
87
+ */
88
+ let structurallyRenamedDomainNames: Set<string> = new Set();
89
+ export function setStructurallyRenamedDomainNames(names: Set<string>): void {
90
+ structurallyRenamedDomainNames = names;
91
+ }
92
+
76
93
  /**
77
94
  * Wire/response interface name.
78
95
  *
@@ -97,7 +114,14 @@ export function wireInterfaceName(domainName: string): string {
97
114
  if (domainName.endsWith('Response')) {
98
115
  const wireForm = `${domainName}Wire`;
99
116
  if (baselineInterfaceNames.has(wireForm)) return wireForm;
100
- if (baselineInterfaceNames.has(domainName)) return domainName;
117
+ // Single-form case (#3 in the docstring): only fire when the resolver
118
+ // structurally renamed an IR model to this baseline name. Otherwise a
119
+ // fresh `CreateDataKeyResponse`-style IR model would collapse onto its
120
+ // own name as soon as one buggy regen wrote `CreateDataKeyResponse` to
121
+ // the baseline, perpetuating the duplicate-interface emission.
122
+ if (structurallyRenamedDomainNames.has(domainName) && baselineInterfaceNames.has(domainName)) {
123
+ return domainName;
124
+ }
101
125
  return wireForm;
102
126
  }
103
127
  return `${domainName}Response`;
@@ -698,7 +698,15 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
698
698
  ? `./interfaces/${fileName(name)}.interface`
699
699
  : `../${modelServiceDir}/interfaces/${fileName(name)}.interface`;
700
700
  if (usedWireTypes.has(resolved)) {
701
- lines.push(`import type { ${resolved}, ${wireInterfaceName(resolved)} } from '${relPath}';`);
701
+ const wireName = wireInterfaceName(resolved);
702
+ // When the wire name collapsed onto the domain name (single-form
703
+ // structural-rename emission), import once — otherwise we ship
704
+ // `import type { ReadObjectResponse, ReadObjectResponse }`.
705
+ if (wireName === resolved) {
706
+ lines.push(`import type { ${resolved} } from '${relPath}';`);
707
+ } else {
708
+ lines.push(`import type { ${resolved}, ${wireName} } from '${relPath}';`);
709
+ }
702
710
  } else {
703
711
  lines.push(`import type { ${resolved} } from '${relPath}';`);
704
712
  }
package/src/node/tests.ts CHANGED
@@ -682,7 +682,14 @@ function buildFieldAssertions(model: Model, accessor: string, modelMap?: Map<str
682
682
  const fieldAccessor = isDateTime ? `${accessor}.${domainField}.toISOString()` : `${accessor}.${domainField}`;
683
683
  // When a field has an example value, use it as the expected assertion value
684
684
  if (field.example !== undefined) {
685
- if (typeof field.example === 'object' && field.example !== null) {
685
+ // A null example on a nullable field must assert `toBeNull()` on the
686
+ // raw value — calling `.toISOString()` on null throws at runtime, and
687
+ // `.toBe(null)` against any non-null value never matches.
688
+ if (field.example === null) {
689
+ assertions.push(`expect(${accessor}.${domainField}).toBeNull();`);
690
+ continue;
691
+ }
692
+ if (typeof field.example === 'object') {
686
693
  // Objects and arrays need toEqual with JSON serialization
687
694
  assertions.push(`expect(${accessor}.${domainField}).toEqual(${JSON.stringify(field.example)});`);
688
695
  } else {
package/src/node/utils.ts CHANGED
@@ -277,7 +277,12 @@ export function isBaselineGeneric(fields: Record<string, unknown>, knownNames: S
277
277
  return false;
278
278
  }
279
279
 
280
- export { isListMetadataModel, isListWrapperModel } from '../shared/model-utils.js';
280
+ export {
281
+ isListMetadataModel,
282
+ isListWrapperModel,
283
+ collectNonPaginatedResponseModelNames,
284
+ collectReferencedListMetadataModels,
285
+ } from '../shared/model-utils.js';
281
286
 
282
287
  function modelFingerprint(model: Model): string {
283
288
  const fields = model.fields.map((f) => `${f.name}:${JSON.stringify(f.type)}:${f.required}`).sort();
package/src/php/models.ts CHANGED
@@ -4,7 +4,11 @@ import { className, enumClassName, fieldName } from './naming.js';
4
4
  import { phpDocComment } from './utils.js';
5
5
 
6
6
  // Import and re-export shared model detection utilities
7
- import { isListMetadataModel, isListWrapperModel } from '../shared/model-utils.js';
7
+ import {
8
+ isListMetadataModel,
9
+ isListWrapperModel,
10
+ collectNonPaginatedResponseModelNames,
11
+ } from '../shared/model-utils.js';
8
12
  export { isListMetadataModel, isListWrapperModel };
9
13
 
10
14
  /**
@@ -32,9 +36,14 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
32
36
  overwriteExisting: true,
33
37
  });
34
38
 
39
+ // Wrappers referenced as a non-paginated response (e.g. `VersionListResponse`
40
+ // for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
41
+ // code references them by name and the pagination iterator doesn't unwrap them.
42
+ const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
43
+
35
44
  for (const model of models) {
36
45
  if (isListMetadataModel(model)) continue;
37
- if (isListWrapperModel(model)) continue;
46
+ if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
38
47
  const name = className(model.name);
39
48
  const lines: string[] = [];
40
49
 
@@ -2,6 +2,7 @@ import type { Model, TypeRef, Enum } from '@workos/oagen';
2
2
 
3
3
  import { fileName, fieldName } from './naming.js';
4
4
  import { isListMetadataModel, isListWrapperModel } from './models.js';
5
+ import { collectNonPaginatedResponseModelNames, collectReferencedListMetadataModels } from '../shared/model-utils.js';
5
6
 
6
7
  /**
7
8
  * Prefix mapping for generating realistic ID fixture values.
@@ -36,9 +37,12 @@ export function generateFixtures(spec: {
36
37
  const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
37
38
  const files: { path: string; content: string }[] = [];
38
39
 
40
+ const nonPaginatedRefs = collectNonPaginatedResponseModelNames(spec.services);
41
+ const listMetadataNeeded = collectReferencedListMetadataModels(spec.models, nonPaginatedRefs);
42
+
39
43
  for (const model of spec.models) {
40
- if (isListMetadataModel(model)) continue;
41
- if (isListWrapperModel(model)) continue;
44
+ if (isListMetadataModel(model) && !listMetadataNeeded.has(model.name)) continue;
45
+ if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
42
46
  // Skip models with no fields — these are typically discriminated unions
43
47
  // with hand-maintained @oagen-ignore overrides; generated empty fixtures
44
48
  // would not match the override's required fields.
@@ -41,11 +41,21 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
41
41
  // oneOf enrichment collide with existing IR models in snake_case.
42
42
  const emittedFilePaths = new Set<string>();
43
43
 
44
+ // Wrappers referenced as a non-paginated operation response (e.g.
45
+ // `VersionListResponse` for `GET /vault/v1/kv/{id}/versions`) must still be
46
+ // emitted — the resource code references them by name and SyncPage doesn't
47
+ // wrap them.
48
+ const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
49
+ // ListMetadata-shape models referenced by a surviving non-paginated wrapper
50
+ // (e.g. vault's `VersionListResponse`) must still emit a dataclass —
51
+ // otherwise the wrapper's module imports a class that was never written.
52
+ const listMetadataNeeded = collectReferencedListMetadataModels(models, nonPaginatedRefs);
53
+
44
54
  for (const model of models) {
45
55
  // Skip list wrapper models (e.g., OrganizationList) — SyncPage handles envelopes
46
- if (isListWrapperModel(model)) continue;
56
+ if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
47
57
  // Skip all list metadata models (e.g., ListMetadata, FooListListMetadata)
48
- if (isListMetadataModel(model)) continue;
58
+ if (isListMetadataModel(model) && !listMetadataNeeded.has(model.name)) continue;
49
59
 
50
60
  const service = modelToService.get(model.name);
51
61
  const dirName = resolveDir(service);
@@ -865,5 +875,10 @@ function serializeField(ref: any, accessor: string): string {
865
875
  }
866
876
 
867
877
  // Import and re-export shared model detection utilities
868
- import { isListMetadataModel, isListWrapperModel } from '../shared/model-utils.js';
878
+ import {
879
+ isListMetadataModel,
880
+ isListWrapperModel,
881
+ collectNonPaginatedResponseModelNames,
882
+ collectReferencedListMetadataModels,
883
+ } from '../shared/model-utils.js';
869
884
  export { isListMetadataModel, isListWrapperModel };
@@ -1053,8 +1053,14 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
1053
1053
 
1054
1054
  for (const op of allOperations) {
1055
1055
  const plan = planOperation(op);
1056
- if (plan.responseModelName && !listWrapperNames.has(plan.responseModelName)) {
1057
- modelImports.add(plan.responseModelName);
1056
+ if (plan.responseModelName) {
1057
+ // List-wrapper responses are normally replaced by SyncPage on paginated
1058
+ // ops, so the wrapper itself is never referenced. On non-paginated ops
1059
+ // (e.g. `GET /vault/v1/kv/{id}/versions` → `VersionListResponse`) the
1060
+ // resource method still returns the wrapper by name and must import it.
1061
+ if (!listWrapperNames.has(plan.responseModelName) || !plan.isPaginated) {
1062
+ modelImports.add(plan.responseModelName);
1063
+ }
1058
1064
  }
1059
1065
  if (op.requestBody?.kind === 'model') {
1060
1066
  const requestBodyRef = op.requestBody;
@@ -22,6 +22,7 @@ import { resolveResourceClassName, bodyParamName } from './resources.js';
22
22
  import { buildServiceAccessPaths } from './client.js';
23
23
  import { generateFixtures, generateModelFixture } from './fixtures.js';
24
24
  import { isListWrapperModel, isListMetadataModel } from './models.js';
25
+ import { collectNonPaginatedResponseModelNames, collectReferencedListMetadataModels } from '../shared/model-utils.js';
25
26
  import {
26
27
  groupByMount,
27
28
  buildResolvedLookup,
@@ -1396,8 +1397,13 @@ function generateModelRoundTripTests(spec: ApiSpec, ctx: EmitterContext): Genera
1396
1397
  // A model is request-only if it's used as a request body but never as a response
1397
1398
  for (const name of responseModelNames) requestOnlyModelNames.delete(name);
1398
1399
 
1400
+ const nonPaginatedRefs = collectNonPaginatedResponseModelNames(spec.services);
1401
+ const listMetadataNeeded = collectReferencedListMetadataModels(spec.models, nonPaginatedRefs);
1399
1402
  const models = spec.models.filter(
1400
- (m) => !isListWrapperModel(m) && !isListMetadataModel(m) && !requestOnlyModelNames.has(m.name),
1403
+ (m) =>
1404
+ !(isListWrapperModel(m) && !nonPaginatedRefs.has(m.name)) &&
1405
+ !(isListMetadataModel(m) && !listMetadataNeeded.has(m.name)) &&
1406
+ !requestOnlyModelNames.has(m.name),
1401
1407
  );
1402
1408
  if (models.length === 0) return null;
1403
1409
 
package/src/ruby/index.ts CHANGED
@@ -36,8 +36,8 @@ function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
36
36
  * has its original fields restored — otherwise `ConnectApplication`-style
37
37
  * bases would silently lose every variant field they had previously.
38
38
  */
39
- function enrichModelsForRuby(models: Model[]): Model[] {
40
- const enriched = enrichModelsFromSpec(models);
39
+ function enrichModelsForRuby(models: Model[], enums: Enum[]): Model[] {
40
+ const enriched = enrichModelsFromSpec(models, enums);
41
41
  const originalByName = new Map(models.map((m) => [m.name, m]));
42
42
  return enriched.map((m) => {
43
43
  if ((m as { discriminator?: unknown }).discriminator && m.fields.length === 0) {
@@ -54,7 +54,7 @@ export const rubyEmitter: Emitter = {
54
54
  language: 'ruby',
55
55
 
56
56
  generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
57
- const modelFiles = generateModels(enrichModelsForRuby(models), ctx);
57
+ const modelFiles = generateModels(enrichModelsForRuby(models, ctx.spec.enums), ctx);
58
58
  return ensureTrailingNewlines(modelFiles);
59
59
  },
60
60
 
@@ -1,7 +1,11 @@
1
1
  import type { Model, EmitterContext, GeneratedFile, TypeRef, Field } from '@workos/oagen';
2
2
  import { walkTypeRef, assignModelsToServices } from '@workos/oagen';
3
3
  import { className, fieldName, fileName, buildMountDirMap } from './naming.js';
4
- import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
4
+ import {
5
+ isListWrapperModel,
6
+ isListMetadataModel,
7
+ collectNonPaginatedResponseModelNames,
8
+ } from '../shared/model-utils.js';
5
9
 
6
10
  /** Folder under lib/workos/ for models not owned by any service. */
7
11
  export const SHARED_MODEL_DIR = 'shared';
@@ -77,11 +81,17 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
77
81
 
78
82
  const files: GeneratedFile[] = [];
79
83
 
84
+ // Wrappers referenced as a non-paginated response (e.g. `VersionListResponse`
85
+ // for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
86
+ // code references them by name and the pagination iterator doesn't unwrap them.
87
+ const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
88
+ const skipAsListWrapper = (m: Model): boolean => isListWrapperModel(m) && !nonPaginatedRefs.has(m.name);
89
+
80
90
  // Dedup identical models (by recursive structural hash).
81
91
  const recursiveHashes = buildRecursiveHashMap(models, enumNames);
82
92
  const hashGroups = new Map<string, string[]>();
83
93
  for (const m of models) {
84
- if (isListWrapperModel(m) || isListMetadataModel(m)) continue;
94
+ if (skipAsListWrapper(m) || isListMetadataModel(m)) continue;
85
95
  const h = recursiveHashes.get(m.name) ?? '';
86
96
  if (!hashGroups.has(h)) hashGroups.set(h, []);
87
97
  hashGroups.get(h)!.push(m.name);
@@ -95,7 +105,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
95
105
  }
96
106
 
97
107
  for (const model of models) {
98
- if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
108
+ if (skipAsListWrapper(model) || isListMetadataModel(model)) continue;
99
109
 
100
110
  const cls = className(model.name);
101
111
  const file = fileName(model.name);
@@ -29,8 +29,10 @@ function mapSorbetType(ref: TypeRef): string {
29
29
  return `WorkOS::${className(ref.name)}`;
30
30
  case 'enum':
31
31
  return 'String';
32
- case 'nullable':
33
- return `T.nilable(${mapSorbetType(ref.inner)})`;
32
+ case 'nullable': {
33
+ const inner = mapSorbetType(ref.inner);
34
+ return inner === 'T.untyped' ? inner : `T.nilable(${inner})`;
35
+ }
34
36
  case 'literal':
35
37
  if (typeof ref.value === 'string') return 'String';
36
38
  if (ref.value === null) return 'NilClass';
package/src/ruby/rbi.ts CHANGED
@@ -316,6 +316,7 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
316
316
 
317
317
  /** Unwrap T.nilable(...) if already wrapped, to avoid double-wrapping. */
318
318
  function unwrapNilable(type: string): string {
319
+ if (type === 'T.untyped') return type;
319
320
  const match = type.match(/^T\.nilable\((.+)\)$/);
320
321
  return match ? match[1] : type;
321
322
  }
@@ -140,7 +140,11 @@ function renderField(field: Field, rustField: string, modelName: string, registr
140
140
  function renderModelsBarrel(modules: string[]): string {
141
141
  const sorted = [...new Set(modules)].sort();
142
142
  const lines: string[] = [];
143
- for (const m of sorted) lines.push(`pub mod ${m};`);
143
+ // Declare the modules privately so `pub use crate::models::*` in lib.rs only
144
+ // re-exports the struct names, not the module names themselves. Otherwise a
145
+ // module like `models::organization_membership` collides with the same-named
146
+ // `resources::organization_membership` when both barrels are glob-re-exported.
147
+ for (const m of sorted) lines.push(`mod ${m};`);
144
148
  lines.push('');
145
149
  for (const m of sorted) lines.push(`pub use ${m}::*;`);
146
150
  return lines.join('\n') + '\n';
@@ -1317,7 +1317,10 @@ function renderResourcesBarrel(exports: { module: string; struct: string }[]): s
1317
1317
  unique.sort((a, b) => a.module.localeCompare(b.module));
1318
1318
 
1319
1319
  const lines: string[] = [];
1320
- for (const { module } of unique) lines.push(`pub mod ${module};`);
1320
+ // Declare modules privately see the matching comment in `models.ts`.
1321
+ // `pub mod resources::organization_membership` would collide with the
1322
+ // same-named module re-exported via `pub use models::*` in lib.rs.
1323
+ for (const { module } of unique) lines.push(`mod ${module};`);
1321
1324
  lines.push('');
1322
1325
  for (const { module, struct } of unique) lines.push(`pub use ${module}::${struct};`);
1323
1326
  return lines.join('\n') + '\n';
@@ -1,10 +1,31 @@
1
- import type { Model, Field, TypeRef, Enum } from '@workos/oagen';
2
- import { toSnakeCase, toUpperSnakeCase } from '@workos/oagen';
1
+ import type { Model, Field, TypeRef, Enum, Service } from '@workos/oagen';
2
+ import { collectFieldDependencies, toSnakeCase, toUpperSnakeCase, walkTypeRef } from '@workos/oagen';
3
3
  import { readFileSync, existsSync } from 'node:fs';
4
4
  import { resolve } from 'node:path';
5
5
  // @ts-ignore -- js-yaml has no type declarations in this project
6
6
  import { load as yamlLoad } from 'js-yaml';
7
7
 
8
+ /**
9
+ * Collect model names referenced as the return type of any non-paginated
10
+ * operation. The list-wrapper skip rule below assumes a wrapper is always
11
+ * replaced by the SDK's pagination machinery — but a few endpoints
12
+ * (e.g. `GET /vault/v1/kv/{id}/versions`) have a list-envelope response
13
+ * shape with no pagination params, so the parser leaves them as a plain
14
+ * model reference. We must still emit those wrappers as regular models;
15
+ * otherwise the generated resource code references an undefined name.
16
+ */
17
+ export function collectNonPaginatedResponseModelNames(services: Service[]): Set<string> {
18
+ const names = new Set<string>();
19
+ for (const service of services) {
20
+ for (const op of service.operations) {
21
+ if (op.pagination) continue;
22
+ walkTypeRef(op.response, { model: (r) => names.add(r.name) });
23
+ for (const sr of op.successResponses ?? []) walkTypeRef(sr.type, { model: (r) => names.add(r.name) });
24
+ }
25
+ }
26
+ return names;
27
+ }
28
+
8
29
  /**
9
30
  * Detect whether a model is a list wrapper -- the standard paginated
10
31
  * list envelope with `data` (array), `list_metadata`, and optionally `object: 'list'`.
@@ -51,6 +72,41 @@ export function isListMetadataModel(model: Model): boolean {
51
72
  return isNullableString(before) && isNullableString(after);
52
73
  }
53
74
 
75
+ /**
76
+ * Compute the `ListMetadata`-shape model names that must still be emitted
77
+ * because some surviving wrapper references them.
78
+ *
79
+ * Each language emitter blanket-skips `isListMetadataModel` models on the
80
+ * assumption that the SDK's shared pagination wrapper subsumes them. That
81
+ * is correct for paginated list envelopes (the iterator unwraps the
82
+ * envelope and `list_metadata` is handled at runtime), but a *non-paginated*
83
+ * wrapper like vault's `VersionListResponse` still has a
84
+ * `list_metadata: ListMetadata` field — and skipping emission of
85
+ * `ListMetadata` leaves the wrapper's interface importing from a file that
86
+ * was never written.
87
+ *
88
+ * Pass the same `nonPaginatedRefs` set the emitter uses for its own
89
+ * wrapper-survival decision so the two answers stay in sync.
90
+ */
91
+ export function collectReferencedListMetadataModels(models: Model[], nonPaginatedRefs: Set<string>): Set<string> {
92
+ const listMetadataNames = new Set<string>();
93
+ for (const m of models) {
94
+ if (isListMetadataModel(m)) listMetadataNames.add(m.name);
95
+ }
96
+ if (listMetadataNames.size === 0) return new Set();
97
+
98
+ const referenced = new Set<string>();
99
+ for (const model of models) {
100
+ if (isListMetadataModel(model)) continue;
101
+ if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
102
+ const deps = collectFieldDependencies(model);
103
+ for (const dep of deps.models) {
104
+ if (listMetadataNames.has(dep)) referenced.add(dep);
105
+ }
106
+ }
107
+ return referenced;
108
+ }
109
+
54
110
  /** Check if a field type is nullable string (nullable<string> or just string). */
55
111
  function isNullableString(field: Field): boolean {
56
112
  if (field.type.kind === 'primitive' && field.type.type === 'string') return true;
@@ -503,7 +559,7 @@ export function detectDiscriminators(models: Model[]): Model[] {
503
559
  * Returns a new array of enriched models (original models are not mutated).
504
560
  * Synthetic enums are stored internally; retrieve them via `getSyntheticEnums()`.
505
561
  */
506
- export function enrichModelsFromSpec(models: Model[]): Model[] {
562
+ export function enrichModelsFromSpec(models: Model[], enums: Enum[] = []): Model[] {
507
563
  const spec = loadRawSpec();
508
564
  if (!spec) {
509
565
  _lastSyntheticEnums = [];
@@ -518,6 +574,17 @@ export function enrichModelsFromSpec(models: Model[]): Model[] {
518
574
  collector.usedNames.add(m.name);
519
575
  collector.usedNames.add(toSnakeCase(m.name));
520
576
  }
577
+ // Seed existing IR enum names too. The parser already emits inline enums
578
+ // like `DataIntegrationAccessTokenResponseError` (PascalCase); without this
579
+ // seed, the synthetic path would emit a sibling enum named
580
+ // `DataIntegrationAccessTokenResponse_error`, and language emitters that
581
+ // PascalCase-normalize identifiers (Ruby, Go, PHP, Python) would collapse
582
+ // both onto the same class/file path — the second overwrites the first
583
+ // with a `X = X` self-alias.
584
+ for (const e of enums) {
585
+ collector.usedNames.add(e.name);
586
+ collector.usedNames.add(toSnakeCase(e.name));
587
+ }
521
588
 
522
589
  const enriched = models.map((model) => {
523
590
  const rawSchema = lookupRawSchema(model.name);
@@ -1,7 +1,13 @@
1
1
  import { afterEach, describe, expect, it } from 'vitest';
2
2
  import type { EmitterContext } from '@workos/oagen';
3
3
  import { defaultSdkBehavior } from '@workos/oagen';
4
- import { resolveInterfaceName, setAdoptedModelNames } from '../../src/node/naming.js';
4
+ import {
5
+ resolveInterfaceName,
6
+ setAdoptedModelNames,
7
+ setBaselineInterfaceNames,
8
+ setStructurallyRenamedDomainNames,
9
+ wireInterfaceName,
10
+ } from '../../src/node/naming.js';
5
11
 
6
12
  const ctx: EmitterContext = {
7
13
  namespace: 'workos',
@@ -19,6 +25,8 @@ const ctx: EmitterContext = {
19
25
 
20
26
  afterEach(() => {
21
27
  setAdoptedModelNames(new Set());
28
+ setBaselineInterfaceNames(new Set());
29
+ setStructurallyRenamedDomainNames(new Set());
22
30
  });
23
31
 
24
32
  describe('resolveInterfaceName', () => {
@@ -75,3 +83,39 @@ describe('resolveInterfaceName', () => {
75
83
  expect(result).toBe('CreateM2MApplication');
76
84
  });
77
85
  });
86
+
87
+ describe('wireInterfaceName', () => {
88
+ it('emits *Wire for a fresh `*Response`-named IR model with an empty baseline', () => {
89
+ expect(wireInterfaceName('CreateDataKeyResponse')).toBe('CreateDataKeyResponseWire');
90
+ });
91
+
92
+ it('returns *Wire even when a prior buggy regen poisoned the baseline with the bare name', () => {
93
+ // Reproduces the vault regression: `CreateDataKeyResponse` is its own IR
94
+ // name (no structural rename), so the resolver does not flag it. A prior
95
+ // broken emission wrote `export interface CreateDataKeyResponse { ... }`
96
+ // twice into the file, and the baseline now contains the bare name.
97
+ // Without the rename signal, `wireInterfaceName` must still pick `*Wire`
98
+ // — otherwise the duplicate emission perpetuates across regens.
99
+ setBaselineInterfaceNames(new Set(['CreateDataKeyResponse']));
100
+ expect(wireInterfaceName('CreateDataKeyResponse')).toBe('CreateDataKeyResponseWire');
101
+ });
102
+
103
+ it('uses *Wire when baseline already has a separate `*Wire` companion', () => {
104
+ setBaselineInterfaceNames(new Set(['DecryptResponse', 'DecryptResponseWire']));
105
+ expect(wireInterfaceName('DecryptResponse')).toBe('DecryptResponseWire');
106
+ });
107
+
108
+ it('returns the bare name when a structural rename mapped a differently-named IR model onto a baseline `*Response`', () => {
109
+ // The legitimate single-form case the heuristic was designed for:
110
+ // `AuditLogSchemaJson` → `AuditLogSchemaResponse` via structural match,
111
+ // and the baseline owns just the one `AuditLogSchemaResponse` interface
112
+ // representing the wire shape.
113
+ setBaselineInterfaceNames(new Set(['AuditLogSchemaResponse']));
114
+ setStructurallyRenamedDomainNames(new Set(['AuditLogSchemaResponse']));
115
+ expect(wireInterfaceName('AuditLogSchemaResponse')).toBe('AuditLogSchemaResponse');
116
+ });
117
+
118
+ it('falls back to `${name}Response` for non-`*Response` IR names', () => {
119
+ expect(wireInterfaceName('Organization')).toBe('OrganizationResponse');
120
+ });
121
+ });
@@ -268,4 +268,73 @@ describe('node test generation ownership', () => {
268
268
  fs.rmSync(tmpRoot, { recursive: true, force: true });
269
269
  }
270
270
  });
271
+
272
+ it('asserts toBeNull() for nullable fields whose example is null', () => {
273
+ // When the spec gives `example: null` on a nullable field — common for
274
+ // optional date-times like `last_used_at` — the previous emitter would
275
+ // emit `expect(x.toISOString()).toBe(null)`, which both blows up at
276
+ // runtime on a null `x` and never matches when `x` is a Date.
277
+ const secretModel: Model = {
278
+ name: 'Secret',
279
+ fields: [
280
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true, example: 'sec_1' },
281
+ {
282
+ name: 'last_used_at',
283
+ type: {
284
+ kind: 'nullable',
285
+ inner: { kind: 'primitive', type: 'string', format: 'date-time' },
286
+ },
287
+ required: true,
288
+ example: null,
289
+ },
290
+ {
291
+ name: 'created_at',
292
+ type: { kind: 'primitive', type: 'string', format: 'date-time' },
293
+ required: true,
294
+ example: '2026-01-15T12:00:00.000Z',
295
+ },
296
+ ],
297
+ };
298
+
299
+ const showOp = {
300
+ name: 'showSecret',
301
+ httpMethod: 'get' as const,
302
+ path: '/secrets/{id}',
303
+ pathParams: [{ name: 'id', type: { kind: 'primitive' as const, type: 'string' as const }, required: true }],
304
+ queryParams: [],
305
+ headerParams: [],
306
+ response: { kind: 'model' as const, name: 'Secret' },
307
+ errors: [],
308
+ injectIdempotencyKey: false,
309
+ };
310
+ const secretService: Service = { name: 'Secrets', operations: [showOp] };
311
+ const secretSpec: ApiSpec = {
312
+ ...spec,
313
+ models: [secretModel],
314
+ services: [secretService],
315
+ };
316
+ const tmpRoot = createTrackedSdkRoot();
317
+ try {
318
+ fs.mkdirSync(path.join(tmpRoot, 'src', 'secrets', 'fixtures'), { recursive: true });
319
+ execFileSync('git', ['add', 'src'], { cwd: tmpRoot, stdio: 'ignore' });
320
+
321
+ const result = nodeEmitter.generateTests!(secretSpec, {
322
+ ...ctx,
323
+ spec: secretSpec,
324
+ outputDir: tmpRoot,
325
+ emitterOptions: { ownedServices: ['Secrets'], regenerateOwnedTests: true },
326
+ } as EmitterContext);
327
+
328
+ const testFile = result.find((f) => f.path === 'src/secrets/secrets.spec.ts');
329
+ expect(testFile).toBeDefined();
330
+ const content = testFile!.content;
331
+ // Null example on a nullable date-time → toBeNull(), not `.toISOString().toBe(null)`.
332
+ expect(content).toContain('expect(result.lastUsedAt).toBeNull();');
333
+ expect(content).not.toContain('lastUsedAt.toISOString()).toBe(null)');
334
+ // Non-null date-time examples still go through `.toISOString()`.
335
+ expect(content).toContain("expect(result.createdAt.toISOString()).toBe('2026-01-15T12:00:00.000Z');");
336
+ } finally {
337
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
338
+ }
339
+ });
271
340
  });
@@ -110,7 +110,7 @@ describe('rust/models', () => {
110
110
  const event = files.find((f) => f.path === 'src/models/event.rs')!;
111
111
  expect(event.content).toContain('pub payload: EventPayloadOneOf,');
112
112
  const barrel = files.find((f) => f.path === 'src/models/mod.rs')!;
113
- expect(barrel.content).toContain('pub mod _unions;');
113
+ expect(barrel.content).toContain('mod _unions;');
114
114
  // The _unions.rs file is rendered by generateClient (the final structural
115
115
  // pass) so resource-side body unions can join the same registry.
116
116
  const clientFiles = generateClient(emptySpec, ctx, registry);
@@ -170,8 +170,8 @@ describe('rust/models', () => {
170
170
  ];
171
171
  const files = generateModels(models, ctx, new UnionRegistry());
172
172
  const barrel = files.find((f) => f.path === 'src/models/mod.rs')!;
173
- expect(barrel.content).toContain('pub mod alpha;');
174
- expect(barrel.content).toContain('pub mod beta;');
173
+ expect(barrel.content).toContain('mod alpha;');
174
+ expect(barrel.content).toContain('mod beta;');
175
175
  expect(barrel.content).toContain('pub use alpha::*;');
176
176
  });
177
177
  });