@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
@@ -12,7 +12,7 @@ import { mapTypeRef, mapTypeRefForPHPDoc } from './type-map.js';
12
12
  import { className, fieldName, resolveMethodName, buildExportedClassNameSet, resolveServiceTarget } from './naming.js';
13
13
  import { isListWrapperModel } from './models.js';
14
14
  import {
15
- groupByMount,
15
+ scopedMountGroups,
16
16
  buildResolvedLookup,
17
17
  lookupResolved,
18
18
  getOpDefaults,
@@ -44,10 +44,12 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
44
44
  const files: GeneratedFile[] = [];
45
45
  const modelMap = new Map(ctx.spec.models.map((m) => [m.name, m]));
46
46
 
47
- // Group operations by mount target
48
- const mountGroups = groupByMount(ctx);
47
+ // Group operations by mount target. In a scoped (`--services`) run this
48
+ // returns only the selected post-mount services so we emit per-service
49
+ // resource files for those alone.
50
+ const mountGroups = scopedMountGroups(ctx);
49
51
  const entries: Array<{ name: string; operations: Operation[] }> =
50
- mountGroups.size > 0
52
+ mountGroups.size > 0 || ctx.scopedServices?.size
51
53
  ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
52
54
  : services.map((s) => ({ name: className(s.name), operations: s.operations }));
53
55
 
package/src/php/tests.ts CHANGED
@@ -8,12 +8,19 @@ import type {
8
8
  ResolvedOperation,
9
9
  } from '@workos/oagen';
10
10
  import { planOperation, toCamelCase, toPascalCase } from '@workos/oagen';
11
- import { className, enumClassName, resolveMethodName, snakeName, servicePropertyName } from './naming.js';
11
+ import {
12
+ className,
13
+ enumClassName,
14
+ resolveMethodName,
15
+ snakeName,
16
+ servicePropertyName,
17
+ domainFieldName,
18
+ } from './naming.js';
12
19
  import { isListWrapperModel } from './models.js';
13
20
  import { generateFixtures } from './fixtures.js';
14
21
  import {
15
22
  getMountTarget,
16
- groupByMount,
23
+ scopedMountGroups,
17
24
  buildHiddenParams,
18
25
  getOpDefaults,
19
26
  getOpInferFromClient,
@@ -32,9 +39,12 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
32
39
 
33
40
  // Collect all operations per mount target using resolved per-operation mounts.
34
41
  // This correctly handles operationHint mountOn overrides (e.g., audit_logs_retention → AuditLogs).
35
- const mountGroupsFromResolved = groupByMount(ctx);
42
+ // In a scoped (`--services`) run this returns only the selected post-mount
43
+ // services so we emit per-service test files for those alone. ClientTest.php
44
+ // and fixtures below are built from `spec` and stay full.
45
+ const mountGroupsFromResolved = scopedMountGroups(ctx);
36
46
  const mountGroups = new Map<string, { op: Operation; service: Service; resolvedOp?: ResolvedOperation }[]>();
37
- if (mountGroupsFromResolved.size > 0) {
47
+ if (mountGroupsFromResolved.size > 0 || ctx.scopedServices?.size) {
38
48
  for (const [target, group] of mountGroupsFromResolved) {
39
49
  mountGroups.set(
40
50
  target,
@@ -503,7 +513,9 @@ function emitFieldHydrationAssertions(
503
513
 
504
514
  for (const f of assertFields) {
505
515
  if (!f) continue;
506
- const phpProp = toCamelCase(f.name);
516
+ // DOMAIN identifier: the deserialized model's PHP property (honors `domainName`).
517
+ // The fixture key `f.name` is the WIRE key and stays unchanged.
518
+ const phpProp = domainFieldName(f);
507
519
  lines.push(` $this->assertSame(${fixtureVar}['${f.name}'], ${resultVar}->${phpProp});`);
508
520
  }
509
521
  }
@@ -2,6 +2,7 @@ import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
2
2
  import { toUpperSnakeCase } from '@workos/oagen';
3
3
  import { className, fileName, buildMountDirMap, dirToModule } from './naming.js';
4
4
  import { computeSchemaPlacement } from './shared-schemas.js';
5
+ import { isEnumInScope } from '../shared/resolved-ops.js';
5
6
 
6
7
  /**
7
8
  * Convert a PascalCase class name to a human-readable lowercase string,
@@ -38,6 +39,10 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
38
39
  for (const enumDef of enums) {
39
40
  const service = enumToService.get(enumDef.name);
40
41
  const dirName = resolveDir(service);
42
+ // FR-1.4: write per-enum FILES only when in scope; the enum barrel is built
43
+ // separately (collectGeneratedEnumSymbolsByDir over the full set), so an
44
+ // out-of-scope enum left on disk stays exported and importable.
45
+ const enumInScope = isEnumInScope(enumDef.name, ctx);
41
46
 
42
47
  // If this enum is an alias for a canonical enum, generate a type alias file
43
48
  const canonicalName = aliasOf.get(enumDef.name);
@@ -73,12 +78,14 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
73
78
  lines.push(' raise AttributeError(f"module {__name__!r} has no attribute {name!r}")');
74
79
  }
75
80
  lines.push(`__all__ = ["${aliasCls}"]`);
76
- files.push({
77
- path: `src/${ctx.namespace}/${dirName}/models/${fileName(enumDef.name)}.py`,
78
- content: lines.join('\n'),
79
- integrateTarget: true,
80
- overwriteExisting: true,
81
- });
81
+ if (enumInScope) {
82
+ files.push({
83
+ path: `src/${ctx.namespace}/${dirName}/models/${fileName(enumDef.name)}.py`,
84
+ content: lines.join('\n'),
85
+ integrateTarget: true,
86
+ overwriteExisting: true,
87
+ });
88
+ }
82
89
 
83
90
  // Also generate compat alias files for dedup aliases (they may have compat aliases too)
84
91
  for (const aliasName of compatAliases.get(enumDef.name) ?? []) {
@@ -107,12 +114,14 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
107
114
  `__all__ = ["${aliasName}"]`,
108
115
  ].join('\n');
109
116
  }
110
- files.push({
111
- path: `src/${ctx.namespace}/${dirName}/models/${fileName(aliasName)}.py`,
112
- content: compatContent,
113
- integrateTarget: true,
114
- overwriteExisting: true,
115
- });
117
+ if (enumInScope) {
118
+ files.push({
119
+ path: `src/${ctx.namespace}/${dirName}/models/${fileName(aliasName)}.py`,
120
+ content: compatContent,
121
+ integrateTarget: true,
122
+ overwriteExisting: true,
123
+ });
124
+ }
116
125
  }
117
126
 
118
127
  continue;
@@ -241,26 +250,28 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
241
250
  );
242
251
  }
243
252
 
244
- files.push({
245
- path: `src/${ctx.namespace}/${dirName}/models/${fileName(enumDef.name)}.py`,
246
- content: lines.join('\n'),
247
- integrateTarget: true,
248
- overwriteExisting: true,
249
- });
250
-
251
- for (const aliasName of compatAliases.get(enumDef.name) ?? []) {
253
+ if (enumInScope) {
252
254
  files.push({
253
- path: `src/${ctx.namespace}/${dirName}/models/${fileName(aliasName)}.py`,
254
- content: [
255
- 'from typing import TypeAlias',
256
- `from .${fileName(enumDef.name)} import ${cls}`,
257
- '',
258
- `${aliasName}: TypeAlias = ${cls}`,
259
- `__all__ = ["${aliasName}"]`,
260
- ].join('\n'),
255
+ path: `src/${ctx.namespace}/${dirName}/models/${fileName(enumDef.name)}.py`,
256
+ content: lines.join('\n'),
261
257
  integrateTarget: true,
262
258
  overwriteExisting: true,
263
259
  });
260
+
261
+ for (const aliasName of compatAliases.get(enumDef.name) ?? []) {
262
+ files.push({
263
+ path: `src/${ctx.namespace}/${dirName}/models/${fileName(aliasName)}.py`,
264
+ content: [
265
+ 'from typing import TypeAlias',
266
+ `from .${fileName(enumDef.name)} import ${cls}`,
267
+ '',
268
+ `${aliasName}: TypeAlias = ${cls}`,
269
+ `__all__ = ["${aliasName}"]`,
270
+ ].join('\n'),
271
+ integrateTarget: true,
272
+ overwriteExisting: true,
273
+ });
274
+ }
264
275
  }
265
276
  }
266
277
 
@@ -1,6 +1,6 @@
1
1
  import type { Model, TypeRef, Enum } from '@workos/oagen';
2
2
 
3
- import { fileName, fieldName } from './naming.js';
3
+ import { fileName, domainFieldName } from './naming.js';
4
4
  import { isListMetadataModel, isListWrapperModel } from './models.js';
5
5
  import { collectNonPaginatedResponseModelNames, collectReferencedListMetadataModels } from '../shared/model-utils.js';
6
6
 
@@ -104,10 +104,11 @@ export function generateModelFixture(
104
104
  ): Record<string, any> {
105
105
  const fixture: Record<string, any> = {};
106
106
 
107
- // Deduplicate fields by snake_case name (matching model generation in models.ts)
107
+ // Deduplicate fields by DOMAIN identifier (matching model generation in
108
+ // models.ts, which honors `domainName`); the wire key below stays `field.name`.
108
109
  const seenFieldNames = new Set<string>();
109
110
  const deduplicatedFields = model.fields.filter((f) => {
110
- const pyName = fieldName(f.name);
111
+ const pyName = domainFieldName(f);
111
112
  if (seenFieldNames.has(pyName)) return false;
112
113
  seenFieldNames.add(pyName);
113
114
  return true;
@@ -11,6 +11,7 @@ import type {
11
11
 
12
12
  import { generateModels } from './models.js';
13
13
  import { detectDiscriminators } from '../shared/model-utils.js';
14
+ import { AUTOGEN_NOTICE } from '../shared/file-header.js';
14
15
  import { generateEnums } from './enums.js';
15
16
  import { generateResources } from './resources.js';
16
17
  import { generateClient } from './client.js';
@@ -86,7 +87,7 @@ export const pythonEmitter: Emitter = {
86
87
  },
87
88
 
88
89
  fileHeader(): string {
89
- return '# This file is auto-generated by oagen. Do not edit.';
90
+ return `# ${AUTOGEN_NOTICE}`;
90
91
  },
91
92
 
92
93
  formatCommand(_targetDir: string): FormatCommand | null {
@@ -1,9 +1,10 @@
1
1
  import type { Model, EmitterContext, GeneratedFile } from '@workos/oagen';
2
2
  import { collectFieldDependencies, walkTypeRef } from '@workos/oagen';
3
3
  import { mapTypeRef } from './type-map.js';
4
- import { className, fieldName, fileName, buildMountDirMap, dirToModule } from './naming.js';
4
+ import { className, domainFieldName, fileName, buildMountDirMap, dirToModule } from './naming.js';
5
5
  import { collectGeneratedEnumSymbolsByDir } from './enums.js';
6
6
  import { computeSchemaPlacement } from './shared-schemas.js';
7
+ import { isModelInScope } from '../shared/resolved-ops.js';
7
8
 
8
9
  /**
9
10
  * Generate Python dataclass model files from IR Model definitions.
@@ -169,12 +170,16 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
169
170
  dispLines.push(` return cast("${variantTypeName}", dispatch_cls.from_dict(data))`);
170
171
  dispLines.push(` return ${unknownClassName}.from_dict(data)`);
171
172
 
172
- files.push({
173
- path: `src/${ctx.namespace}/${dirName}/models/${fileName(model.name)}.py`,
174
- content: dispLines.join('\n'),
175
- integrateTarget: true,
176
- overwriteExisting: true,
177
- });
173
+ // FR-1.4: write the file only when in scope; the barrel tracking below is
174
+ // unconditional so out-of-scope models (left on disk) stay exported.
175
+ if (isModelInScope(model.name, ctx)) {
176
+ files.push({
177
+ path: `src/${ctx.namespace}/${dirName}/models/${fileName(model.name)}.py`,
178
+ content: dispLines.join('\n'),
179
+ integrateTarget: true,
180
+ overwriteExisting: true,
181
+ });
182
+ }
178
183
 
179
184
  if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
180
185
  emittedModelSymbolsByDir.get(dirName)!.push(model.name);
@@ -216,12 +221,14 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
216
221
  }
217
222
  lines.push('');
218
223
  lines.push(`${modelClassName}: TypeAlias = ${canonicalClassName}`);
219
- files.push({
220
- path: `src/${ctx.namespace}/${dirName}/models/${fileName(model.name)}.py`,
221
- content: lines.join('\n'),
222
- integrateTarget: true,
223
- overwriteExisting: true,
224
- });
224
+ if (isModelInScope(model.name, ctx)) {
225
+ files.push({
226
+ path: `src/${ctx.namespace}/${dirName}/models/${fileName(model.name)}.py`,
227
+ content: lines.join('\n'),
228
+ integrateTarget: true,
229
+ overwriteExisting: true,
230
+ });
231
+ }
225
232
  if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
226
233
  emittedModelSymbolsByDir.get(dirName)!.push(model.name);
227
234
  const aliasNatural = originalModelToService.get(model.name);
@@ -232,7 +239,9 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
232
239
  // Deduplicate fields that map to the same snake_case name
233
240
  const seenFieldNames = new Set<string>();
234
241
  const deduplicatedFields = model.fields.filter((f) => {
235
- const pyName = fieldName(f.name);
242
+ // Dedup on the DOMAIN identifier (the dataclass attribute name), which
243
+ // honors a `domainName` override; the wire key stays `field.name`.
244
+ const pyName = domainFieldName(f);
236
245
  if (seenFieldNames.has(pyName)) return false;
237
246
  seenFieldNames.add(pyName);
238
247
  return true;
@@ -343,7 +352,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
343
352
  };
344
353
 
345
354
  for (const field of requiredFields) {
346
- const pyFieldName = fieldName(field.name);
355
+ // DOMAIN identifier: the dataclass attribute name (honors `domainName`).
356
+ const pyFieldName = domainFieldName(field);
347
357
  const pyType = rewriteDiscriminatorType(resolveModelFieldType(field.type));
348
358
  if (field.description || field.deprecated) {
349
359
  const parts: string[] = [];
@@ -357,7 +367,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
357
367
  }
358
368
 
359
369
  for (const field of optionalFields) {
360
- const pyFieldName = fieldName(field.name);
370
+ // DOMAIN identifier: the dataclass attribute name (honors `domainName`).
371
+ const pyFieldName = domainFieldName(field);
361
372
  const innerType =
362
373
  field.type.kind === 'nullable' ? resolveModelFieldType(field.type.inner) : resolveModelFieldType(field.type);
363
374
  const pyType = `Optional[${rewriteDiscriminatorType(innerType)}]`;
@@ -383,7 +394,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
383
394
  const fieldAssignmentLines: string[] = [];
384
395
 
385
396
  for (const field of [...requiredFields, ...optionalFields]) {
386
- const pyFieldName = fieldName(field.name);
397
+ // DOMAIN identifier (LHS of `cls(...)`); the wire key below stays `field.name`.
398
+ const pyFieldName = domainFieldName(field);
387
399
  const wireKey = field.name; // Wire keys are snake_case from the spec
388
400
  const isRequired = !isOptionalField(model.name, field, ctx);
389
401
 
@@ -424,7 +436,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
424
436
  lines.push(' result: Dict[str, Any] = {}');
425
437
 
426
438
  for (const field of [...requiredFields, ...optionalFields]) {
427
- const pyFieldName = fieldName(field.name);
439
+ // DOMAIN identifier (`self.<attr>`); the wire key below stays `field.name`.
440
+ const pyFieldName = domainFieldName(field);
428
441
  const wireKey = field.name;
429
442
  const isRequired = !isOptionalField(model.name, field, ctx);
430
443
 
@@ -451,12 +464,14 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
451
464
 
452
465
  lines.push(' return result');
453
466
 
454
- files.push({
455
- path: `src/${ctx.namespace}/${dirName}/models/${fileName(model.name)}.py`,
456
- content: lines.join('\n'),
457
- integrateTarget: true,
458
- overwriteExisting: true,
459
- });
467
+ if (isModelInScope(model.name, ctx)) {
468
+ files.push({
469
+ path: `src/${ctx.namespace}/${dirName}/models/${fileName(model.name)}.py`,
470
+ content: lines.join('\n'),
471
+ integrateTarget: true,
472
+ overwriteExisting: true,
473
+ });
474
+ }
460
475
  if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
461
476
  emittedModelSymbolsByDir.get(dirName)!.push(model.name);
462
477
  const regularNatural = originalModelToService.get(model.name);
@@ -50,6 +50,16 @@ export function fieldName(name: string): string {
50
50
  return toSnakeCase(name);
51
51
  }
52
52
 
53
+ /**
54
+ * snake_case domain field name for a model field, honoring a `domainName`
55
+ * override (set via the `fieldHints` config) so a wire field can surface under
56
+ * a friendlier name. The wire name (still derived from `field.name`) is
57
+ * unaffected, so the API contract is preserved.
58
+ */
59
+ export function domainFieldName(field: { name: string; domainName?: string }): string {
60
+ return toSnakeCase(field.domainName ?? field.name);
61
+ }
62
+
53
63
  /**
54
64
  * Python builtins that should not be shadowed by parameter names.
55
65
  * When a path/query param name collides, suffix with underscore.
@@ -30,7 +30,7 @@ import {
30
30
  buildResolvedLookup,
31
31
  lookupMethodName,
32
32
  lookupResolved,
33
- groupByMount,
33
+ scopedMountGroups,
34
34
  getOpDefaults,
35
35
  getOpInferFromClient,
36
36
  buildHiddenParams as buildHiddenParamsShared,
@@ -1056,12 +1056,12 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
1056
1056
  const resolvedLookup = buildResolvedLookup(ctx);
1057
1057
  const files: GeneratedFile[] = [];
1058
1058
  const mountDirMap = buildMountDirMap(ctx);
1059
- const mountGroups = groupByMount(ctx);
1059
+ const mountGroups = scopedMountGroups(ctx);
1060
1060
 
1061
1061
  // Build mount group entries. When resolved operations are available, group by
1062
1062
  // mount target. Otherwise fall back to one group per service (for tests).
1063
1063
  const entries: Array<{ name: string; operations: Operation[] }> =
1064
- mountGroups.size > 0
1064
+ mountGroups.size > 0 || ctx.scopedServices?.size
1065
1065
  ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
1066
1066
  : services.map((s) => ({ name: resolveClassName(s, ctx), operations: s.operations }));
1067
1067
 
@@ -13,6 +13,7 @@ import {
13
13
  className,
14
14
  fileName,
15
15
  fieldName,
16
+ domainFieldName,
16
17
  moduleName,
17
18
  resolveMethodName,
18
19
  buildMountDirMap,
@@ -24,7 +25,7 @@ import { generateFixtures, generateModelFixture } from './fixtures.js';
24
25
  import { isListWrapperModel, isListMetadataModel } from './models.js';
25
26
  import { collectNonPaginatedResponseModelNames, collectReferencedListMetadataModels } from '../shared/model-utils.js';
26
27
  import {
27
- groupByMount,
28
+ scopedMountGroups,
28
29
  buildResolvedLookup,
29
30
  lookupResolved,
30
31
  buildHiddenParams,
@@ -117,9 +118,9 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
117
118
  const accessPaths = buildServiceAccessPaths(spec.services, ctx);
118
119
 
119
120
  // Generate per-mount-target test files (merges all sub-services into one file)
120
- const mountGroups = groupByMount(ctx);
121
+ const mountGroups = scopedMountGroups(ctx);
121
122
  const testEntries: Array<{ name: string; operations: Operation[]; resolvedOps?: ResolvedOperation[] }> =
122
- mountGroups.size > 0
123
+ mountGroups.size > 0 || ctx.scopedServices?.size
123
124
  ? [...mountGroups].map(([name, group]) => ({
124
125
  name,
125
126
  operations: group.operations,
@@ -1026,12 +1027,13 @@ function pickAssertableFields(
1026
1027
  // Skip strings containing characters that are hard to represent as Python literals
1027
1028
  if (val.includes('"') || val.includes("'") || val.includes('{') || val.includes('\\') || val.includes('\n'))
1028
1029
  continue;
1029
- results.push({ field: fieldName(f.name), value: `"${val}"` });
1030
+ // DOMAIN identifier: asserted as `result.<attr>` (honors `domainName`).
1031
+ results.push({ field: domainFieldName(f), value: `"${val}"` });
1030
1032
  } else if (typeof val === 'boolean') {
1031
1033
  // Use "is True/False" to satisfy ruff E712
1032
- results.push({ field: fieldName(f.name), value: val ? 'True' : 'False', isBool: true });
1034
+ results.push({ field: domainFieldName(f), value: val ? 'True' : 'False', isBool: true });
1033
1035
  } else if (typeof val === 'number') {
1034
- results.push({ field: fieldName(f.name), value: String(val) });
1036
+ results.push({ field: domainFieldName(f), value: String(val) });
1035
1037
  }
1036
1038
  }
1037
1039
  return results;
@@ -1114,7 +1116,9 @@ function buildTestArgs(op: Operation, spec: ApiSpec, hiddenParams?: Set<string>)
1114
1116
  if (plan.hasBody && op.requestBody?.kind === 'model') {
1115
1117
  const rbName = op.requestBody.name;
1116
1118
  const bodyModel = spec.models.find((m) => m.name === rbName);
1117
- if (bodyModel?.fields.some((f) => fieldName(f.name) === fieldName(param.name))) continue;
1119
+ // Compare the body field's DOMAIN identifier (honors `domainName`)
1120
+ // against the param kwarg name; the param has no domainName override.
1121
+ if (bodyModel?.fields.some((f) => domainFieldName(f) === fieldName(param.name))) continue;
1118
1122
  }
1119
1123
  if (param.required && !pathParamNames.has(fieldName(param.name))) {
1120
1124
  args.push(`${fieldName(param.name)}=${generateTestValue(param.type, param.name)}`);
@@ -1511,10 +1515,11 @@ function generateModelRoundTripTests(spec: ApiSpec, ctx: EmitterContext): Genera
1511
1515
  // don't have a to_dict() and their round-trip semantics differ.
1512
1516
  if (model.fields.length === 0) continue;
1513
1517
  if ((model as any).discriminator) continue;
1514
- // Deduplicate fields that map to the same snake_case name (mirrors models.ts)
1518
+ // Deduplicate fields by DOMAIN identifier (mirrors models.ts, which honors
1519
+ // `domainName`); the wire key stays `field.name`.
1515
1520
  const seenFieldNames = new Set<string>();
1516
1521
  const dedupFields = model.fields.filter((f) => {
1517
- const pyName = fieldName(f.name);
1522
+ const pyName = domainFieldName(f);
1518
1523
  if (seenFieldNames.has(pyName)) return false;
1519
1524
  seenFieldNames.add(pyName);
1520
1525
  return true;
package/src/ruby/enums.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
2
2
  import { toUpperSnakeCase } from '@workos/oagen';
3
3
  import { className, fileName } from './naming.js';
4
+ import { isEnumInScope } from '../shared/resolved-ops.js';
4
5
 
5
6
  /**
6
7
  * Generate Ruby enum class files.
@@ -9,7 +10,6 @@ import { className, fileName } from './naming.js';
9
10
  * and a frozen `ALL` array of all values.
10
11
  */
11
12
  export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
12
- void ctx;
13
13
  if (enums.length === 0) return [];
14
14
 
15
15
  const files: GeneratedFile[] = [];
@@ -17,6 +17,9 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
17
17
 
18
18
  for (const enumDef of enums) {
19
19
  const cls = className(enumDef.name);
20
+ // FR-1.4: write the per-enum file only when in scope. Out-of-scope enum
21
+ // files are left untouched on disk; Zeitwerk autoloads them by path.
22
+ const enumInScope = isEnumInScope(enumDef.name, ctx);
20
23
 
21
24
  // If this enum duplicates another (by value set), emit a Ruby constant
22
25
  // alias. Zeitwerk autoloads the canonical when the alias is first
@@ -30,12 +33,14 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
30
33
  lines.push(` ${cls} = ${canonicalCls}`);
31
34
  lines.push(' end');
32
35
  lines.push('end');
33
- files.push({
34
- path: `lib/workos/types/${fileName(enumDef.name)}.rb`,
35
- content: lines.join('\n'),
36
- integrateTarget: true,
37
- overwriteExisting: true,
38
- });
36
+ if (enumInScope) {
37
+ files.push({
38
+ path: `lib/workos/types/${fileName(enumDef.name)}.rb`,
39
+ content: lines.join('\n'),
40
+ integrateTarget: true,
41
+ overwriteExisting: true,
42
+ });
43
+ }
39
44
  continue;
40
45
  }
41
46
 
@@ -60,12 +65,14 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
60
65
  lines.push(' end');
61
66
  lines.push(' end');
62
67
  lines.push('end');
63
- files.push({
64
- path: `lib/workos/types/${fileName(enumDef.name)}.rb`,
65
- content: lines.join('\n'),
66
- integrateTarget: true,
67
- overwriteExisting: true,
68
- });
68
+ if (enumInScope) {
69
+ files.push({
70
+ path: `lib/workos/types/${fileName(enumDef.name)}.rb`,
71
+ content: lines.join('\n'),
72
+ integrateTarget: true,
73
+ overwriteExisting: true,
74
+ });
75
+ }
69
76
  continue;
70
77
  }
71
78
 
@@ -108,12 +115,14 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
108
115
  lines.push(' end');
109
116
  lines.push('end');
110
117
 
111
- files.push({
112
- path: `lib/workos/types/${fileName(enumDef.name)}.rb`,
113
- content: lines.join('\n'),
114
- integrateTarget: true,
115
- overwriteExisting: true,
116
- });
118
+ if (enumInScope) {
119
+ files.push({
120
+ path: `lib/workos/types/${fileName(enumDef.name)}.rb`,
121
+ content: lines.join('\n'),
122
+ integrateTarget: true,
123
+ overwriteExisting: true,
124
+ });
125
+ }
117
126
  }
118
127
 
119
128
  return files;
package/src/ruby/index.ts CHANGED
@@ -16,6 +16,7 @@ import { generateTests } from './tests.js';
16
16
  import { buildOperationsMap } from './manifest.js';
17
17
  import { generateRbiFiles } from './rbi.js';
18
18
  import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
19
+ import { AUTOGEN_NOTICE } from '../shared/file-header.js';
19
20
 
20
21
  /** Ensure every generated file's content ends with a trailing newline. */
21
22
  function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
@@ -93,7 +94,7 @@ export const rubyEmitter: Emitter = {
93
94
  },
94
95
 
95
96
  fileHeader(): string {
96
- return `# frozen_string_literal: true\n\n# This file is auto-generated by oagen. Do not edit.`;
97
+ return `# frozen_string_literal: true\n\n# ${AUTOGEN_NOTICE}`;
97
98
  },
98
99
 
99
100
  formatCommand(targetDir: string): FormatCommand | null {