@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/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as workosEmittersPlugin } from "./plugin-Cciic50q.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-BXDPA9pJ.mjs";
2
2
  export { workosEmittersPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/oagen-emitters",
3
- "version": "0.18.4",
3
+ "version": "0.19.0",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -42,8 +42,8 @@
42
42
  "@commitlint/config-conventional": "^21.0.2",
43
43
  "@types/node": "^26.0.0",
44
44
  "husky": "^9.1.7",
45
- "oxfmt": "^0.55.0",
46
- "oxlint": "^1.70.0",
45
+ "oxfmt": "^0.56.0",
46
+ "oxlint": "^1.71.0",
47
47
  "prettier": "^3.8.4",
48
48
  "tsdown": "^0.22.3",
49
49
  "tsx": "^4.22.4",
@@ -54,6 +54,6 @@
54
54
  "node": ">=24.10.0"
55
55
  },
56
56
  "dependencies": {
57
- "@workos/oagen": "^0.22.7"
57
+ "@workos/oagen": "^0.23.0"
58
58
  }
59
59
  }
@@ -3,6 +3,7 @@ import { walkTypeRef } from '@workos/oagen';
3
3
  import { className, deprecationMessage, escapeCsAttributeString, humanize } from './naming.js';
4
4
  import { setEnumAliases, setSingleValueEnumNames } from './type-map.js';
5
5
  import { enrichModelsFromSpec } from '../shared/model-utils.js';
6
+ import { isEnumInScope } from '../shared/resolved-ops.js';
6
7
 
7
8
  /**
8
9
  * Generate C# enum definitions from IR Enum definitions.
@@ -135,11 +136,16 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
135
136
  lines.push(' }');
136
137
  lines.push('}');
137
138
 
138
- files.push({
139
- path: `Enums/${typeName}.cs`,
140
- content: lines.join('\n'),
141
- overwriteExisting: true,
142
- });
139
+ // FR-1.4: write the per-enum FILE only when in scope. .NET uses a flat
140
+ // Enums/ directory with C# namespaces (no barrel/index), so an
141
+ // out-of-scope enum left untouched on disk stays referenceable.
142
+ if (isEnumInScope(enumDef.name, ctx)) {
143
+ files.push({
144
+ path: `Enums/${typeName}.cs`,
145
+ content: lines.join('\n'),
146
+ overwriteExisting: true,
147
+ });
148
+ }
143
149
  }
144
150
 
145
151
  return files;
@@ -26,6 +26,7 @@ import {
26
26
  isListMetadataModel,
27
27
  collectNonPaginatedResponseModelNames,
28
28
  } from '../shared/model-utils.js';
29
+ import { isModelInScope } from '../shared/resolved-ops.js';
29
30
  export { isListWrapperModel, isListMetadataModel };
30
31
 
31
32
  /**
@@ -355,11 +356,16 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
355
356
  lines.push(' }');
356
357
  lines.push('}');
357
358
 
358
- files.push({
359
- path: `Entities/${csClassName}.cs`,
360
- content: lines.join('\n'),
361
- overwriteExisting: true,
362
- });
359
+ // FR-1.4: write the per-model FILE only when in scope. .NET uses a flat
360
+ // Entities/ directory with C# namespaces (no barrel/index), so an
361
+ // out-of-scope model left untouched on disk stays referenceable.
362
+ if (isModelInScope(model.name, ctx)) {
363
+ files.push({
364
+ path: `Entities/${csClassName}.cs`,
365
+ content: lines.join('\n'),
366
+ overwriteExisting: true,
367
+ });
368
+ }
363
369
  }
364
370
 
365
371
  return files;
@@ -35,7 +35,7 @@ import {
35
35
  import {
36
36
  buildResolvedLookup,
37
37
  lookupResolved,
38
- groupByMount,
38
+ scopedMountGroups,
39
39
  getOpDefaults,
40
40
  getOpInferFromClient,
41
41
  buildHiddenParams,
@@ -71,10 +71,10 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
71
71
  if (services.length === 0) return [];
72
72
 
73
73
  const files: GeneratedFile[] = [];
74
- const mountGroups = groupByMount(ctx);
74
+ const mountGroups = scopedMountGroups(ctx);
75
75
 
76
76
  const entries: Array<{ name: string; operations: Operation[] }> =
77
- mountGroups.size > 0
77
+ mountGroups.size > 0 || ctx.scopedServices?.size
78
78
  ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
79
79
  : services.map((s) => ({ name: resolveResourceClassName(s, ctx), operations: s.operations }));
80
80
 
@@ -16,7 +16,7 @@ import { resolveResourceClassName, sortPathParamsByTemplateOrder, optionsClassNa
16
16
  import { generateFixtures, generateModelFixture } from './fixtures.js';
17
17
  import { isListWrapperModel } from './models.js';
18
18
  import {
19
- groupByMount,
19
+ scopedMountGroups,
20
20
  buildResolvedLookup,
21
21
  lookupResolved,
22
22
  buildHiddenParams,
@@ -40,9 +40,9 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
40
40
  }
41
41
 
42
42
  // Generate per-mount-target test files
43
- const mountGroups = groupByMount(ctx);
43
+ const mountGroups = scopedMountGroups(ctx);
44
44
  const testEntries: Array<{ name: string; operations: Operation[] }> =
45
- mountGroups.size > 0
45
+ mountGroups.size > 0 || ctx.scopedServices?.size
46
46
  ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
47
47
  : spec.services.map((s) => ({
48
48
  name: resolveResourceClassName(s, ctx),
@@ -21,7 +21,7 @@ import {
21
21
  import {
22
22
  buildResolvedLookup,
23
23
  lookupResolved,
24
- groupByMount,
24
+ scopedMountGroups,
25
25
  getOpDefaults,
26
26
  getOpInferFromClient,
27
27
  buildHiddenParams,
@@ -60,11 +60,11 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
60
60
  if (services.length === 0) return [];
61
61
 
62
62
  const files: GeneratedFile[] = [];
63
- const mountGroups = groupByMount(ctx);
63
+ const mountGroups = scopedMountGroups(ctx);
64
64
 
65
65
  // If no resolved operations, fall back to raw services
66
66
  const entries: Array<{ name: string; operations: Operation[] }> =
67
- mountGroups.size > 0
67
+ mountGroups.size > 0 || ctx.scopedServices?.size
68
68
  ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
69
69
  : services.map((s) => ({ name: resolveResourceClassName(s, ctx), operations: s.operations }));
70
70
 
package/src/go/tests.ts CHANGED
@@ -5,7 +5,7 @@ import { resolveResourceClassName, paramsStructName, sortPathParamsByTemplateOrd
5
5
  import { buildServiceAccessPaths } from './client.js';
6
6
  import { generateFixtures } from './fixtures.js';
7
7
  import { isListWrapperModel } from './models.js';
8
- import { groupByMount, buildResolvedLookup, lookupResolved, buildHiddenParams } from '../shared/resolved-ops.js';
8
+ import { scopedMountGroups, buildResolvedLookup, lookupResolved, buildHiddenParams } from '../shared/resolved-ops.js';
9
9
  import { existsSync, readFileSync } from 'node:fs';
10
10
  import { resolve } from 'node:path';
11
11
 
@@ -85,9 +85,9 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
85
85
  const accessPaths = buildServiceAccessPaths(spec.services, ctx);
86
86
 
87
87
  // Generate per-mount-target test files
88
- const mountGroups = groupByMount(ctx);
88
+ const mountGroups = scopedMountGroups(ctx);
89
89
  const testEntries: Array<{ name: string; operations: Operation[] }> =
90
- mountGroups.size > 0
90
+ mountGroups.size > 0 || ctx.scopedServices?.size
91
91
  ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
92
92
  : spec.services.map((s) => ({
93
93
  name: resolveResourceClassName(s, ctx),
@@ -1,5 +1,6 @@
1
1
  import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
2
2
  import { className, ktStringLiteral } from './naming.js';
3
+ import { isEnumInScope } from '../shared/resolved-ops.js';
3
4
 
4
5
  const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
5
6
  const ENUMS_PACKAGE = 'com.workos.types';
@@ -24,7 +25,7 @@ export const enumCanonicalMap = new Map<string, string>();
24
25
  * shortest PascalCase name becomes canonical and the rest emit `typealias`
25
26
  * files pointing at the canonical class.
26
27
  */
27
- export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFile[] {
28
+ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
28
29
  if (enums.length === 0) return [];
29
30
 
30
31
  // Reset the canonical map on every generation run (guards against re-entry).
@@ -74,6 +75,11 @@ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFil
74
75
 
75
76
  const typeName = canonicalEnumTypeName(enumDef);
76
77
 
78
+ // FR-1.4: write per-enum FILES only when in scope. Each enum is its own
79
+ // `.kt` file (no barrel), so an out-of-scope enum left untouched on disk
80
+ // stays importable.
81
+ const enumInScope = isEnumInScope(enumDef.name, ctx);
82
+
77
83
  // Non-canonical enum: emit a typealias instead of a full enum class.
78
84
  const sharedSortEmitter = sharedSortEmitters.has(enumDef.name);
79
85
  const canonicalName = sharedSortEmitter
@@ -94,11 +100,13 @@ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFil
94
100
  aliasLine,
95
101
  '',
96
102
  ].join('\n');
97
- files.push({
98
- path: `${KOTLIN_SRC_PREFIX}${ENUMS_DIR}/${typeName}.kt`,
99
- content: aliasContent,
100
- overwriteExisting: true,
101
- });
103
+ if (enumInScope) {
104
+ files.push({
105
+ path: `${KOTLIN_SRC_PREFIX}${ENUMS_DIR}/${typeName}.kt`,
106
+ content: aliasContent,
107
+ overwriteExisting: true,
108
+ });
109
+ }
102
110
  continue;
103
111
  }
104
112
 
@@ -175,11 +183,13 @@ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFil
175
183
  lines.push('}');
176
184
  lines.push('');
177
185
 
178
- files.push({
179
- path: `${KOTLIN_SRC_PREFIX}${ENUMS_DIR}/${typeName}.kt`,
180
- content: lines.join('\n'),
181
- overwriteExisting: true,
182
- });
186
+ if (enumInScope) {
187
+ files.push({
188
+ path: `${KOTLIN_SRC_PREFIX}${ENUMS_DIR}/${typeName}.kt`,
189
+ content: lines.join('\n'),
190
+ overwriteExisting: true,
191
+ });
192
+ }
183
193
  }
184
194
 
185
195
  return files;
@@ -8,6 +8,7 @@ import {
8
8
  collectNonPaginatedResponseModelNames,
9
9
  collectReferencedListMetadataModels,
10
10
  } from '../shared/model-utils.js';
11
+ import { isModelInScope } from '../shared/resolved-ops.js';
11
12
 
12
13
  const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
13
14
  const MODELS_PACKAGE = 'com.workos.models';
@@ -123,10 +124,17 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
123
124
  for (const model of models) {
124
125
  if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
125
126
  const typeName = className(model.name);
127
+ // FR-1.4: write per-model FILES only when in scope. Each model is its own
128
+ // `.kt` file (no barrel), so an out-of-scope model left untouched on disk
129
+ // stays importable. The WorkOSEvent sealed interface below is an aggregate
130
+ // built from many event models, so it is NOT gated.
131
+ const modelInScope = isModelInScope(model.name, ctx);
126
132
 
127
133
  // Parent of a discriminated union: emit a sealed class.
128
134
  if (model.fields.length === 0 && discriminatedUnions.has(typeName)) {
129
- files.push(emitSealedUnion(typeName, discriminatedUnions.get(typeName)!));
135
+ if (modelInScope) {
136
+ files.push(emitSealedUnion(typeName, discriminatedUnions.get(typeName)!));
137
+ }
130
138
  continue;
131
139
  }
132
140
 
@@ -142,15 +150,19 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
142
150
  `typealias ${typeName} = ${canonicalType}`,
143
151
  '',
144
152
  ].join('\n');
145
- files.push({
146
- path: `${KOTLIN_SRC_PREFIX}${MODELS_DIR}/${typeName}.kt`,
147
- content: aliasContent,
148
- overwriteExisting: true,
149
- });
153
+ if (modelInScope) {
154
+ files.push({
155
+ path: `${KOTLIN_SRC_PREFIX}${MODELS_DIR}/${typeName}.kt`,
156
+ content: aliasContent,
157
+ overwriteExisting: true,
158
+ });
159
+ }
150
160
  continue;
151
161
  }
152
162
 
153
- files.push(emitDataClass(model));
163
+ if (modelInScope) {
164
+ files.push(emitDataClass(model));
165
+ }
154
166
  }
155
167
 
156
168
  // Generate the sealed WorkOSEvent interface. Collect all event envelope
@@ -34,7 +34,7 @@ import {
34
34
  import {
35
35
  buildResolvedLookup,
36
36
  lookupResolved,
37
- groupByMount,
37
+ scopedMountGroups,
38
38
  buildHiddenParams,
39
39
  getOpDefaults,
40
40
  getOpInferFromClient,
@@ -97,7 +97,7 @@ function promoteFieldType(f: Field): Field {
97
97
  export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
98
98
  if (services.length === 0) return [];
99
99
 
100
- const mountGroups = groupByMount(ctx);
100
+ const mountGroups = scopedMountGroups(ctx);
101
101
  if (mountGroups.size === 0) return [];
102
102
 
103
103
  const files: GeneratedFile[] = [];
@@ -22,7 +22,7 @@ import {
22
22
  } from './naming.js';
23
23
  import { mapTypeRef } from './type-map.js';
24
24
  import {
25
- groupByMount,
25
+ scopedMountGroups,
26
26
  lookupResolved,
27
27
  buildResolvedLookup,
28
28
  buildHiddenParams,
@@ -76,7 +76,7 @@ function promoteIso8601TypeRef(type: TypeRef, description: string | undefined):
76
76
  */
77
77
  export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
78
78
  const files: GeneratedFile[] = [];
79
- const mountGroups = groupByMount(ctx);
79
+ const mountGroups = scopedMountGroups(ctx);
80
80
  const resolvedLookup = buildResolvedLookup(ctx);
81
81
 
82
82
  const exportedClasses = buildExportedClassNameSet(ctx);
package/src/node/enums.ts CHANGED
@@ -5,6 +5,7 @@ import { docComment, assignModelsToEmittableServices } from './utils.js';
5
5
  import { isInlineEnum } from './type-map.js';
6
6
  import { isNodeOwnedService } from './options.js';
7
7
  import { liveSurfaceConstEnumMembers, liveSurfaceInterfacePath } from './live-surface.js';
8
+ import { isEnumInScope } from '../shared/resolved-ops.js';
8
9
 
9
10
  export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
10
11
  if (enums.length === 0) return [];
@@ -120,11 +121,13 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
120
121
  lines.push(` (typeof ${enumDef.name})[keyof typeof ${enumDef.name}];`);
121
122
  }
122
123
 
123
- files.push({
124
- path: `src/${dirName}/interfaces/${fileName(enumDef.name)}.interface.ts`,
125
- content: lines.join('\n'),
126
- skipIfExists: !hasNewValues,
127
- });
124
+ if (isEnumInScope(enumDef.name, ctx)) {
125
+ files.push({
126
+ path: `src/${dirName}/interfaces/${fileName(enumDef.name)}.interface.ts`,
127
+ content: lines.join('\n'),
128
+ skipIfExists: !hasNewValues,
129
+ });
130
+ }
128
131
  }
129
132
 
130
133
  return files;
@@ -45,7 +45,7 @@ import {
45
45
  import { liveSurfaceHasExistingSdk, liveSurfaceHasManagedFile, liveSurfaceInterfacePath } from './live-surface.js';
46
46
  import { isNodeOwnedService, isHandOwnedType } from './options.js';
47
47
  import { unwrapListModel } from './fixtures.js';
48
- import { groupByMount, buildResolvedLookup, lookupResolved } from '../shared/resolved-ops.js';
48
+ import { groupByMount, buildResolvedLookup, lookupResolved, isModelInScope } from '../shared/resolved-ops.js';
49
49
  import { resolveWrapperParams } from '../shared/wrapper-utils.js';
50
50
  import { collectWrapperResponseModels } from './wrappers.js';
51
51
  import { resolveResourceClassName } from './resources.js';
@@ -308,11 +308,13 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
308
308
  const aliasLines = importSymbols
309
309
  ? [`import type { ${importSymbols} } from '${canonRelPath}';`, '', ...aliasExports]
310
310
  : [...aliasExports];
311
- files.push({
312
- path: aliasPath,
313
- content: aliasLines.join('\n'),
314
- overwriteExisting: true,
315
- });
311
+ if (isModelInScope(model.name, ctx)) {
312
+ files.push({
313
+ path: aliasPath,
314
+ content: aliasLines.join('\n'),
315
+ overwriteExisting: true,
316
+ });
317
+ }
316
318
  continue;
317
319
  }
318
320
 
@@ -745,11 +747,13 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
745
747
  }
746
748
  }
747
749
 
748
- files.push({
749
- path: filePath,
750
- content: pruneUnusedImports(lines).join('\n'),
751
- overwriteExisting: true,
752
- });
750
+ if (isModelInScope(model.name, ctx)) {
751
+ files.push({
752
+ path: filePath,
753
+ content: pruneUnusedImports(lines).join('\n'),
754
+ overwriteExisting: true,
755
+ });
756
+ }
753
757
  }
754
758
 
755
759
  return files;
@@ -943,11 +947,13 @@ export function generateSerializers(
943
947
  parts.push(`serialize${canonDomainName} as serialize${domainName}`);
944
948
  }
945
949
  const reexportContent = `export { ${parts.join(', ')} } from '${rel}';`;
946
- files.push({
947
- path: serializerPath,
948
- content: reexportContent,
949
- overwriteExisting: true,
950
- });
950
+ if (isModelInScope(model.name, ctx)) {
951
+ files.push({
952
+ path: serializerPath,
953
+ content: reexportContent,
954
+ overwriteExisting: true,
955
+ });
956
+ }
951
957
  continue;
952
958
  }
953
959
  // The alias is response-reachable, but the canonical model is
@@ -997,11 +1003,13 @@ export function generateSerializers(
997
1003
  ),
998
1004
  ];
999
1005
 
1000
- files.push({
1001
- path: serializerPath,
1002
- content: pruneUnusedImports(lines).join('\n'),
1003
- overwriteExisting: true,
1004
- });
1006
+ if (isModelInScope(model.name, ctx)) {
1007
+ files.push({
1008
+ path: serializerPath,
1009
+ content: pruneUnusedImports(lines).join('\n'),
1010
+ overwriteExisting: true,
1011
+ });
1012
+ }
1005
1013
  }
1006
1014
 
1007
1015
  (ctx as any)._skippedSerializeModels = skippedSerializeModels;
@@ -78,6 +78,7 @@ import {
78
78
  buildResolvedLookup,
79
79
  lookupResolved,
80
80
  groupByMount,
81
+ isMountInScope,
81
82
  getOpDefaults,
82
83
  getOpInferFromClient,
83
84
  } from '../shared/resolved-ops.js';
@@ -718,11 +719,18 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
718
719
  // multiple IR services mount to the same resource class.
719
720
  const mountGroups = groupByMount(ctx);
720
721
  const mergedServices: Service[] =
721
- mountGroups.size > 0 ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations })) : services;
722
+ mountGroups.size > 0 || ctx.scopedServices?.size
723
+ ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
724
+ : services;
722
725
 
723
726
  const topLevelEnumNames = new Set(ctx.spec.enums.map((e) => e.name));
724
727
 
725
728
  for (const service of mergedServices) {
729
+ // Scope gate: in a scoped (`--services`) run, only emit per-service resource
730
+ // files for the selected post-mount names. `service.name` is the mount-group
731
+ // key (the POST-MOUNT name that matches `ctx.scopedServices`). Applied as an
732
+ // additional early continue ahead of the node-owned/coverage skip logic.
733
+ if (!isMountInScope(service.name, ctx)) continue;
726
734
  const isOwnedService = isNodeOwnedService(ctx, service.name, resolveResourceClassName(service, ctx));
727
735
  if (!isOwnedService && isServiceCoveredByExisting(service, ctx)) {
728
736
  if (!hasMethodsAbsentFromBaseline(service, ctx)) {
@@ -770,6 +778,9 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
770
778
  // stable. Placing them under `interfaces/` lets the per-service barrel
771
779
  // pick them up automatically.
772
780
  for (const service of mergedServices) {
781
+ // Scope gate: keep the per-service options interfaces aligned with the
782
+ // resource files emitted above — only the selected post-mount names.
783
+ if (!isMountInScope(service.name, ctx)) continue;
773
784
  const isOwnedService = isNodeOwnedService(ctx, service.name, resolveResourceClassName(service, ctx));
774
785
  if (!isOwnedService && isServiceCoveredByExisting(service, ctx) && !hasMethodsAbsentFromBaseline(service, ctx))
775
786
  continue;
package/src/node/tests.ts CHANGED
@@ -35,7 +35,7 @@ import {
35
35
  modelHasNewFields,
36
36
  computeNonEventReachable,
37
37
  } from './utils.js';
38
- import { groupByMount, buildResolvedLookup, lookupResolved } from '../shared/resolved-ops.js';
38
+ import { groupByMount, buildResolvedLookup, lookupResolved, isMountInScope } from '../shared/resolved-ops.js';
39
39
  import { isNodeOwnedService, nodeOptions, planOperationFor } from './options.js';
40
40
 
41
41
  type BaselineMethod = {
@@ -137,7 +137,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
137
137
  }
138
138
 
139
139
  const testEntries: Array<{ name: string; operations: Operation[] }> =
140
- mountGroups.size > 0
140
+ mountGroups.size > 0 || ctx.scopedServices?.size
141
141
  ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
142
142
  : spec.services.map((s) => ({ name: resolveResourceClassName(s, ctx), operations: s.operations }));
143
143
 
@@ -159,6 +159,11 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
159
159
  }
160
160
 
161
161
  for (const { name: mountName, operations } of testEntries) {
162
+ // Scope gate: in a scoped (`--services`) run, only emit per-service test
163
+ // files for the selected post-mount names. `mountName` is the mount-group
164
+ // key (the POST-MOUNT name that matches `ctx.scopedServices`). Applied as an
165
+ // additional early continue ahead of the node-owned/coverage skip logic.
166
+ if (!isMountInScope(mountName, ctx)) continue;
162
167
  if (operations.length === 0) continue;
163
168
  const mergedService: Service = { name: mountName, operations };
164
169
  const isOwnedService = isNodeOwnedService(ctx, mountName, resolveResourceClassName(mergedService, ctx));
package/src/php/enums.ts CHANGED
@@ -2,6 +2,7 @@ import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
2
2
  import { toPascalCase } from '@workos/oagen';
3
3
  import { className, resolveEnumName } from './naming.js';
4
4
  import { phpDocComment } from './utils.js';
5
+ import { isEnumInScope } from '../shared/resolved-ops.js';
5
6
 
6
7
  /**
7
8
  * Generate PHP enum files from IR enums.
@@ -17,6 +18,16 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
17
18
  if (emittedCanonical.has(canonical)) continue; // skip aliases
18
19
  emittedCanonical.add(canonical);
19
20
 
21
+ // FR-1.4: write the per-enum FILE only when in scope. PHP dedupes
22
+ // value-identical enums onto a single canonical class, so the canonical
23
+ // file is needed when EITHER the canonical name OR any alias resolving to
24
+ // it is reachable from the selected services. PSR-4 (one class per file
25
+ // under lib/Resource/, no barrel) means an out-of-scope enum is simply
26
+ // left untouched on disk and stays loadable.
27
+ const enumInScope = enums.some(
28
+ (other) => resolveEnumName(other.name) === canonical && isEnumInScope(other.name, ctx),
29
+ );
30
+
20
31
  const name = className(canonical);
21
32
  const _isAllStrings = e.values.every((v) => typeof v.value === 'string');
22
33
  const isAllInts = e.values.every((v) => typeof v.value === 'number' && Number.isInteger(v.value));
@@ -56,11 +67,13 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
56
67
 
57
68
  lines.push('}');
58
69
 
59
- files.push({
60
- path: `lib/Resource/${name}.php`,
61
- content: lines.join('\n'),
62
- overwriteExisting: true,
63
- });
70
+ if (enumInScope) {
71
+ files.push({
72
+ path: `lib/Resource/${name}.php`,
73
+ content: lines.join('\n'),
74
+ overwriteExisting: true,
75
+ });
76
+ }
64
77
  }
65
78
 
66
79
  return files;
package/src/php/index.ts CHANGED
@@ -42,9 +42,17 @@ function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
42
42
  * classes (no sum types), so a discriminated base whose IR fields the
43
43
  * parser stripped (post-allOf-aware detection) gets its original fields
44
44
  * restored to avoid silently dropping variant data.
45
+ *
46
+ * `enums` is forwarded to seed `enrichModelsFromSpec`'s collision set: an
47
+ * inline oneOf enum whose synthetic name (`Parent_field`) snake-collapses
48
+ * onto an existing IR enum (e.g. `DataIntegrationAccessTokenResponse_error`
49
+ * vs `DataIntegrationAccessTokenResponseError`) must NOT spawn a duplicate
50
+ * synthetic. Otherwise both collapse to the same `lib/Resource/X.php` path
51
+ * and the later writer wins by array order — which differs between a full
52
+ * and a scoped (`--services`) run, producing a non-deterministic case order.
45
53
  */
46
- function enrichModelsForPhp(models: Model[]): Model[] {
47
- const enriched = enrichModelsFromSpec(models);
54
+ function enrichModelsForPhp(models: Model[], enums: Enum[]): Model[] {
55
+ const enriched = enrichModelsFromSpec(models, enums);
48
56
  const originalByName = new Map(models.map((m) => [m.name, m]));
49
57
  return enriched.map((m) => {
50
58
  if ((m as { discriminator?: unknown }).discriminator && m.fields.length === 0) {
@@ -62,7 +70,7 @@ export const phpEmitter: Emitter = {
62
70
 
63
71
  generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
64
72
  ensureNamingInitialized(ctx);
65
- return ensureTrailingNewlines(generateModels(enrichModelsForPhp(models), ctx));
73
+ return ensureTrailingNewlines(generateModels(enrichModelsForPhp(models, ctx.spec.enums), ctx));
66
74
  },
67
75
 
68
76
  generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
package/src/php/models.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  collectNonPaginatedResponseModelNames,
11
11
  collectReferencedListMetadataModels,
12
12
  } from '../shared/model-utils.js';
13
+ import { isModelInScope } from '../shared/resolved-ops.js';
13
14
  export { isListMetadataModel, isListWrapperModel };
14
15
 
15
16
  /**
@@ -140,11 +141,16 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
140
141
 
141
142
  lines.push('}');
142
143
 
143
- files.push({
144
- path: `lib/Resource/${name}.php`,
145
- content: lines.join('\n'),
146
- overwriteExisting: true,
147
- });
144
+ // FR-1.4: write the per-model FILE only when in scope. PHP uses PSR-4
145
+ // (one class per file under lib/Resource/, no barrel/index), so an
146
+ // out-of-scope model is simply left untouched on disk and stays loadable.
147
+ if (isModelInScope(model.name, ctx)) {
148
+ files.push({
149
+ path: `lib/Resource/${name}.php`,
150
+ content: lines.join('\n'),
151
+ overwriteExisting: true,
152
+ });
153
+ }
148
154
  }
149
155
 
150
156
  return files;
@@ -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