@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
@@ -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,11 +22,14 @@ 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,
29
29
  collectGroupedParamNames,
30
+ isModelInScope,
31
+ isEnumInScope,
32
+ fileExistsAfterRun,
30
33
  } from '../shared/resolved-ops.js';
31
34
  import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
32
35
  import { resolveWrapperParams } from '../shared/wrapper-utils.js';
@@ -34,6 +37,23 @@ import { isHandwrittenOverride } from './overrides.js';
34
37
 
35
38
  const TEST_PREFIX = 'src/test/kotlin/';
36
39
 
40
+ // Per-item FILE paths (target-root-relative, matching the prior manifest) the
41
+ // model/enum emitters write. The whole-suite test aggregates below reference
42
+ // these classes by name, so they may only list an item whose file will EXIST
43
+ // on disk after the run (in-scope ∪ prior manifest) — otherwise a scoped
44
+ // (`--services`) run references a `Class::class.java` whose `.kt` was never
45
+ // emitted. Must stay in sync with the paths in `models.ts` / `enums.ts`.
46
+ const MODELS_FILE_PREFIX = 'src/main/kotlin/com/workos/models/';
47
+ const ENUMS_FILE_PREFIX = 'src/main/kotlin/com/workos/types/';
48
+
49
+ function modelFilePath(modelName: string): string {
50
+ return `${MODELS_FILE_PREFIX}${className(modelName)}.kt`;
51
+ }
52
+
53
+ function enumFilePath(enumName: string): string {
54
+ return `${ENUMS_FILE_PREFIX}${className(enumName)}.kt`;
55
+ }
56
+
37
57
  /**
38
58
  * Mirror the ISO-8601 hint promotion the resource/model emitters use so tests
39
59
  * synthesize values whose Kotlin type matches the generated method signature.
@@ -76,7 +96,7 @@ function promoteIso8601TypeRef(type: TypeRef, description: string | undefined):
76
96
  */
77
97
  export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
78
98
  const files: GeneratedFile[] = [];
79
- const mountGroups = groupByMount(ctx);
99
+ const mountGroups = scopedMountGroups(ctx);
80
100
  const resolvedLookup = buildResolvedLookup(ctx);
81
101
 
82
102
  const exportedClasses = buildExportedClassNameSet(ctx);
@@ -1029,6 +1049,11 @@ function generateModelRoundTripTest(spec: ApiSpec, ctx: EmitterContext): Generat
1029
1049
  for (const m of spec.models) {
1030
1050
  if (isListWrapperModel(m) || isListMetadataModel(m)) continue;
1031
1051
  if (m.fields.length === 0) continue;
1052
+ // AGGREGATE gate: this whole-suite test references `${cls}::class.java`. In a
1053
+ // scoped run only in-scope model files are emitted, so skip a brand-new
1054
+ // OUT-OF-SCOPE model whose `.kt` won't exist on disk. In-scope ∪ prior
1055
+ // manifest is retained; a full run keeps everything (gate is inert).
1056
+ if (!fileExistsAfterRun(modelFilePath(m.name), isModelInScope(m.name, ctx), ctx)) continue;
1032
1057
  const cls = className(m.name);
1033
1058
  if (seenModelClassNames.has(cls)) continue;
1034
1059
  seenModelClassNames.add(cls);
@@ -1092,10 +1117,20 @@ const MAX_ENUM_FORWARD_COMPAT = 15;
1092
1117
 
1093
1118
  function generateForwardCompatTest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile | null {
1094
1119
  // Select multiple enums for forward-compat testing, not just the first.
1095
- const enumTargets = spec.enums.filter((e) => e.values.length > 0).slice(0, MAX_ENUM_FORWARD_COMPAT);
1120
+ // AGGREGATE gate: each selected enum is imported as `com.workos.types.X`. In a
1121
+ // scoped run only in-scope enum files are emitted, so skip a brand-new
1122
+ // OUT-OF-SCOPE enum whose `.kt` won't exist on disk (in-scope ∪ prior manifest
1123
+ // retained; full run keeps everything).
1124
+ const enumTargets = spec.enums
1125
+ .filter((e) => e.values.length > 0)
1126
+ .filter((e) => fileExistsAfterRun(enumFilePath(e.name), isEnumInScope(e.name, ctx), ctx))
1127
+ .slice(0, MAX_ENUM_FORWARD_COMPAT);
1096
1128
  const modelTarget = spec.models.find((m) => {
1097
1129
  if (isListWrapperModel(m) || isListMetadataModel(m)) return false;
1098
1130
  if (m.fields.length === 0) return false;
1131
+ // Same aggregate gate as the round-trip test: the model is referenced as
1132
+ // `${cls}::class.java`, so it must exist on disk after the run.
1133
+ if (!fileExistsAfterRun(modelFilePath(m.name), isModelInScope(m.name, ctx), ctx)) return false;
1099
1134
  return synthJsonForModelName(m.name, ctx, new Set()) !== null;
1100
1135
  });
1101
1136
  if (enumTargets.length === 0 && !modelTarget) return null;
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
 
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
 
@@ -1,8 +1,9 @@
1
- import type { Model, TypeRef, Enum } from '@workos/oagen';
1
+ import type { Model, TypeRef, Enum, EmitterContext } from '@workos/oagen';
2
2
 
3
3
  import { fileName, domainFieldName } from './naming.js';
4
4
  import { isListMetadataModel, isListWrapperModel } from './models.js';
5
5
  import { collectNonPaginatedResponseModelNames, collectReferencedListMetadataModels } from '../shared/model-utils.js';
6
+ import { isModelInScope, fileExistsAfterRun } from '../shared/resolved-ops.js';
6
7
 
7
8
  /**
8
9
  * Prefix mapping for generating realistic ID fixture values.
@@ -25,18 +26,39 @@ export const ID_PREFIXES: Record<string, string> = {
25
26
 
26
27
  /**
27
28
  * Generate JSON fixture files for test data.
29
+ *
30
+ * `ctx` is optional so unit tests can call with a bare spec; when supplied, a
31
+ * scoped (`--services`) run only emits a fixture for a model whose per-model
32
+ * file will exist on disk after the run (in-scope, or already present from a
33
+ * prior manifest). Brand-new out-of-scope models are skipped: the round-trip
34
+ * test that consumes these fixtures is gated the same way, and the per-service
35
+ * tests only reference fixtures for their (in-scope) models.
28
36
  */
29
- export function generateFixtures(spec: {
30
- models: Model[];
31
- enums: Enum[];
32
- services: any[];
33
- }): { path: string; content: string }[] {
37
+ export function generateFixtures(
38
+ spec: {
39
+ models: Model[];
40
+ enums: Enum[];
41
+ services: any[];
42
+ },
43
+ ctx?: EmitterContext,
44
+ ): { path: string; content: string }[] {
34
45
  if (spec.models.length === 0) return [];
35
46
 
36
47
  const modelMap = new Map(spec.models.map((m) => [m.name, m]));
37
48
  const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
38
49
  const files: { path: string; content: string }[] = [];
39
50
 
51
+ // Gate a fixture by ITS OWN path (recorded verbatim in the prior manifest),
52
+ // scoped on the owning model. A base-model fixture and its `list_*` fixture
53
+ // are distinct files, so each must be checked against its own path — keying a
54
+ // list fixture off the base path would leak a brand-new `list_*` fixture for
55
+ // an out-of-scope model that merely already had its base fixture on disk.
56
+ // When ctx is absent (unit tests) every fixture is in scope.
57
+ const fixtureEmitted = (path: string, modelName: string): boolean => {
58
+ if (!ctx) return true;
59
+ return fileExistsAfterRun(path, isModelInScope(modelName, ctx), ctx);
60
+ };
61
+
40
62
  const nonPaginatedRefs = collectNonPaginatedResponseModelNames(spec.services);
41
63
  const listMetadataNeeded = collectReferencedListMetadataModels(spec.models, nonPaginatedRefs);
42
64
 
@@ -47,6 +69,8 @@ export function generateFixtures(spec: {
47
69
  // with hand-maintained @oagen-ignore overrides; generated empty fixtures
48
70
  // would not match the override's required fields.
49
71
  if (model.fields.length === 0) continue;
72
+ // Scoped run: skip a fixture for a brand-new out-of-scope model.
73
+ if (!fixtureEmitted(`tests/fixtures/${fileName(model.name)}.json`, model.name)) continue;
50
74
 
51
75
  const fixture = generateModelFixture(model, modelMap, enumMap);
52
76
 
@@ -65,6 +89,10 @@ export function generateFixtures(spec: {
65
89
  const unwrapped = unwrapListModel(itemModel, modelMap);
66
90
  if (unwrapped) itemModel = unwrapped;
67
91
  if (itemModel.fields.length === 0) continue;
92
+ // Scoped run: a list fixture for a brand-new out-of-scope item model
93
+ // would be referenced by no emitted test; gate it on the LIST
94
+ // fixture's own path (not the base model fixture's).
95
+ if (!fixtureEmitted(`tests/fixtures/list_${fileName(itemModel.name)}.json`, itemModel.name)) continue;
68
96
  const fixture = generateModelFixture(itemModel, modelMap, enumMap);
69
97
  const listFixture = {
70
98
  data: [fixture],