@workos/oagen-emitters 0.18.4 → 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 (43) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +7 -0
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/dist/{plugin-Cciic50q.mjs → plugin-BXDPA9pJ.mjs} +140 -75
  6. package/dist/plugin-BXDPA9pJ.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/models.ts +11 -5
  11. package/src/dotnet/resources.ts +3 -3
  12. package/src/dotnet/tests.ts +3 -3
  13. package/src/go/resources.ts +3 -3
  14. package/src/go/tests.ts +3 -3
  15. package/src/kotlin/enums.ts +21 -11
  16. package/src/kotlin/models.ts +19 -7
  17. package/src/kotlin/resources.ts +2 -2
  18. package/src/kotlin/tests.ts +2 -2
  19. package/src/node/enums.ts +8 -5
  20. package/src/node/models.ts +29 -21
  21. package/src/node/resources.ts +12 -1
  22. package/src/node/tests.ts +7 -2
  23. package/src/php/enums.ts +18 -5
  24. package/src/php/index.ts +11 -3
  25. package/src/php/models.ts +11 -5
  26. package/src/php/resources.ts +6 -4
  27. package/src/php/tests.ts +6 -3
  28. package/src/python/enums.ts +39 -28
  29. package/src/python/models.ts +27 -18
  30. package/src/python/resources.ts +3 -3
  31. package/src/python/tests.ts +3 -3
  32. package/src/ruby/enums.ts +28 -19
  33. package/src/ruby/models.ts +23 -12
  34. package/src/ruby/rbi.ts +17 -6
  35. package/src/ruby/resources.ts +2 -2
  36. package/src/ruby/tests.ts +2 -2
  37. package/src/rust/enums.ts +9 -1
  38. package/src/rust/models.ts +18 -5
  39. package/src/rust/resources.ts +8 -1
  40. package/src/rust/tests.ts +2 -2
  41. package/src/shared/resolved-ops.ts +47 -0
  42. package/test/shared/synthetic-enum-seed.test.ts +79 -0
  43. package/dist/plugin-Cciic50q.mjs.map +0 -1
package/src/php/tests.ts CHANGED
@@ -20,7 +20,7 @@ import { isListWrapperModel } from './models.js';
20
20
  import { generateFixtures } from './fixtures.js';
21
21
  import {
22
22
  getMountTarget,
23
- groupByMount,
23
+ scopedMountGroups,
24
24
  buildHiddenParams,
25
25
  getOpDefaults,
26
26
  getOpInferFromClient,
@@ -39,9 +39,12 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
39
39
 
40
40
  // Collect all operations per mount target using resolved per-operation mounts.
41
41
  // This correctly handles operationHint mountOn overrides (e.g., audit_logs_retention → AuditLogs).
42
- 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);
43
46
  const mountGroups = new Map<string, { op: Operation; service: Service; resolvedOp?: ResolvedOperation }[]>();
44
- if (mountGroupsFromResolved.size > 0) {
47
+ if (mountGroupsFromResolved.size > 0 || ctx.scopedServices?.size) {
45
48
  for (const [target, group] of mountGroupsFromResolved) {
46
49
  mountGroups.set(
47
50
  target,
@@ -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
 
@@ -4,6 +4,7 @@ import { mapTypeRef } from './type-map.js';
4
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);
@@ -457,12 +464,14 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
457
464
 
458
465
  lines.push(' return result');
459
466
 
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
- });
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
+ }
466
475
  if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
467
476
  emittedModelSymbolsByDir.get(dirName)!.push(model.name);
468
477
  const regularNatural = originalModelToService.get(model.name);
@@ -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,7 +25,7 @@ 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,
@@ -118,9 +118,9 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
118
118
  const accessPaths = buildServiceAccessPaths(spec.services, ctx);
119
119
 
120
120
  // Generate per-mount-target test files (merges all sub-services into one file)
121
- const mountGroups = groupByMount(ctx);
121
+ const mountGroups = scopedMountGroups(ctx);
122
122
  const testEntries: Array<{ name: string; operations: Operation[]; resolvedOps?: ResolvedOperation[] }> =
123
- mountGroups.size > 0
123
+ mountGroups.size > 0 || ctx.scopedServices?.size
124
124
  ? [...mountGroups].map(([name, group]) => ({
125
125
  name,
126
126
  operations: group.operations,
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
@@ -13,7 +13,7 @@ import {
13
13
  } from './naming.js';
14
14
  import {
15
15
  buildResolvedLookup,
16
- groupByMount,
16
+ scopedMountGroups,
17
17
  lookupResolved,
18
18
  buildHiddenParams,
19
19
  collectBodyFieldTypes,
@@ -34,7 +34,7 @@ import { buildGroupOwnerMap, pickVariantParamType } from './parameter-groups.js'
34
34
  export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
35
35
  const files: GeneratedFile[] = [];
36
36
 
37
- const groups = groupByMount(ctx);
37
+ const groups = scopedMountGroups(ctx);
38
38
  const models = spec.models as Model[];
39
39
  const modelByName = new Map<string, Model>();
40
40
  for (const m of models) modelByName.set(m.name, m);
package/src/rust/enums.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
2
2
  import { typeName, moduleName, variantName } from './naming.js';
3
+ import { isEnumInScope } from '../shared/resolved-ops.js';
3
4
 
4
5
  /**
5
6
  * Generate one Rust source file per enum under `src/enums/`, plus a
@@ -17,7 +18,7 @@ import { typeName, moduleName, variantName } from './naming.js';
17
18
  * variant and re-serialize as the canonical wire string.
18
19
  * - `Display`, `FromStr`, and `AsRef<str>` are implemented for ergonomics.
19
20
  */
20
- export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFile[] {
21
+ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
21
22
  const files: GeneratedFile[] = [];
22
23
  const seen = new Set<string>();
23
24
  const moduleNames: string[] = [];
@@ -27,8 +28,15 @@ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFil
27
28
  const mod = moduleName(e.name);
28
29
  if (seen.has(mod)) continue;
29
30
  seen.add(mod);
31
+ // The barrel (`src/enums/mod.rs`) must declare every enum's module so Rust
32
+ // compiles even in a scoped run — `moduleNames` is collected from the FULL
33
+ // enum set regardless of scope.
30
34
  moduleNames.push(mod);
31
35
 
36
+ // Only the per-enum `.rs` FILE write is scoped (FR-1.4). In a scoped run we
37
+ // skip emitting files for out-of-scope enums, but the barrel above still
38
+ // declares their modules (their existing `.rs` files stay untouched on disk).
39
+ if (!isEnumInScope(e.name, ctx)) continue;
32
40
  files.push({
33
41
  path: `src/enums/${mod}.rs`,
34
42
  content: renderEnum(e),
@@ -2,6 +2,7 @@ import type { Model, EmitterContext, GeneratedFile, Field, TypeRef } from '@work
2
2
  import { typeName, domainFieldName, moduleName } from './naming.js';
3
3
  import { mapTypeRef, makeOptional, UnionRegistry } from './type-map.js';
4
4
  import { applySecretRedaction } from './secret.js';
5
+ import { isModelInScope } from '../shared/resolved-ops.js';
5
6
 
6
7
  const HEADER_PLACEHOLDER = ''; // engine prepends fileHeader()
7
8
 
@@ -32,15 +33,27 @@ export function generateModels(models: Model[], ctx: EmitterContext, registry: U
32
33
  const mod = moduleName(model.name);
33
34
  if (seen.has(mod)) continue;
34
35
  seen.add(mod);
36
+ // The barrel (`src/models/mod.rs`) must declare every model's module so
37
+ // Rust compiles even in a scoped run — `moduleNames` is collected from the
38
+ // FULL model set regardless of scope.
35
39
  moduleNames.push(mod);
36
40
 
41
+ // renderModel registers inline unions into `registry` as a side effect, and
42
+ // `_unions.rs` is rendered later (in generateClient) from that registry — so
43
+ // it MUST run for every model, even out-of-scope ones, or scoped runs drop
44
+ // unions. Compute content unconditionally; only the per-model `.rs` FILE write
45
+ // is scoped (FR-1.4). The barrel above still declares every module, and an
46
+ // out-of-scope model's existing `.rs` file stays untouched on disk.
37
47
  const hintPath = ctx.overlayLookup?.fileBySymbol?.get(model.name);
38
48
  const path = hintPath ?? `src/models/${mod}.rs`;
39
- files.push({
40
- path,
41
- content: renderModel(model, registry, taggedVariantFields.get(model.name)),
42
- overwriteExisting: true,
43
- });
49
+ const content = renderModel(model, registry, taggedVariantFields.get(model.name));
50
+ if (isModelInScope(model.name, ctx)) {
51
+ files.push({
52
+ path,
53
+ content,
54
+ overwriteExisting: true,
55
+ });
56
+ }
44
57
  }
45
58
 
46
59
  // Always include the unions module in the barrel so downstream stages
@@ -14,7 +14,7 @@ import { fieldName, domainFieldName, methodName, typeName, moduleName, variantNa
14
14
  import { mapTypeRef, makeOptional, UnionRegistry } from './type-map.js';
15
15
  import { applySecretRedaction } from './secret.js';
16
16
  import { parsePathTemplate } from '../shared/path-template.js';
17
- import { groupByMount, buildResolvedLookup } from '../shared/resolved-ops.js';
17
+ import { groupByMount, buildResolvedLookup, isMountInScope } from '../shared/resolved-ops.js';
18
18
  import { resolveWrapperParams, type ResolvedWrapperParam } from '../shared/wrapper-utils.js';
19
19
 
20
20
  /**
@@ -32,7 +32,14 @@ export function generateResources(_services: Service[], ctx: EmitterContext, reg
32
32
  if (group.operations.length === 0) continue;
33
33
  const basename = moduleName(mountName);
34
34
  const struct = mountStructName(mountName);
35
+ // The barrel (`src/resources/mod.rs`) must list every mount's module so
36
+ // Rust compiles even in a scoped run — `exports` is collected from the
37
+ // FULL groupByMount set regardless of scope.
35
38
  exports.push({ module: basename, struct });
39
+ // Only the per-service resource `.rs` FILE write is scoped. In a scoped
40
+ // run we skip emitting files for out-of-scope mounts, but the barrel above
41
+ // still references their modules (their existing `.rs` files stay on disk).
42
+ if (!isMountInScope(mountName, ctx)) continue;
36
43
  files.push({
37
44
  path: `src/resources/${basename}.rs`,
38
45
  content: renderMountGroup(mountName, group.resolvedOps, ctx, registry, lookup),
package/src/rust/tests.ts CHANGED
@@ -11,7 +11,7 @@ import type {
11
11
  TypeRef,
12
12
  } from '@workos/oagen';
13
13
  import { methodName, moduleName, typeName } from './naming.js';
14
- import { groupByMount } from '../shared/resolved-ops.js';
14
+ import { scopedMountGroups } from '../shared/resolved-ops.js';
15
15
  import { exampleFor, generateFixtures } from './fixtures.js';
16
16
  import { resolveWrapperParams } from '../shared/wrapper-utils.js';
17
17
  import { isInlineEnvelopeList } from './resources.js';
@@ -43,7 +43,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
43
43
  overwriteExisting: true,
44
44
  });
45
45
 
46
- const groups = groupByMount(ctx);
46
+ const groups = scopedMountGroups(ctx);
47
47
  const modelMap = new Map(spec.models.map((m) => [m.name, m]));
48
48
  const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
49
49
 
@@ -94,6 +94,53 @@ export function groupByMount(ctx: EmitterContext): Map<string, MountGroup> {
94
94
  return groups;
95
95
  }
96
96
 
97
+ /**
98
+ * Like {@link groupByMount}, but for a scoped (`--services`) run returns ONLY the
99
+ * mount groups the run selected (`ctx.scopedServices`, POST-MOUNT names). When
100
+ * scoping is inactive the full set is returned unchanged.
101
+ *
102
+ * Use this for PER-SERVICE resource/test emission. Do NOT use it for
103
+ * aggregate/barrel files (Rust `mod.rs`, Ruby `client.rbi`, the root client) —
104
+ * those must continue to list every service, so they keep calling
105
+ * {@link groupByMount} over the full set; otherwise a scoped run would drop
106
+ * sibling modules and break the build/type-check.
107
+ */
108
+ export function scopedMountGroups(ctx: EmitterContext): Map<string, MountGroup> {
109
+ const groups = groupByMount(ctx);
110
+ const scope = ctx.scopedServices;
111
+ if (!scope || scope.size === 0) return groups;
112
+ return new Map([...groups].filter(([mountName]) => scope.has(mountName)));
113
+ }
114
+
115
+ /**
116
+ * True when a POST-MOUNT service name should be emitted in the current run.
117
+ * Inactive scoping (no `ctx.scopedServices`) ⇒ everything is in scope. Use this
118
+ * for inline per-service gates (e.g. manifest loops keyed by `getMountTarget`).
119
+ */
120
+ export function isMountInScope(mountName: string, ctx: EmitterContext): boolean {
121
+ const scope = ctx.scopedServices;
122
+ return !scope || scope.size === 0 || scope.has(mountName);
123
+ }
124
+
125
+ /**
126
+ * True when a MODEL's per-model FILE should be written in the current run (FR-1.4).
127
+ * A scoped run sets `ctx.scopedModelNames` to the models reachable from the
128
+ * selected services; out-of-scope models are left untouched on disk. Inactive
129
+ * scoping ⇒ everything is in scope. NOTE: gate only the per-model FILE write —
130
+ * the model must still appear in barrels/indexes (built from the full set) so the
131
+ * untouched on-disk file stays importable.
132
+ */
133
+ export function isModelInScope(modelName: string, ctx: EmitterContext): boolean {
134
+ const scope = ctx.scopedModelNames;
135
+ return !scope || scope.has(modelName);
136
+ }
137
+
138
+ /** Like {@link isModelInScope} but for an ENUM's per-enum file (`ctx.scopedEnumNames`). */
139
+ export function isEnumInScope(enumName: string, ctx: EmitterContext): boolean {
140
+ const scope = ctx.scopedEnumNames;
141
+ return !scope || scope.has(enumName);
142
+ }
143
+
97
144
  /**
98
145
  * Get the mount target for an IR service.
99
146
  * Checks the first resolved operation that belongs to this service.