@workos/oagen-emitters 0.18.4 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +14 -0
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/dist/{plugin-Cciic50q.mjs → plugin-DXIciTnN.mjs} +668 -164
  6. package/dist/plugin-DXIciTnN.mjs.map +1 -0
  7. package/dist/plugin.mjs +1 -1
  8. package/package.json +4 -4
  9. package/src/dotnet/enums.ts +11 -5
  10. package/src/dotnet/fixtures.ts +28 -7
  11. package/src/dotnet/index.ts +42 -1
  12. package/src/dotnet/models.ts +11 -5
  13. package/src/dotnet/resources.ts +3 -3
  14. package/src/dotnet/tests.ts +4 -4
  15. package/src/go/enums.ts +91 -18
  16. package/src/go/fixtures.ts +25 -3
  17. package/src/go/flat-merge.ts +253 -0
  18. package/src/go/models.ts +85 -20
  19. package/src/go/resources.ts +3 -3
  20. package/src/go/tests.ts +7 -5
  21. package/src/kotlin/enums.ts +21 -11
  22. package/src/kotlin/models.ts +53 -11
  23. package/src/kotlin/resources.ts +2 -2
  24. package/src/kotlin/tests.ts +38 -3
  25. package/src/node/enums.ts +8 -5
  26. package/src/node/models.ts +29 -21
  27. package/src/node/resources.ts +12 -1
  28. package/src/node/tests.ts +7 -2
  29. package/src/php/enums.ts +18 -5
  30. package/src/php/index.ts +11 -3
  31. package/src/php/models.ts +11 -5
  32. package/src/php/resources.ts +6 -4
  33. package/src/php/tests.ts +6 -3
  34. package/src/python/enums.ts +39 -28
  35. package/src/python/fixtures.ts +34 -6
  36. package/src/python/models.ts +138 -45
  37. package/src/python/resources.ts +3 -3
  38. package/src/python/tests.ts +31 -12
  39. package/src/ruby/enums.ts +28 -19
  40. package/src/ruby/models.ts +23 -12
  41. package/src/ruby/rbi.ts +17 -6
  42. package/src/ruby/resources.ts +2 -2
  43. package/src/ruby/tests.ts +37 -4
  44. package/src/rust/enums.ts +29 -7
  45. package/src/rust/fixtures.ts +12 -3
  46. package/src/rust/models.ts +37 -6
  47. package/src/rust/resources.ts +8 -1
  48. package/src/rust/tests.ts +3 -3
  49. package/src/shared/resolved-ops.ts +104 -0
  50. package/test/dotnet/scoped-aggregates.test.ts +247 -0
  51. package/test/go/scoping.test.ts +324 -0
  52. package/test/kotlin/models.test.ts +74 -0
  53. package/test/kotlin/tests.test.ts +33 -0
  54. package/test/python/scoped-aggregates.test.ts +205 -0
  55. package/test/ruby/tests.test.ts +130 -0
  56. package/test/rust/fixtures.test.ts +13 -7
  57. package/test/shared/synthetic-enum-seed.test.ts +79 -0
  58. package/dist/plugin-Cciic50q.mjs.map +0 -1
@@ -2,8 +2,9 @@ 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
4
  import { className, domainFieldName, fileName, buildMountDirMap, dirToModule } from './naming.js';
5
- import { collectGeneratedEnumSymbolsByDir } from './enums.js';
5
+ import { collectGeneratedEnumSymbolsByDir, collectCompatEnumAliases } from './enums.js';
6
6
  import { computeSchemaPlacement } from './shared-schemas.js';
7
+ import { isModelInScope, isEnumInScope, fileExistsAfterRun, priorManifestBasenames } from '../shared/resolved-ops.js';
7
8
 
8
9
  /**
9
10
  * Generate Python dataclass model files from IR Model definitions.
@@ -169,26 +170,38 @@ 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
+ // gated on the file existing after the run so out-of-scope models that
175
+ // are already on disk stay exported, but brand-new out-of-scope models
176
+ // (whose file is never emitted) are NOT referenced by the barrel.
177
+ const dispInScope = isModelInScope(model.name, ctx);
178
+ if (dispInScope) {
179
+ files.push({
180
+ path: `src/${ctx.namespace}/${dirName}/models/${fileName(model.name)}.py`,
181
+ content: dispLines.join('\n'),
182
+ integrateTarget: true,
183
+ overwriteExisting: true,
184
+ });
185
+ }
178
186
 
179
- if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
180
- emittedModelSymbolsByDir.get(dirName)!.push(model.name);
181
- // Also register the variant type alias and unknown variant in the barrel,
182
- // pointing to the same file as the dispatcher.
183
- emittedModelSymbolsByDir.get(dirName)!.push(variantTypeName);
184
- symbolToFile.set(variantTypeName, fileName(model.name));
185
- emittedModelSymbolsByDir.get(dirName)!.push(unknownClassName);
186
- symbolToFile.set(unknownClassName, fileName(model.name));
187
- const dispatcherNatural = originalModelToService.get(model.name);
188
- if (dispatcherNatural) {
189
- symbolToOriginalService.set(model.name, dispatcherNatural);
190
- symbolToOriginalService.set(variantTypeName, dispatcherNatural);
191
- symbolToOriginalService.set(unknownClassName, dispatcherNatural);
187
+ // Only reference this dispatcher (and its variant/unknown symbols, which
188
+ // live in the same file) from the barrel when that file exists on disk
189
+ // after the run.
190
+ if (fileExistsAfterRun(`src/${ctx.namespace}/${dirName}/models/${fileName(model.name)}.py`, dispInScope, ctx)) {
191
+ if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
192
+ emittedModelSymbolsByDir.get(dirName)!.push(model.name);
193
+ // Also register the variant type alias and unknown variant in the barrel,
194
+ // pointing to the same file as the dispatcher.
195
+ emittedModelSymbolsByDir.get(dirName)!.push(variantTypeName);
196
+ symbolToFile.set(variantTypeName, fileName(model.name));
197
+ emittedModelSymbolsByDir.get(dirName)!.push(unknownClassName);
198
+ symbolToFile.set(unknownClassName, fileName(model.name));
199
+ const dispatcherNatural = originalModelToService.get(model.name);
200
+ if (dispatcherNatural) {
201
+ symbolToOriginalService.set(model.name, dispatcherNatural);
202
+ symbolToOriginalService.set(variantTypeName, dispatcherNatural);
203
+ symbolToOriginalService.set(unknownClassName, dispatcherNatural);
204
+ }
192
205
  }
193
206
  continue;
194
207
  }
@@ -216,16 +229,24 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
216
229
  }
217
230
  lines.push('');
218
231
  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
- });
225
- if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
226
- emittedModelSymbolsByDir.get(dirName)!.push(model.name);
227
- const aliasNatural = originalModelToService.get(model.name);
228
- if (aliasNatural) symbolToOriginalService.set(model.name, aliasNatural);
232
+ const aliasInScope = isModelInScope(model.name, ctx);
233
+ const aliasPath = `src/${ctx.namespace}/${dirName}/models/${fileName(model.name)}.py`;
234
+ if (aliasInScope) {
235
+ files.push({
236
+ path: aliasPath,
237
+ content: lines.join('\n'),
238
+ integrateTarget: true,
239
+ overwriteExisting: true,
240
+ });
241
+ }
242
+ // Reference the alias from the barrel only when its file exists on disk
243
+ // after the run (in-scope, or already present from a prior run).
244
+ if (fileExistsAfterRun(aliasPath, aliasInScope, ctx)) {
245
+ if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
246
+ emittedModelSymbolsByDir.get(dirName)!.push(model.name);
247
+ const aliasNatural = originalModelToService.get(model.name);
248
+ if (aliasNatural) symbolToOriginalService.set(model.name, aliasNatural);
249
+ }
229
250
  continue;
230
251
  }
231
252
 
@@ -457,16 +478,25 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
457
478
 
458
479
  lines.push(' return result');
459
480
 
460
- files.push({
461
- path: `src/${ctx.namespace}/${dirName}/models/${fileName(model.name)}.py`,
462
- content: lines.join('\n'),
463
- integrateTarget: true,
464
- overwriteExisting: true,
465
- });
466
- if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
467
- emittedModelSymbolsByDir.get(dirName)!.push(model.name);
468
- const regularNatural = originalModelToService.get(model.name);
469
- if (regularNatural) symbolToOriginalService.set(model.name, regularNatural);
481
+ const regularInScope = isModelInScope(model.name, ctx);
482
+ if (regularInScope) {
483
+ files.push({
484
+ path: modelFilePath,
485
+ content: lines.join('\n'),
486
+ integrateTarget: true,
487
+ overwriteExisting: true,
488
+ });
489
+ }
490
+ // Reference the model from the barrel only when its file exists on disk
491
+ // after the run (in-scope, or already present from a prior run). A
492
+ // brand-new out-of-scope model whose file is never emitted must NOT be
493
+ // referenced, or the `from .x import X` line dangles and the import fails.
494
+ if (fileExistsAfterRun(modelFilePath, regularInScope, ctx)) {
495
+ if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
496
+ emittedModelSymbolsByDir.get(dirName)!.push(model.name);
497
+ const regularNatural = originalModelToService.get(model.name);
498
+ if (regularNatural) symbolToOriginalService.set(model.name, regularNatural);
499
+ }
470
500
  }
471
501
 
472
502
  // Generate __init__.py barrel files for each models/ directory
@@ -474,7 +504,11 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
474
504
  // A direct symbol lives in the file at `dirPath/<file>.py`. A re-exported
475
505
  // symbol was relocated to common/ but is being mirrored from its natural
476
506
  // service barrel for backwards compatibility.
477
- type BarrelSymbol = { name: string; reExport?: { fromDir: string; file: string } };
507
+ // A `retainBasename` symbol has no known IR name it is a per-item file
508
+ // recorded in the PRIOR manifest (renamed/removed from the current spec but
509
+ // still on disk) that we re-export wholesale via `from .<base> import *` so
510
+ // out-of-scope code the scoped run did not regenerate keeps resolving.
511
+ type BarrelSymbol = { name: string; reExport?: { fromDir: string; file: string }; retainBasename?: string };
478
512
  const symbolsByDir = new Map<string, BarrelSymbol[]>();
479
513
  for (const [dirName, names] of emittedModelSymbolsByDir) {
480
514
  const key = `src/${ctx.namespace}/${dirName}/models`;
@@ -486,10 +520,57 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
486
520
  const reachableEnumNames = collectReachableEnumNames(ctx);
487
521
  const emittedEnums = ctx.spec.enums.filter((enumDef) => reachableEnumNames.has(enumDef.name));
488
522
  const enumSymbolsByDir = collectGeneratedEnumSymbolsByDir(emittedEnums, ctx);
523
+ // Map each compat-alias symbol back to its canonical enum so we can gate it by
524
+ // the canonical enum's scope (the alias file is only written when the
525
+ // canonical enum is in scope; see enums.ts).
526
+ const aliasToCanonicalEnum = new Map<string, string>();
527
+ for (const [canonical, aliasNames] of collectCompatEnumAliases(emittedEnums, ctx)) {
528
+ for (const aliasName of aliasNames) aliasToCanonicalEnum.set(aliasName, canonical);
529
+ }
489
530
  for (const [dirName, names] of enumSymbolsByDir) {
490
531
  const key = `src/${ctx.namespace}/${dirName}/models`;
491
532
  if (!symbolsByDir.has(key)) symbolsByDir.set(key, []);
492
- for (const name of names) symbolsByDir.get(key)!.push({ name });
533
+ for (const name of names) {
534
+ // Reference an enum (or its compat alias) from the barrel only when its
535
+ // per-enum file exists on disk after the run. A brand-new out-of-scope
536
+ // enum whose file is never emitted must NOT be referenced.
537
+ const enumFilePath = `${key}/${fileName(name)}.py`;
538
+ const scopeName = aliasToCanonicalEnum.get(name) ?? name;
539
+ if (fileExistsAfterRun(enumFilePath, isEnumInScope(scopeName, ctx), ctx)) {
540
+ symbolsByDir.get(key)!.push({ name });
541
+ }
542
+ }
543
+ }
544
+
545
+ // Scoped runs: retain barrel entries for per-item files still on disk (prior
546
+ // manifest) that the current spec no longer produces — e.g. a model/enum
547
+ // renamed or removed for an out-of-scope service. Out-of-scope code we did
548
+ // not regenerate may still `from .<base> import X`, so dropping it would
549
+ // break the import. We can't recover the original class name from the
550
+ // basename, so re-export the module wholesale with `import *`. A full run
551
+ // (priorManifestBasenames returns []) yields nothing here. De-duped against
552
+ // files already referenced by an emitted/enum symbol in the same dir.
553
+ //
554
+ // Candidate dirs are every `src/<ns>/<dir>/models` that appears in the prior
555
+ // manifest (a dir may have no in-scope items yet still hold on-disk files
556
+ // referenced by stale out-of-scope code).
557
+ const manifestModelDirs = new Set<string>();
558
+ for (const p of ctx.priorTargetManifestPaths ?? []) {
559
+ const m = p.match(new RegExp(`^(src/${ctx.namespace}/[^/]+/models)/[^/]+\\.py$`));
560
+ if (m) manifestModelDirs.add(m[1]);
561
+ }
562
+ for (const dirPath of manifestModelDirs) {
563
+ if (!symbolsByDir.has(dirPath)) symbolsByDir.set(dirPath, []);
564
+ const referencedBasenames = new Set<string>();
565
+ for (const sym of symbolsByDir.get(dirPath)!) {
566
+ if (sym.reExport || sym.retainBasename) continue;
567
+ referencedBasenames.add(symbolToFile.get(sym.name) ?? fileName(sym.name));
568
+ }
569
+ for (const base of priorManifestBasenames(ctx, dirPath, '.py', new Set(['__init__']))) {
570
+ if (referencedBasenames.has(base)) continue;
571
+ referencedBasenames.add(base);
572
+ symbolsByDir.get(dirPath)!.push({ name: base, retainBasename: base });
573
+ }
493
574
  }
494
575
 
495
576
  // Backwards-compat re-exports: every relocated model is also re-exported
@@ -561,6 +642,12 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
561
642
  // Use `import X as X` syntax for explicit re-exports (required by pyright strict)
562
643
  const importLines: string[] = [];
563
644
  for (const sym of uniqueSymbols) {
645
+ if (sym.retainBasename) {
646
+ // On-disk module renamed/removed from the spec: re-export it wholesale
647
+ // since its concrete symbol names are unknown from the manifest alone.
648
+ importLines.push(`from .${sym.retainBasename} import * # noqa: F401,F403`);
649
+ continue;
650
+ }
564
651
  const cls = className(sym.name);
565
652
  if (sym.reExport) {
566
653
  importLines.push(
@@ -584,9 +671,15 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
584
671
  // which includes both the resource class re-export and model star import.
585
672
  if (!serviceDirModelPaths.has(dirPath)) {
586
673
  const parentDir = dirPath.replace(/\/models$/, '');
587
- const reExports = uniqueSymbols
588
- .map((sym) => `from .models import ${className(sym.name)} as ${className(sym.name)}`)
589
- .join('\n');
674
+ const reExports = [
675
+ ...new Set(
676
+ uniqueSymbols.map((sym) =>
677
+ sym.retainBasename
678
+ ? 'from .models import * # noqa: F401,F403'
679
+ : `from .models import ${className(sym.name)} as ${className(sym.name)}`,
680
+ ),
681
+ ),
682
+ ].join('\n');
590
683
  files.push({
591
684
  path: `${parentDir}/__init__.py`,
592
685
  content: reExports,
@@ -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
 
@@ -25,11 +25,13 @@ import { generateFixtures, generateModelFixture } from './fixtures.js';
25
25
  import { isListWrapperModel, isListMetadataModel } from './models.js';
26
26
  import { collectNonPaginatedResponseModelNames, collectReferencedListMetadataModels } from '../shared/model-utils.js';
27
27
  import {
28
- groupByMount,
28
+ scopedMountGroups,
29
29
  buildResolvedLookup,
30
30
  lookupResolved,
31
31
  buildHiddenParams,
32
32
  collectGroupedParamNames,
33
+ isModelInScope,
34
+ fileExistsAfterRun,
33
35
  } from '../shared/resolved-ops.js';
34
36
  import { resolveWrapperParams } from '../shared/wrapper-utils.js';
35
37
  import { pythonLiteral } from './wrappers.js';
@@ -100,7 +102,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
100
102
  const files: GeneratedFile[] = [];
101
103
 
102
104
  // Generate fixture JSON files
103
- const fixtures = generateFixtures(spec);
105
+ const fixtures = generateFixtures(spec, ctx);
104
106
  for (const fixture of fixtures) {
105
107
  files.push({
106
108
  path: fixture.path,
@@ -118,9 +120,9 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
118
120
  const accessPaths = buildServiceAccessPaths(spec.services, ctx);
119
121
 
120
122
  // Generate per-mount-target test files (merges all sub-services into one file)
121
- const mountGroups = groupByMount(ctx);
123
+ const mountGroups = scopedMountGroups(ctx);
122
124
  const testEntries: Array<{ name: string; operations: Operation[]; resolvedOps?: ResolvedOperation[] }> =
123
- mountGroups.size > 0
125
+ mountGroups.size > 0 || ctx.scopedServices?.size
124
126
  ? [...mountGroups].map(([name, group]) => ({
125
127
  name,
126
128
  operations: group.operations,
@@ -1461,22 +1463,39 @@ function generateModelRoundTripTests(spec: ApiSpec, ctx: EmitterContext): Genera
1461
1463
 
1462
1464
  const nonPaginatedRefs = collectNonPaginatedResponseModelNames(spec.services);
1463
1465
  const listMetadataNeeded = collectReferencedListMetadataModels(spec.models, nonPaginatedRefs);
1464
- const models = spec.models.filter(
1465
- (m) =>
1466
- !(isListWrapperModel(m) && !nonPaginatedRefs.has(m.name)) &&
1467
- !(isListMetadataModel(m) && !listMetadataNeeded.has(m.name)) &&
1468
- !requestOnlyModelNames.has(m.name),
1469
- );
1470
- if (models.length === 0) return null;
1471
1466
 
1472
1467
  // The round-trip test imports models from their *natural* (pre-relocation)
1473
1468
  // service so existing callers keep working — those imports resolve via the
1474
1469
  // BC re-exports that the model emitter writes into each service barrel.
1475
- const modelToService = computeSchemaPlacement(spec, ctx).originalModelToService;
1470
+ const placement = computeSchemaPlacement(spec, ctx);
1471
+ const modelToService = placement.originalModelToService;
1472
+ // The per-model FILE is written to the RELOCATED dir; use it to compute the
1473
+ // gate's relPath so it lines up with the prior manifest.
1474
+ const relocatedModelToService = placement.modelToService;
1476
1475
  const roundTripDirMap = buildMountDirMap(ctx);
1477
1476
  const resolveDir = (irService: string | undefined) =>
1478
1477
  irService ? (roundTripDirMap.get(irService) ?? 'common') : 'common';
1479
1478
 
1479
+ // Scoped runs: the round-trip test must only reference a model whose per-model
1480
+ // file exists on disk after the run (in-scope, or already present from the
1481
+ // prior manifest). A brand-new out-of-scope model is excluded — its file is
1482
+ // never emitted, so importing it would raise ModuleNotFoundError. A full run
1483
+ // keeps every model (fileExistsAfterRun ⇒ true).
1484
+ const modelFileExists = (name: string): boolean => {
1485
+ const dir = resolveDir(relocatedModelToService.get(name));
1486
+ const path = `src/${ctx.namespace}/${dir}/models/${fileName(name)}.py`;
1487
+ return fileExistsAfterRun(path, isModelInScope(name, ctx), ctx);
1488
+ };
1489
+
1490
+ const models = spec.models.filter(
1491
+ (m) =>
1492
+ !(isListWrapperModel(m) && !nonPaginatedRefs.has(m.name)) &&
1493
+ !(isListMetadataModel(m) && !listMetadataNeeded.has(m.name)) &&
1494
+ !requestOnlyModelNames.has(m.name) &&
1495
+ modelFileExists(m.name),
1496
+ );
1497
+ if (models.length === 0) return null;
1498
+
1480
1499
  const lines: string[] = [];
1481
1500
  lines.push('"""Model round-trip tests: from_dict(to_dict()) preserves data."""');
1482
1501
  lines.push('');
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;
@@ -6,6 +6,7 @@ import {
6
6
  isListMetadataModel,
7
7
  collectNonPaginatedResponseModelNames,
8
8
  } from '../shared/model-utils.js';
9
+ import { isModelInScope } from '../shared/resolved-ops.js';
9
10
 
10
11
  /** Folder under lib/workos/ for models not owned by any service. */
11
12
  export const SHARED_MODEL_DIR = 'shared';
@@ -119,12 +120,17 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
119
120
  lines.push('module WorkOS');
120
121
  lines.push(` ${cls} = ${canonCls}`);
121
122
  lines.push('end');
122
- files.push({
123
- path: `lib/workos/${dirFor(model.name)}/${file}.rb`,
124
- content: lines.join('\n'),
125
- integrateTarget: true,
126
- overwriteExisting: true,
127
- });
123
+ // FR-1.4: write the per-model file only when in scope. Zeitwerk autoloads
124
+ // by path, so there is no barrel to keep in sync; out-of-scope alias files
125
+ // are left untouched on disk.
126
+ if (isModelInScope(model.name, ctx)) {
127
+ files.push({
128
+ path: `lib/workos/${dirFor(model.name)}/${file}.rb`,
129
+ content: lines.join('\n'),
130
+ integrateTarget: true,
131
+ overwriteExisting: true,
132
+ });
133
+ }
128
134
  continue;
129
135
  }
130
136
 
@@ -214,12 +220,17 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
214
220
  lines.push(' end');
215
221
  lines.push('end');
216
222
 
217
- files.push({
218
- path: `lib/workos/${dirFor(model.name)}/${file}.rb`,
219
- content: lines.join('\n'),
220
- integrateTarget: true,
221
- overwriteExisting: true,
222
- });
223
+ // FR-1.4: write the per-model file only when in scope. Zeitwerk autoloads by
224
+ // path, so there is no barrel to keep in sync; out-of-scope model files are
225
+ // left untouched on disk.
226
+ if (isModelInScope(model.name, ctx)) {
227
+ files.push({
228
+ path: `lib/workos/${dirFor(model.name)}/${file}.rb`,
229
+ content: lines.join('\n'),
230
+ integrateTarget: true,
231
+ overwriteExisting: true,
232
+ });
233
+ }
223
234
  }
224
235
 
225
236
  return files;
package/src/ruby/rbi.ts CHANGED
@@ -15,6 +15,8 @@ import {
15
15
  import {
16
16
  buildResolvedLookup,
17
17
  groupByMount,
18
+ isMountInScope,
19
+ isModelInScope,
18
20
  lookupResolved,
19
21
  buildHiddenParams,
20
22
  collectGroupedParamNames,
@@ -116,12 +118,17 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
116
118
  lines.push(' end');
117
119
  lines.push('end');
118
120
 
119
- files.push({
120
- path: `rbi/workos/${fileName(model.name)}.rbi`,
121
- content: lines.join('\n'),
122
- integrateTarget: true,
123
- overwriteExisting: true,
124
- });
121
+ // FR-1.4: write the per-model .rbi only when in scope. The client.rbi
122
+ // aggregate (section 3) stays on the full set so sigs for out-of-scope
123
+ // services whose .rb/.rbi still exist keep resolving.
124
+ if (isModelInScope(model.name, ctx)) {
125
+ files.push({
126
+ path: `rbi/workos/${fileName(model.name)}.rbi`,
127
+ content: lines.join('\n'),
128
+ integrateTarget: true,
129
+ overwriteExisting: true,
130
+ });
131
+ }
125
132
  }
126
133
 
127
134
  // 2. Generate service RBI files
@@ -137,6 +144,10 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
137
144
  const exportedClasses = buildExportedClassNameSet(ctx);
138
145
 
139
146
  for (const [mountTarget, group] of groups) {
147
+ // Scoped run: emit per-service .rbi only for selected mount targets. The
148
+ // client.rbi aggregate loop below intentionally stays on the FULL `groups`
149
+ // set so it keeps emitting sigs for every service whose .rb still exists.
150
+ if (!isMountInScope(mountTarget, ctx)) continue;
140
151
  const resolvedTarget = resolveServiceTarget(mountTarget, exportedClasses);
141
152
  const cls = className(resolvedTarget);
142
153
  const lines: string[] = [];
@@ -15,7 +15,7 @@ import { mapTypeRefForYard } from './type-map.js';
15
15
  import {
16
16
  buildResolvedLookup,
17
17
  lookupResolved,
18
- groupByMount,
18
+ scopedMountGroups,
19
19
  getOpDefaults,
20
20
  getOpInferFromClient,
21
21
  buildHiddenParams,
@@ -33,7 +33,7 @@ import { buildGroupOwnerMap, collectVariantsForMountTarget, emitInlineVariantCla
33
33
  export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
34
34
  const files: GeneratedFile[] = [];
35
35
 
36
- const groups = groupByMount(ctx);
36
+ const groups = scopedMountGroups(ctx);
37
37
  const lookup = buildResolvedLookup(ctx);
38
38
  const modelNames = new Set(ctx.spec.models.map((m) => m.name));
39
39
  const enumNames = new Set(ctx.spec.enums.map((e) => e.name));
package/src/ruby/tests.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ApiSpec, EmitterContext, GeneratedFile, Model, Operation, ResolvedWrapper, TypeRef } from '@workos/oagen';
2
+ import { assignModelsToServices } from '@workos/oagen';
2
3
  import {
3
4
  className,
4
5
  fileName,
@@ -10,15 +11,19 @@ import {
10
11
  resolveMethodName,
11
12
  buildExportedClassNameSet,
12
13
  resolveServiceTarget,
14
+ buildMountDirMap,
13
15
  } from './naming.js';
14
16
  import {
15
17
  buildResolvedLookup,
16
- groupByMount,
18
+ scopedMountGroups,
17
19
  lookupResolved,
18
20
  buildHiddenParams,
19
21
  collectBodyFieldTypes,
22
+ isModelInScope,
23
+ fileExistsAfterRun,
20
24
  } from '../shared/resolved-ops.js';
21
25
  import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
26
+ import { classifyUnassignedModel } from './models.js';
22
27
  import { resolveWrapperParams } from '../shared/wrapper-utils.js';
23
28
  import { buildGroupOwnerMap, pickVariantParamType } from './parameter-groups.js';
24
29
 
@@ -34,7 +39,7 @@ import { buildGroupOwnerMap, pickVariantParamType } from './parameter-groups.js'
34
39
  export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
35
40
  const files: GeneratedFile[] = [];
36
41
 
37
- const groups = groupByMount(ctx);
42
+ const groups = scopedMountGroups(ctx);
38
43
  const models = spec.models as Model[];
39
44
  const modelByName = new Map<string, Model>();
40
45
  for (const m of models) modelByName.set(m.name, m);
@@ -203,7 +208,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
203
208
  });
204
209
  }
205
210
 
206
- files.push(generateModelRoundTripTest(spec));
211
+ files.push(generateModelRoundTripTest(spec, ctx));
207
212
 
208
213
  return files;
209
214
  }
@@ -212,8 +217,19 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
212
217
  * Emit test/workos/model_round_trip_test.rb that round-trips every non-wrapper
213
218
  * model through `.new(json)` and `.to_json`, asserting the result is a Hash and
214
219
  * that required fields appear with the seeded values.
220
+ *
221
+ * This is a whole-suite AGGREGATE: it references `WorkOS::<Class>` for every
222
+ * model. Under a scoped (`--services`) run only the selected services' per-model
223
+ * `.rb` files are (re)written, so a test referencing a brand-new out-of-scope
224
+ * model whose file was never emitted would raise `NameError: uninitialized
225
+ * constant`. Each per-model test is therefore gated by {@link fileExistsAfterRun}
226
+ * on the SAME path `models.ts` writes (`lib/workos/<dir>/<file>.rb`): emit only
227
+ * when that file will exist on disk after the run — in-scope (emitted this run)
228
+ * OR already present from a prior run (`priorTargetManifestPaths`). Brand-new
229
+ * out-of-scope models are excluded; renamed/removed-but-on-disk models are
230
+ * retained. A full run (scoping inert) keeps every model.
215
231
  */
216
- function generateModelRoundTripTest(spec: ApiSpec): GeneratedFile {
232
+ function generateModelRoundTripTest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
217
233
  const lines: string[] = [];
218
234
  lines.push(`require 'test_helper'`);
219
235
  lines.push('');
@@ -223,7 +239,24 @@ function generateModelRoundTripTest(spec: ApiSpec): GeneratedFile {
223
239
  const enumNames = new Set(spec.enums.map((e) => e.name));
224
240
  const emitted = new Set<string>();
225
241
 
242
+ // Resolve each model's per-model file path EXACTLY as models.ts does, so the
243
+ // scope gate keys on the same path the per-model FILE writer uses.
244
+ const modelToService = assignModelsToServices(models, ctx.spec.services, ctx.modelHints);
245
+ const mountDirMap = buildMountDirMap(ctx);
246
+ const dirFor = (modelName: string): string => {
247
+ const service = modelToService.get(modelName);
248
+ if (!service) return classifyUnassignedModel(modelName);
249
+ return mountDirMap.get(service) ?? classifyUnassignedModel(modelName);
250
+ };
251
+
226
252
  for (const model of models) {
253
+ // Skip models whose per-model `.rb` file will NOT exist on disk after this
254
+ // run (brand-new + out-of-scope under `--services`). Without this gate the
255
+ // aggregate would reference an undefined constant. The path mirrors
256
+ // models.ts's `lib/workos/${dirFor}/${fileName}.rb`.
257
+ const modelFilePath = `lib/workos/${dirFor(model.name)}/${fileName(model.name)}.rb`;
258
+ if (!fileExistsAfterRun(modelFilePath, isModelInScope(model.name, ctx), ctx)) continue;
259
+
227
260
  // Avoid duplicate test names when two IR model names collapse to the same
228
261
  // snake_case file name (we use the file name as the test suffix).
229
262
  const fileBase = fileName(model.name);