@workos/oagen-emitters 0.18.3 → 0.19.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 (67) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +16 -0
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/dist/{plugin-1ckLMpgo.mjs → plugin-BXDPA9pJ.mjs} +581 -172
  6. package/dist/plugin-BXDPA9pJ.mjs.map +1 -0
  7. package/dist/plugin.mjs +1 -1
  8. package/docs/sdk-architecture/rust.md +2 -2
  9. package/package.json +5 -5
  10. package/src/dotnet/enums.ts +11 -5
  11. package/src/dotnet/fixtures.ts +5 -2
  12. package/src/dotnet/index.ts +2 -1
  13. package/src/dotnet/models.ts +41 -10
  14. package/src/dotnet/naming.ts +10 -0
  15. package/src/dotnet/resources.ts +3 -3
  16. package/src/dotnet/tests.ts +8 -4
  17. package/src/go/fixtures.ts +4 -2
  18. package/src/go/index.ts +4 -0
  19. package/src/go/models.ts +4 -2
  20. package/src/go/naming.ts +10 -0
  21. package/src/go/resources.ts +22 -9
  22. package/src/go/tests.ts +3 -3
  23. package/src/kotlin/enums.ts +21 -11
  24. package/src/kotlin/index.ts +2 -1
  25. package/src/kotlin/models.ts +24 -9
  26. package/src/kotlin/naming.ts +11 -0
  27. package/src/kotlin/resources.ts +2 -2
  28. package/src/kotlin/tests.ts +7 -3
  29. package/src/node/enums.ts +8 -5
  30. package/src/node/field-plan.ts +3 -3
  31. package/src/node/index.ts +2 -1
  32. package/src/node/models.ts +69 -22
  33. package/src/node/naming.ts +10 -0
  34. package/src/node/options.ts +45 -1
  35. package/src/node/resources.ts +67 -18
  36. package/src/node/tests.ts +302 -31
  37. package/src/php/enums.ts +18 -5
  38. package/src/php/index.ts +13 -4
  39. package/src/php/models.ts +22 -10
  40. package/src/php/naming.ts +10 -0
  41. package/src/php/resources.ts +6 -4
  42. package/src/php/tests.ts +17 -5
  43. package/src/python/enums.ts +39 -28
  44. package/src/python/fixtures.ts +4 -3
  45. package/src/python/index.ts +2 -1
  46. package/src/python/models.ts +39 -24
  47. package/src/python/naming.ts +10 -0
  48. package/src/python/resources.ts +3 -3
  49. package/src/python/tests.ts +14 -9
  50. package/src/ruby/enums.ts +28 -19
  51. package/src/ruby/index.ts +2 -1
  52. package/src/ruby/models.ts +33 -19
  53. package/src/ruby/naming.ts +10 -0
  54. package/src/ruby/rbi.ts +20 -7
  55. package/src/ruby/resources.ts +2 -2
  56. package/src/ruby/tests.ts +6 -3
  57. package/src/rust/enums.ts +9 -1
  58. package/src/rust/index.ts +2 -1
  59. package/src/rust/models.ts +100 -15
  60. package/src/rust/naming.ts +10 -0
  61. package/src/rust/resources.ts +14 -3
  62. package/src/rust/tests.ts +2 -2
  63. package/src/shared/file-header.ts +13 -0
  64. package/src/shared/resolved-ops.ts +47 -0
  65. package/test/rust/models.test.ts +49 -0
  66. package/test/shared/synthetic-enum-seed.test.ts +79 -0
  67. package/dist/plugin-1ckLMpgo.mjs.map +0 -1
package/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as workosEmittersPlugin } from "./plugin-1ckLMpgo.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-BXDPA9pJ.mjs";
2
2
  export { workosEmittersPlugin };
@@ -316,8 +316,8 @@ surface that the `rust` extractor reads.
316
316
  Every generated file begins with:
317
317
 
318
318
  ```rust
319
- // Code generated by oagen. DO NOT EDIT.
319
+ // This file is auto-generated by oagen. Do not edit.
320
320
  ```
321
321
 
322
- `Cargo.toml` uses the TOML-style equivalent (`# Code generated by oagen. DO NOT EDIT.`)
322
+ `Cargo.toml` uses the TOML-style equivalent (`# This file is auto-generated by oagen. Do not edit.`)
323
323
  and JSON fixtures skip the header (`headerPlacement: 'skip'`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/oagen-emitters",
3
- "version": "0.18.3",
3
+ "version": "0.19.0",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -40,10 +40,10 @@
40
40
  "devDependencies": {
41
41
  "@commitlint/cli": "^21.0.2",
42
42
  "@commitlint/config-conventional": "^21.0.2",
43
- "@types/node": "^25.9.3",
43
+ "@types/node": "^26.0.0",
44
44
  "husky": "^9.1.7",
45
- "oxfmt": "^0.55.0",
46
- "oxlint": "^1.70.0",
45
+ "oxfmt": "^0.56.0",
46
+ "oxlint": "^1.71.0",
47
47
  "prettier": "^3.8.4",
48
48
  "tsdown": "^0.22.3",
49
49
  "tsx": "^4.22.4",
@@ -54,6 +54,6 @@
54
54
  "node": ">=24.10.0"
55
55
  },
56
56
  "dependencies": {
57
- "@workos/oagen": "^0.22.6"
57
+ "@workos/oagen": "^0.23.0"
58
58
  }
59
59
  }
@@ -3,6 +3,7 @@ import { walkTypeRef } from '@workos/oagen';
3
3
  import { className, deprecationMessage, escapeCsAttributeString, humanize } from './naming.js';
4
4
  import { setEnumAliases, setSingleValueEnumNames } from './type-map.js';
5
5
  import { enrichModelsFromSpec } from '../shared/model-utils.js';
6
+ import { isEnumInScope } from '../shared/resolved-ops.js';
6
7
 
7
8
  /**
8
9
  * Generate C# enum definitions from IR Enum definitions.
@@ -135,11 +136,16 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
135
136
  lines.push(' }');
136
137
  lines.push('}');
137
138
 
138
- files.push({
139
- path: `Enums/${typeName}.cs`,
140
- content: lines.join('\n'),
141
- overwriteExisting: true,
142
- });
139
+ // FR-1.4: write the per-enum FILE only when in scope. .NET uses a flat
140
+ // Enums/ directory with C# namespaces (no barrel/index), so an
141
+ // out-of-scope enum left untouched on disk stays referenceable.
142
+ if (isEnumInScope(enumDef.name, ctx)) {
143
+ files.push({
144
+ path: `Enums/${typeName}.cs`,
145
+ content: lines.join('\n'),
146
+ overwriteExisting: true,
147
+ });
148
+ }
143
149
  }
144
150
 
145
151
  return files;
@@ -1,5 +1,5 @@
1
1
  import type { Model, TypeRef, Enum } from '@workos/oagen';
2
- import { fixtureFileName, fieldName } from './naming.js';
2
+ import { fixtureFileName, domainFieldName } from './naming.js';
3
3
  import { isListMetadataModel, isListWrapperModel } from './models.js';
4
4
  import { collectNonPaginatedResponseModelNames } from '../shared/model-utils.js';
5
5
 
@@ -155,7 +155,10 @@ export function generateModelFixture(
155
155
 
156
156
  const seenFieldNames = new Set<string>();
157
157
  const deduplicatedFields = model.fields.filter((f) => {
158
- const csName = fieldName(f.name);
158
+ // Dedup on the DOMAIN identifier (the C# property name, honoring a
159
+ // `domainName` override) to mirror the dedup in models.ts. The fixture
160
+ // payload below still keys on the wire name (`field.name`).
161
+ const csName = domainFieldName(f);
159
162
  if (seenFieldNames.has(csName)) return false;
160
163
  seenFieldNames.add(csName);
161
164
  return true;
@@ -20,6 +20,7 @@ import { generateTests } from './tests.js';
20
20
  import { buildOperationsMap } from './manifest.js';
21
21
  import { generateWrapperOptionsClasses } from './wrappers.js';
22
22
  import { groupByMount } from '../shared/resolved-ops.js';
23
+ import { AUTOGEN_NOTICE } from '../shared/file-header.js';
23
24
  import { discriminatedUnions, resolveModelName } from './type-map.js';
24
25
  import { modelClassName } from './naming.js';
25
26
 
@@ -315,7 +316,7 @@ export const dotnetEmitter: Emitter = {
315
316
  },
316
317
 
317
318
  fileHeader(): string {
318
- return '// This file is auto-generated by oagen. Do not edit.';
319
+ return `// ${AUTOGEN_NOTICE}`;
319
320
  },
320
321
 
321
322
  formatCommand(targetDir: string): FormatCommand | null {
@@ -12,6 +12,7 @@ import {
12
12
  import {
13
13
  articleFor,
14
14
  fieldName,
15
+ domainFieldName,
15
16
  humanize,
16
17
  emitXmlDoc,
17
18
  deprecationMessage,
@@ -25,6 +26,7 @@ import {
25
26
  isListMetadataModel,
26
27
  collectNonPaginatedResponseModelNames,
27
28
  } from '../shared/model-utils.js';
29
+ import { isModelInScope } from '../shared/resolved-ops.js';
28
30
  export { isListWrapperModel, isListMetadataModel };
29
31
 
30
32
  /**
@@ -93,7 +95,10 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
93
95
  const baseClassName = modelClassName(model.name);
94
96
  const fieldMap = new Map<string, string>();
95
97
  for (const field of model.fields) {
96
- let csName = fieldName(field.name);
98
+ // DOMAIN identifier: the C# property name used for inheritance
99
+ // comparison (honors a `domainName` override). Must match the
100
+ // property name emitted below so variant fields dedup correctly.
101
+ let csName = domainFieldName(field);
97
102
  if (csName === baseClassName) csName = `${csName}Value`;
98
103
  fieldMap.set(csName, mapTypeRef(field.type));
99
104
  }
@@ -119,8 +124,16 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
119
124
  // Required enums need JsonProperty / STJS; a field whose PascalCase name
120
125
  // collides with the enclosing class needs the same imports for the wire-
121
126
  // name override emitted below.
122
- const hasClassNameCollision = model.fields.some((f) => fieldName(f.name) === csClassName);
123
- const needsJsonAttrs = hasClassNameCollision || model.fields.some((f) => f.required && isEnumRef(f.type));
127
+ // DOMAIN identifier: the emitted C# property name (honors `domainName`)
128
+ // is what can collide with the enclosing class name.
129
+ const hasClassNameCollision = model.fields.some((f) => domainFieldName(f) === csClassName);
130
+ // A `domainName` override renames the C# property away from the wire key
131
+ // (e.g. wire `connection_type` surfaced as domain `Type`). The
132
+ // SnakeCaseLower naming policy would otherwise serialize the domain name,
133
+ // so these fields need an explicit pinned wire name (and thus the imports).
134
+ const hasDomainRename = model.fields.some((f) => domainFieldName(f) !== fieldName(f.name));
135
+ const needsJsonAttrs =
136
+ hasClassNameCollision || hasDomainRename || model.fields.some((f) => f.required && isEnumRef(f.type));
124
137
 
125
138
  lines.push(`namespace ${ctx.namespacePascal}`);
126
139
  lines.push('{');
@@ -175,9 +188,16 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
175
188
  // when that happens. Track the rename so we emit an explicit
176
189
  // `[JsonProperty]` attribute below — the SnakeCaseLower naming policy
177
190
  // would otherwise serialize `ErrorValue` as `error_value`, not `error`.
178
- let csFieldName = fieldName(field.name);
191
+ // DOMAIN identifier: the C# property name, honoring a `domainName`
192
+ // override (e.g. wire `connection_type` → domain `Type`). The wire key
193
+ // passed to `emitJsonPropertyAttributes` below still derives from
194
+ // `field.name`.
195
+ let csFieldName = domainFieldName(field);
179
196
  const collidesWithClassName = csFieldName === csClassName;
180
197
  if (collidesWithClassName) csFieldName = `${csFieldName}Value`;
198
+ // When the domain rename diverges from the wire key, the SnakeCaseLower
199
+ // naming policy can't recover the wire name from the property — pin it.
200
+ const hasDomainOverride = domainFieldName(field) !== fieldName(field.name);
181
201
  if (seenFieldNames.has(csFieldName)) continue;
182
202
  seenFieldNames.add(csFieldName);
183
203
 
@@ -257,8 +277,14 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
257
277
  }
258
278
 
259
279
  const isRequiredEnum = field.required && isEnumRef(field.type) && constInit === null;
280
+ // WIRE key: always derives from `field.name`. Pin it explicitly when the
281
+ // C# property name (collision suffix or `domainName` override) no longer
282
+ // round-trips to the wire name via the SnakeCaseLower naming policy.
260
283
  lines.push(
261
- ...emitJsonPropertyAttributes(field.name, { isRequiredEnum, explicitWireName: collidesWithClassName }),
284
+ ...emitJsonPropertyAttributes(field.name, {
285
+ isRequiredEnum,
286
+ explicitWireName: collidesWithClassName || hasDomainOverride,
287
+ }),
262
288
  );
263
289
  // Discriminated-union-typed field: attach the variant-dispatching converter
264
290
  // so Newtonsoft picks the right subtype on deserialization. The converter
@@ -330,11 +356,16 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
330
356
  lines.push(' }');
331
357
  lines.push('}');
332
358
 
333
- files.push({
334
- path: `Entities/${csClassName}.cs`,
335
- content: lines.join('\n'),
336
- overwriteExisting: true,
337
- });
359
+ // FR-1.4: write the per-model FILE only when in scope. .NET uses a flat
360
+ // Entities/ directory with C# namespaces (no barrel/index), so an
361
+ // out-of-scope model left untouched on disk stays referenceable.
362
+ if (isModelInScope(model.name, ctx)) {
363
+ files.push({
364
+ path: `Entities/${csClassName}.cs`,
365
+ content: lines.join('\n'),
366
+ overwriteExisting: true,
367
+ });
368
+ }
338
369
  }
339
370
 
340
371
  return files;
@@ -39,6 +39,16 @@ export function fieldName(name: string): string {
39
39
  return toPascalCase(name);
40
40
  }
41
41
 
42
+ /**
43
+ * PascalCase domain property name for a model field, honoring a `domainName`
44
+ * override (set via the `fieldHints` config) so a wire field can surface under
45
+ * a friendlier C# property name. The wire/serialization key (the
46
+ * `[JsonPropertyName("...")]` value) still derives from `field.name`.
47
+ */
48
+ export function domainFieldName(field: { name: string; domainName?: string }): string {
49
+ return toPascalCase(field.domainName ?? field.name);
50
+ }
51
+
42
52
  /** PascalCase directory name for service modules. */
43
53
  export function moduleName(name: string): string {
44
54
  return toPascalCase(name);
@@ -35,7 +35,7 @@ import {
35
35
  import {
36
36
  buildResolvedLookup,
37
37
  lookupResolved,
38
- groupByMount,
38
+ scopedMountGroups,
39
39
  getOpDefaults,
40
40
  getOpInferFromClient,
41
41
  buildHiddenParams,
@@ -71,10 +71,10 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
71
71
  if (services.length === 0) return [];
72
72
 
73
73
  const files: GeneratedFile[] = [];
74
- const mountGroups = groupByMount(ctx);
74
+ const mountGroups = scopedMountGroups(ctx);
75
75
 
76
76
  const entries: Array<{ name: string; operations: Operation[] }> =
77
- mountGroups.size > 0
77
+ mountGroups.size > 0 || ctx.scopedServices?.size
78
78
  ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
79
79
  : services.map((s) => ({ name: resolveResourceClassName(s, ctx), operations: s.operations }));
80
80
 
@@ -3,6 +3,7 @@ import { planOperation } from '@workos/oagen';
3
3
  import {
4
4
  fixtureFileName,
5
5
  fieldName as csFieldName,
6
+ domainFieldName as csDomainFieldName,
6
7
  methodName as csMethodName,
7
8
  appendAsyncSuffix,
8
9
  modelClassName,
@@ -15,7 +16,7 @@ import { resolveResourceClassName, sortPathParamsByTemplateOrder, optionsClassNa
15
16
  import { generateFixtures, generateModelFixture } from './fixtures.js';
16
17
  import { isListWrapperModel } from './models.js';
17
18
  import {
18
- groupByMount,
19
+ scopedMountGroups,
19
20
  buildResolvedLookup,
20
21
  lookupResolved,
21
22
  buildHiddenParams,
@@ -39,9 +40,9 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
39
40
  }
40
41
 
41
42
  // Generate per-mount-target test files
42
- const mountGroups = groupByMount(ctx);
43
+ const mountGroups = scopedMountGroups(ctx);
43
44
  const testEntries: Array<{ name: string; operations: Operation[] }> =
44
- mountGroups.size > 0
45
+ mountGroups.size > 0 || ctx.scopedServices?.size
45
46
  ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
46
47
  : spec.services.map((s) => ({
47
48
  name: resolveResourceClassName(s, ctx),
@@ -694,7 +695,10 @@ function buildFixtureAssertions(model: import('@workos/oagen').Model, spec: ApiS
694
695
  if (field.type.kind !== 'primitive' || field.type.type !== 'string') continue;
695
696
  if (field.type.format === 'date-time' || field.type.format === 'date') continue;
696
697
  if (field.type.format === 'binary') continue;
697
- const csField = csFieldName(field.name);
698
+ // DOMAIN identifier: the C# property accessed on the deserialized model
699
+ // (honors a `domainName` override). The fixture lookup below uses the wire
700
+ // key (`field.name`).
701
+ const csField = csDomainFieldName(field);
698
702
  const val = fixture[field.name];
699
703
  if (typeof val === 'string' && val.length > 0) {
700
704
  assertions.push(`Assert.Equal(${csStringLiteral(val)}, result.${csField});`);
@@ -1,5 +1,5 @@
1
1
  import type { Model, TypeRef, Enum } from '@workos/oagen';
2
- import { fileName, fieldName } from './naming.js';
2
+ import { fileName, domainFieldName } from './naming.js';
3
3
  import { isListMetadataModel, isListWrapperModel } from './models.js';
4
4
  import { collectNonPaginatedResponseModelNames, collectReferencedListMetadataModels } from '../shared/model-utils.js';
5
5
 
@@ -131,7 +131,9 @@ export function generateModelFixture(
131
131
 
132
132
  const seenFieldNames = new Set<string>();
133
133
  const deduplicatedFields = model.fields.filter((f) => {
134
- const goName = fieldName(f.name);
134
+ // Dedup by the domain Go field name to mirror the struct in models.ts; the
135
+ // fixture key itself (wireName below) still derives from field.name.
136
+ const goName = domainFieldName(f);
135
137
  if (seenFieldNames.has(goName)) return false;
136
138
  seenFieldNames.add(goName);
137
139
  return true;
package/src/go/index.ts CHANGED
@@ -88,6 +88,10 @@ export const goEmitter: Emitter = {
88
88
  },
89
89
 
90
90
  fileHeader(): string {
91
+ // Go-specific: this exact form matches the standard generated-file regex
92
+ // (`^// Code generated .* DO NOT EDIT\.$`) that gofmt, gopls, golangci-lint,
93
+ // and other Go tooling use to classify a file as generated. It intentionally
94
+ // does NOT use the shared AUTOGEN_NOTICE, which would break that detection.
91
95
  return '// Code generated by oagen. DO NOT EDIT.';
92
96
  },
93
97
 
package/src/go/models.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Model, EmitterContext, GeneratedFile, TypeRef, Service } from '@workos/oagen';
2
2
  import { walkTypeRef } from '@workos/oagen';
3
3
  import { mapTypeRef } from './type-map.js';
4
- import { className, fieldName } from './naming.js';
4
+ import { className, domainFieldName } from './naming.js';
5
5
  import { lowerFirstForDoc, fieldDocComment, articleFor } from '../shared/naming-utils.js';
6
6
 
7
7
  // Import and re-export shared model detection utilities
@@ -185,7 +185,9 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
185
185
  // Deduplicate fields by Go field name
186
186
  const seenFieldNames = new Set<string>();
187
187
  for (const field of model.fields) {
188
- const goFieldName = fieldName(field.name);
188
+ // Domain identifier honors a `fieldHints` override (e.g. connection_type
189
+ // → type); the json struct tag below still derives from `field.name`.
190
+ const goFieldName = domainFieldName(field);
189
191
  if (seenFieldNames.has(goFieldName)) continue;
190
192
  seenFieldNames.add(goFieldName);
191
193
 
package/src/go/naming.ts CHANGED
@@ -61,6 +61,16 @@ export function fieldName(name: string): string {
61
61
  return applyAcronyms(toPascalCase(name));
62
62
  }
63
63
 
64
+ /**
65
+ * PascalCase domain field name for a model field, honoring a `domainName`
66
+ * override (set via the `fieldHints` config) so a wire field can surface under
67
+ * a friendlier name. The wire name (the `json:"..."` struct tag) still derives
68
+ * from `field.name`.
69
+ */
70
+ export function domainFieldName(field: { name: string; domainName?: string }): string {
71
+ return applyAcronyms(toPascalCase(field.domainName ?? field.name));
72
+ }
73
+
64
74
  /** snake_case module/directory name. */
65
75
  export function moduleName(name: string): string {
66
76
  return toSnakeCase(name);
@@ -9,11 +9,19 @@ import type {
9
9
  import { planOperation, toSnakeCase } from '@workos/oagen';
10
10
  import { isListWrapperModel } from './models.js';
11
11
  import { mapTypeRef, mapTypeRefValue } from './type-map.js';
12
- import { className, fieldName, methodName, resolveClassName, resolveMethodName, unexportedName } from './naming.js';
12
+ import {
13
+ className,
14
+ domainFieldName,
15
+ fieldName,
16
+ methodName,
17
+ resolveClassName,
18
+ resolveMethodName,
19
+ unexportedName,
20
+ } from './naming.js';
13
21
  import {
14
22
  buildResolvedLookup,
15
23
  lookupResolved,
16
- groupByMount,
24
+ scopedMountGroups,
17
25
  getOpDefaults,
18
26
  getOpInferFromClient,
19
27
  buildHiddenParams,
@@ -52,11 +60,11 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
52
60
  if (services.length === 0) return [];
53
61
 
54
62
  const files: GeneratedFile[] = [];
55
- const mountGroups = groupByMount(ctx);
63
+ const mountGroups = scopedMountGroups(ctx);
56
64
 
57
65
  // If no resolved operations, fall back to raw services
58
66
  const entries: Array<{ name: string; operations: Operation[] }> =
59
- mountGroups.size > 0
67
+ mountGroups.size > 0 || ctx.scopedServices?.size
60
68
  ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
61
69
  : services.map((s) => ({ name: resolveResourceClassName(s, ctx), operations: s.operations }));
62
70
 
@@ -404,7 +412,8 @@ function generateParamsStruct(
404
412
  for (const field of bodyModel.fields) {
405
413
  if (hidden.has(field.name)) continue;
406
414
  if (groupedParams.has(field.name)) continue;
407
- const goField = fieldName(field.name);
415
+ // Domain struct field; the json tag below keeps deriving from field.name.
416
+ const goField = domainFieldName(field);
408
417
  if (emittedFields.has(goField)) continue;
409
418
  emittedFields.add(goField);
410
419
  const isOptional = !field.required;
@@ -942,7 +951,8 @@ function emitHiddenParamsBodyStruct(
942
951
  if (hidden.has(field.name)) continue;
943
952
  if (groupedParamNames.has(field.name)) continue;
944
953
  if (!field.required) continue;
945
- const goField = fieldName(field.name);
954
+ // Domain struct field; the json tag below keeps deriving from field.name.
955
+ const goField = domainFieldName(field);
946
956
  const goType = mapTypeRef(field.type);
947
957
  lines.push(`\t${goField} ${goType} \`json:"${field.name}"\``);
948
958
  }
@@ -960,7 +970,8 @@ function emitHiddenParamsBodyStruct(
960
970
  if (hidden.has(field.name)) continue;
961
971
  if (groupedParamNames.has(field.name)) continue;
962
972
  if (field.required) continue;
963
- const goField = fieldName(field.name);
973
+ // Domain struct field; the json tag below keeps deriving from field.name.
974
+ const goField = domainFieldName(field);
964
975
  const goType = makeOptional(mapTypeRef(field.type));
965
976
  lines.push(`\t${goField} ${goType} \`json:"${field.name},omitempty"\``);
966
977
  }
@@ -1007,7 +1018,8 @@ function emitBodyWithHiddenParams(
1007
1018
  for (const field of bodyModel.fields) {
1008
1019
  if (hidden.has(field.name)) continue;
1009
1020
  if (!field.required) continue;
1010
- const goField = fieldName(field.name);
1021
+ // Domain struct field on both the body literal and the params struct.
1022
+ const goField = domainFieldName(field);
1011
1023
  lines.push(`\t\t${goField}: params.${goField},`);
1012
1024
  }
1013
1025
  }
@@ -1025,7 +1037,8 @@ function emitBodyWithHiddenParams(
1025
1037
  for (const field of bodyModel.fields) {
1026
1038
  if (hidden.has(field.name)) continue;
1027
1039
  if (field.required) continue;
1028
- const goField = fieldName(field.name);
1040
+ // Domain struct field on both the body struct and the params struct.
1041
+ const goField = domainFieldName(field);
1029
1042
  lines.push(`\tbody.${goField} = params.${goField}`);
1030
1043
  }
1031
1044
  }
package/src/go/tests.ts CHANGED
@@ -5,7 +5,7 @@ import { resolveResourceClassName, paramsStructName, sortPathParamsByTemplateOrd
5
5
  import { buildServiceAccessPaths } from './client.js';
6
6
  import { generateFixtures } from './fixtures.js';
7
7
  import { isListWrapperModel } from './models.js';
8
- import { groupByMount, buildResolvedLookup, lookupResolved, buildHiddenParams } from '../shared/resolved-ops.js';
8
+ import { scopedMountGroups, buildResolvedLookup, lookupResolved, buildHiddenParams } from '../shared/resolved-ops.js';
9
9
  import { existsSync, readFileSync } from 'node:fs';
10
10
  import { resolve } from 'node:path';
11
11
 
@@ -85,9 +85,9 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
85
85
  const accessPaths = buildServiceAccessPaths(spec.services, ctx);
86
86
 
87
87
  // Generate per-mount-target test files
88
- const mountGroups = groupByMount(ctx);
88
+ const mountGroups = scopedMountGroups(ctx);
89
89
  const testEntries: Array<{ name: string; operations: Operation[] }> =
90
- mountGroups.size > 0
90
+ mountGroups.size > 0 || ctx.scopedServices?.size
91
91
  ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
92
92
  : spec.services.map((s) => ({
93
93
  name: resolveResourceClassName(s, ctx),
@@ -1,5 +1,6 @@
1
1
  import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
2
2
  import { className, ktStringLiteral } from './naming.js';
3
+ import { isEnumInScope } from '../shared/resolved-ops.js';
3
4
 
4
5
  const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
5
6
  const ENUMS_PACKAGE = 'com.workos.types';
@@ -24,7 +25,7 @@ export const enumCanonicalMap = new Map<string, string>();
24
25
  * shortest PascalCase name becomes canonical and the rest emit `typealias`
25
26
  * files pointing at the canonical class.
26
27
  */
27
- export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFile[] {
28
+ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
28
29
  if (enums.length === 0) return [];
29
30
 
30
31
  // Reset the canonical map on every generation run (guards against re-entry).
@@ -74,6 +75,11 @@ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFil
74
75
 
75
76
  const typeName = canonicalEnumTypeName(enumDef);
76
77
 
78
+ // FR-1.4: write per-enum FILES only when in scope. Each enum is its own
79
+ // `.kt` file (no barrel), so an out-of-scope enum left untouched on disk
80
+ // stays importable.
81
+ const enumInScope = isEnumInScope(enumDef.name, ctx);
82
+
77
83
  // Non-canonical enum: emit a typealias instead of a full enum class.
78
84
  const sharedSortEmitter = sharedSortEmitters.has(enumDef.name);
79
85
  const canonicalName = sharedSortEmitter
@@ -94,11 +100,13 @@ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFil
94
100
  aliasLine,
95
101
  '',
96
102
  ].join('\n');
97
- files.push({
98
- path: `${KOTLIN_SRC_PREFIX}${ENUMS_DIR}/${typeName}.kt`,
99
- content: aliasContent,
100
- overwriteExisting: true,
101
- });
103
+ if (enumInScope) {
104
+ files.push({
105
+ path: `${KOTLIN_SRC_PREFIX}${ENUMS_DIR}/${typeName}.kt`,
106
+ content: aliasContent,
107
+ overwriteExisting: true,
108
+ });
109
+ }
102
110
  continue;
103
111
  }
104
112
 
@@ -175,11 +183,13 @@ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFil
175
183
  lines.push('}');
176
184
  lines.push('');
177
185
 
178
- files.push({
179
- path: `${KOTLIN_SRC_PREFIX}${ENUMS_DIR}/${typeName}.kt`,
180
- content: lines.join('\n'),
181
- overwriteExisting: true,
182
- });
186
+ if (enumInScope) {
187
+ files.push({
188
+ path: `${KOTLIN_SRC_PREFIX}${ENUMS_DIR}/${typeName}.kt`,
189
+ content: lines.join('\n'),
190
+ overwriteExisting: true,
191
+ });
192
+ }
183
193
  }
184
194
 
185
195
  return files;
@@ -19,6 +19,7 @@ import { generateTests } from './tests.js';
19
19
  import { buildOperationsMap } from './manifest.js';
20
20
  import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
21
21
  import { flattenDiscriminatedUnionFields } from '../shared/union-flatten.js';
22
+ import { AUTOGEN_NOTICE } from '../shared/file-header.js';
22
23
 
23
24
  /** Ensure every generated file ends with a trailing newline. */
24
25
  function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
@@ -95,7 +96,7 @@ export const kotlinEmitter: Emitter = {
95
96
  },
96
97
 
97
98
  fileHeader(): string {
98
- return '// This file is auto-generated by oagen. Do not edit.';
99
+ return `// ${AUTOGEN_NOTICE}`;
99
100
  },
100
101
 
101
102
  formatCommand(targetDir: string): FormatCommand | null {
@@ -1,6 +1,6 @@
1
1
  import type { Model, EmitterContext, GeneratedFile, TypeRef, Field } from '@workos/oagen';
2
2
  import { mapTypeRef, discriminatedUnions } from './type-map.js';
3
- import { className, propertyName, ktStringLiteral, humanize } from './naming.js';
3
+ import { className, domainPropertyName, ktStringLiteral, humanize } from './naming.js';
4
4
  import { enumCanonicalMap } from './enums.js';
5
5
  import {
6
6
  isListWrapperModel,
@@ -8,6 +8,7 @@ import {
8
8
  collectNonPaginatedResponseModelNames,
9
9
  collectReferencedListMetadataModels,
10
10
  } from '../shared/model-utils.js';
11
+ import { isModelInScope } from '../shared/resolved-ops.js';
11
12
 
12
13
  const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
13
14
  const MODELS_PACKAGE = 'com.workos.models';
@@ -123,10 +124,17 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
123
124
  for (const model of models) {
124
125
  if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
125
126
  const typeName = className(model.name);
127
+ // FR-1.4: write per-model FILES only when in scope. Each model is its own
128
+ // `.kt` file (no barrel), so an out-of-scope model left untouched on disk
129
+ // stays importable. The WorkOSEvent sealed interface below is an aggregate
130
+ // built from many event models, so it is NOT gated.
131
+ const modelInScope = isModelInScope(model.name, ctx);
126
132
 
127
133
  // Parent of a discriminated union: emit a sealed class.
128
134
  if (model.fields.length === 0 && discriminatedUnions.has(typeName)) {
129
- files.push(emitSealedUnion(typeName, discriminatedUnions.get(typeName)!));
135
+ if (modelInScope) {
136
+ files.push(emitSealedUnion(typeName, discriminatedUnions.get(typeName)!));
137
+ }
130
138
  continue;
131
139
  }
132
140
 
@@ -142,15 +150,19 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
142
150
  `typealias ${typeName} = ${canonicalType}`,
143
151
  '',
144
152
  ].join('\n');
145
- files.push({
146
- path: `${KOTLIN_SRC_PREFIX}${MODELS_DIR}/${typeName}.kt`,
147
- content: aliasContent,
148
- overwriteExisting: true,
149
- });
153
+ if (modelInScope) {
154
+ files.push({
155
+ path: `${KOTLIN_SRC_PREFIX}${MODELS_DIR}/${typeName}.kt`,
156
+ content: aliasContent,
157
+ overwriteExisting: true,
158
+ });
159
+ }
150
160
  continue;
151
161
  }
152
162
 
153
- files.push(emitDataClass(model));
163
+ if (modelInScope) {
164
+ files.push(emitDataClass(model));
165
+ }
154
166
  }
155
167
 
156
168
  // Generate the sealed WorkOSEvent interface. Collect all event envelope
@@ -374,7 +386,10 @@ function renderFields(fields: Field[], overrideFields: Set<string> = new Set()):
374
386
 
375
387
  for (const rawField of fields) {
376
388
  const field = promoteFieldType(rawField);
377
- const kotlinName = propertyName(field.name);
389
+ // DOMAIN identifier: the data class property name. Honors a `domainName`
390
+ // override (e.g. connection_type -> type); the `@JsonProperty(...)` wire
391
+ // key below still derives from `field.name`.
392
+ const kotlinName = domainPropertyName(field);
378
393
  if (seen.has(kotlinName)) continue;
379
394
  seen.add(kotlinName);
380
395
 
@@ -57,6 +57,17 @@ export function propertyName(name: string): string {
57
57
  return escapeReserved(camel);
58
58
  }
59
59
 
60
+ /**
61
+ * camelCase domain property name for a MODEL field, honoring a `domainName`
62
+ * override (set via the `fieldHints` config) so a wire field can surface under
63
+ * a friendlier name. The wire key (the `@JsonProperty("...")` argument) still
64
+ * derives from `field.name`. No-op when `domainName` is unset, so it is also
65
+ * safe on params. Only apply to model fields.
66
+ */
67
+ export function domainPropertyName(field: { name: string; domainName?: string }): string {
68
+ return propertyName(field.domainName ?? field.name);
69
+ }
70
+
60
71
  /** camelCase alias (kept for parity with other emitters). */
61
72
  export const fieldName = propertyName;
62
73
  export const localName = propertyName;