@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
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-DXIciTnN.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.1",
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;
@@ -1,7 +1,16 @@
1
- import type { Model, TypeRef, Enum } from '@workos/oagen';
1
+ import type { Model, TypeRef, Enum, EmitterContext } from '@workos/oagen';
2
2
  import { fixtureFileName, domainFieldName } from './naming.js';
3
3
  import { isListMetadataModel, isListWrapperModel } from './models.js';
4
4
  import { collectNonPaginatedResponseModelNames } from '../shared/model-utils.js';
5
+ import { isModelInScope, fileExistsAfterRun } from '../shared/resolved-ops.js';
6
+
7
+ /**
8
+ * Prefix the per-model fixture path with the .NET test project layout so the
9
+ * scoped-run gate can compare it against the prefixed paths recorded in the
10
+ * prior manifest. Must mirror `TEST_PREFIX` in index.ts; the fixture path
11
+ * itself (`testdata/<name>.json`) is what `prefixTestPaths` later prepends to.
12
+ */
13
+ const TEST_PREFIX = 'test/WorkOSTests/';
5
14
 
6
15
  /**
7
16
  * Prefix mapping for generating realistic ID fixture values.
@@ -25,11 +34,14 @@ export const ID_PREFIXES: Record<string, string> = {
25
34
  /**
26
35
  * Generate JSON fixture files for test data.
27
36
  */
28
- export function generateFixtures(spec: {
29
- models: Model[];
30
- enums: Enum[];
31
- services: any[];
32
- }): { 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 }[] {
33
45
  if (spec.models.length === 0) return [];
34
46
 
35
47
  const modelMap = new Map(spec.models.map((m) => [m.name, m]));
@@ -50,10 +62,19 @@ export function generateFixtures(spec: {
50
62
  if (isListMetadataModel(model)) continue;
51
63
  if (isListWrapperModel(model) && !nonPaginatedWrapperRefs.has(model.name)) continue;
52
64
 
65
+ // Scoped-run gate: only emit a per-model fixture whose file will exist on
66
+ // disk after the run (in-scope, or already present from a prior manifest).
67
+ // A brand-new out-of-scope model's fixture would be a stray file for a
68
+ // model no in-scope test can reference; a renamed/removed-but-on-disk one
69
+ // is retained. Full runs emit everything. `relPath` carries the
70
+ // `test/WorkOSTests/` prefix so it matches the prefixed prior-manifest paths.
71
+ const fixturePath = `testdata/${fixtureFileName(model.name)}.json`;
72
+ if (!fileExistsAfterRun(`${TEST_PREFIX}${fixturePath}`, isModelInScope(model.name, ctx), ctx)) continue;
73
+
53
74
  const fixture = model.fields.length === 0 ? {} : generateModelFixture(model, modelMap, enumMap);
54
75
 
55
76
  files.push({
56
- path: `testdata/${fixtureFileName(model.name)}.json`,
77
+ path: fixturePath,
57
78
  content: JSON.stringify(fixture, null, 2),
58
79
  });
59
80
 
@@ -19,7 +19,7 @@ import { generateClient } from './client.js';
19
19
  import { generateTests } from './tests.js';
20
20
  import { buildOperationsMap } from './manifest.js';
21
21
  import { generateWrapperOptionsClasses } from './wrappers.js';
22
- import { groupByMount } from '../shared/resolved-ops.js';
22
+ import { groupByMount, isModelInScope, fileExistsAfterRun } from '../shared/resolved-ops.js';
23
23
  import { AUTOGEN_NOTICE } from '../shared/file-header.js';
24
24
  import { discriminatedUnions, resolveModelName } from './type-map.js';
25
25
  import { modelClassName } from './naming.js';
@@ -66,6 +66,28 @@ function prefixTestPaths(files: GeneratedFile[]): GeneratedFile[] {
66
66
  return files;
67
67
  }
68
68
 
69
+ /**
70
+ * Scoped-run gate for a discriminated-union variant referenced by a generated
71
+ * converter (the event-registry / polymorphic dispatch aggregate). A converter
72
+ * may only name a variant whose per-model `Entities/<Class>.cs` file will exist
73
+ * on disk after the run — otherwise the `new <Class>()` / `ToObject<<Class>>`
74
+ * arm references a type whose file is never emitted (CS0246). The set that
75
+ * exists after the run is: in-scope variants (emitted this run) ∪ variants
76
+ * already on disk from a prior manifest (scoped runs never prune), so a
77
+ * renamed/removed-but-still-on-disk variant is RETAINED while a brand-new
78
+ * out-of-scope variant is EXCLUDED. A full run includes everything.
79
+ *
80
+ * The variant name is resolved through the model-alias map first, exactly as
81
+ * the converter emits it (`modelClassName(resolveModelName(name))`), and the
82
+ * candidate path carries the `src/WorkOS.net/` prefix so it matches the
83
+ * prefixed paths recorded in `priorTargetManifestPaths`.
84
+ */
85
+ function variantFileExistsAfterRun(variantModelName: string, ctx: EmitterContext): boolean {
86
+ const canonical = resolveModelName(variantModelName);
87
+ const relPath = `${SRC_PREFIX}Entities/${modelClassName(canonical)}.cs`;
88
+ return fileExistsAfterRun(relPath, isModelInScope(canonical, ctx), ctx);
89
+ }
90
+
69
91
  export const dotnetEmitter: Emitter = {
70
92
  language: 'dotnet',
71
93
 
@@ -146,6 +168,11 @@ export const dotnetEmitter: Emitter = {
146
168
  lines.push(' switch (discriminatorValue)');
147
169
  lines.push(' {');
148
170
  for (const [value, modelName] of Object.entries(disc.mapping)) {
171
+ // Scoped-run gate: only dispatch to a variant whose `Entities/*.cs`
172
+ // file will exist after the run. A brand-new out-of-scope variant is
173
+ // skipped (its `.cs` is never emitted ⇒ CS0246); unmatched discriminator
174
+ // values fall through to the `default` arm below.
175
+ if (!variantFileExistsAfterRun(modelName, c)) continue;
149
176
  const csName = modelClassName(resolveModelName(modelName));
150
177
  lines.push(` case "${value}": return jObject.ToObject<${csName}>(serializer);`);
151
178
  }
@@ -177,6 +204,12 @@ export const dotnetEmitter: Emitter = {
177
204
  // CanWrite is false so serialization uses the default path and never
178
205
  // re-enters WriteJson.
179
206
  for (const [baseName, disc] of modelDiscriminators) {
207
+ // Skip the converter entirely when the base model's own `Entities/*.cs`
208
+ // won't exist after a scoped run (brand-new out-of-scope base): its
209
+ // `new <baseClass>()` default arm and `[JsonConverter]` attribute would
210
+ // both dangle. When the base is already on disk we still emit it — the
211
+ // untouched base class references this converter by name.
212
+ if (!variantFileExistsAfterRun(baseName, c)) continue;
180
213
  const baseClass = modelClassName(baseName);
181
214
  const converterName = `${baseClass}DiscriminatorConverter`;
182
215
  const lines: string[] = [];
@@ -210,6 +243,14 @@ export const dotnetEmitter: Emitter = {
210
243
  lines.push(' switch (discriminatorValue)');
211
244
  lines.push(' {');
212
245
  for (const [value, variantModelName] of Object.entries(disc.mapping)) {
246
+ // Scoped-run gate: only construct a variant whose `Entities/*.cs` file
247
+ // will exist after the run. This is the event-registry aggregate
248
+ // (`EventSchema` enumerates every event payload model): a brand-new
249
+ // out-of-scope event type is skipped (its `.cs` is never emitted ⇒
250
+ // CS0246), while a renamed/removed-but-still-on-disk one is retained.
251
+ // Unmatched discriminator values fall through to the `default` arm,
252
+ // which deserializes into the base class.
253
+ if (!variantFileExistsAfterRun(variantModelName, c)) continue;
213
254
  const csName = modelClassName(variantModelName);
214
255
  lines.push(` case "${value}": target = new ${csName}(); break;`);
215
256
  }
@@ -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,
@@ -30,7 +30,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
30
30
  const files: GeneratedFile[] = [];
31
31
 
32
32
  // Generate fixture JSON files
33
- const fixtures = generateFixtures(spec);
33
+ const fixtures = generateFixtures(spec, ctx);
34
34
  for (const fixture of fixtures) {
35
35
  files.push({
36
36
  path: fixture.path,
@@ -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),
package/src/go/enums.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { Enum, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
2
2
  import { walkTypeRef } from '@workos/oagen';
3
3
  import { className } from './naming.js';
4
+ import { isEnumInScope, isScopedRun } from '../shared/resolved-ops.js';
5
+ import { reconcileFlatBlocks, readPriorFile, type NamedBlock } from './flat-merge.js';
4
6
 
5
7
  /**
6
8
  * Generate Go typed string enum constants from IR Enum definitions.
@@ -16,6 +18,16 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
16
18
  if (enums.length === 0) return [];
17
19
 
18
20
  const aliasOf = collectEnumAliasOf(enums);
21
+ // An in-scope enum alias emits `type Alias = Canonical` against the current
22
+ // spec; the canonical may itself be out of scope and brand-new, which the
23
+ // reconciler would drop, leaving the alias dangling. Force-retain any
24
+ // canonical referenced by an in-scope alias.
25
+ const forcedCanonicals = new Set<string>();
26
+ if (isScopedRun(ctx)) {
27
+ for (const [aliasName, canonical] of aliasOf) {
28
+ if (isEnumInScope(aliasName, ctx)) forcedCanonicals.add(canonical);
29
+ }
30
+ }
19
31
  const files: GeneratedFile[] = [];
20
32
 
21
33
  // Group all enums into a single file per SDK
@@ -23,6 +35,12 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
23
35
  lines.push(`package ${ctx.namespace}`);
24
36
  lines.push('');
25
37
 
38
+ // Build one NamedBlock per emitted enum type so a scoped run can reconcile
39
+ // them against the prior enums.go (drop brand-new out-of-scope enums, retain
40
+ // renamed/removed ones still referenced by un-regenerated code). A full run
41
+ // emits every block unchanged.
42
+ const enumBlocks: NamedBlock[] = [];
43
+
26
44
  for (const enumDef of enums) {
27
45
  // If this enum is an alias, emit a simple type alias
28
46
  const canonicalName = aliasOf.get(enumDef.name);
@@ -33,9 +51,11 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
33
51
  // enums from enrichModelsFromSpec whose underscore names collapse to the
34
52
  // same PascalCase as the original enum).
35
53
  if (aliasType === canonicalType) continue;
36
- lines.push(`// ${aliasType} is an alias for ${canonicalType}.`);
37
- lines.push(`type ${aliasType} = ${canonicalType}`);
38
- lines.push('');
54
+ enumBlocks.push({
55
+ names: [aliasType],
56
+ text: [`// ${aliasType} is an alias for ${canonicalType}.`, `type ${aliasType} = ${canonicalType}`].join('\n'),
57
+ inScope: isEnumInScope(enumDef.name, ctx),
58
+ });
39
59
  continue;
40
60
  }
41
61
 
@@ -43,9 +63,11 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
43
63
 
44
64
  if (enumDef.values.length === 0) {
45
65
  const humanized = humanize(enumDef.name);
46
- lines.push(`// ${typeName} represents ${humanized} values.`);
47
- lines.push(`type ${typeName} = string`);
48
- lines.push('');
66
+ enumBlocks.push({
67
+ names: [typeName],
68
+ text: [`// ${typeName} represents ${humanized} values.`, `type ${typeName} = string`].join('\n'),
69
+ inScope: isEnumInScope(enumDef.name, ctx) || forcedCanonicals.has(enumDef.name),
70
+ });
49
71
  continue;
50
72
  }
51
73
 
@@ -61,10 +83,11 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
61
83
  }
62
84
 
63
85
  const humanized = humanize(enumDef.name);
64
- lines.push(`// ${typeName} represents ${humanized} values.`);
65
- lines.push(`type ${typeName} string`);
66
- lines.push('');
67
- lines.push('const (');
86
+ const blockLines: string[] = [];
87
+ blockLines.push(`// ${typeName} represents ${humanized} values.`);
88
+ blockLines.push(`type ${typeName} string`);
89
+ blockLines.push('');
90
+ blockLines.push('const (');
68
91
 
69
92
  const usedNames = new Set<string>();
70
93
  for (const v of uniqueValues) {
@@ -79,15 +102,25 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
79
102
  usedNames.add(constName);
80
103
  const valueStr = typeof v.value === 'string' ? `"${v.value}"` : String(v.value);
81
104
  if (v.description) {
82
- lines.push(`\t// ${constName} is ${v.description}.`);
105
+ blockLines.push(`\t// ${constName} is ${v.description}.`);
83
106
  }
84
107
  if (v.deprecated) {
85
- if (v.description) lines.push(`\t//`);
86
- lines.push(`\t// Deprecated: this value is deprecated.`);
108
+ if (v.description) blockLines.push(`\t//`);
109
+ blockLines.push(`\t// Deprecated: this value is deprecated.`);
87
110
  }
88
- lines.push(`\t${constName} ${typeName} = ${valueStr}`);
111
+ blockLines.push(`\t${constName} ${typeName} = ${valueStr}`);
89
112
  }
90
- lines.push(')');
113
+ blockLines.push(')');
114
+ enumBlocks.push({
115
+ names: [typeName],
116
+ text: blockLines.join('\n'),
117
+ inScope: isEnumInScope(enumDef.name, ctx) || forcedCanonicals.has(enumDef.name),
118
+ });
119
+ }
120
+
121
+ const reconciled = reconcileFlatBlocks(enumBlocks, 'enums.go', ctx);
122
+ for (const text of reconciled) {
123
+ lines.push(text);
91
124
  lines.push('');
92
125
  }
93
126
 
@@ -96,7 +129,7 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
96
129
  content: lines.join('\n'),
97
130
  overwriteExisting: true,
98
131
  });
99
- const eventConstantsFile = generateEventConstantsFile(enums);
132
+ const eventConstantsFile = generateEventConstantsFile(enums, ctx);
100
133
  if (eventConstantsFile) files.push(eventConstantsFile);
101
134
 
102
135
  return files;
@@ -175,10 +208,28 @@ function collectEnumAliasOf(enums: Enum[]): Map<string, string> {
175
208
  return aliasOf;
176
209
  }
177
210
 
178
- function generateEventConstantsFile(enums: Enum[]): GeneratedFile | null {
211
+ const EVENTS_FILE_PATH = 'pkg/events/events.go';
212
+
213
+ function generateEventConstantsFile(enums: Enum[], ctx: EmitterContext): GeneratedFile | null {
179
214
  const enumDef = findWebhookEventEnum(enums);
180
215
  if (!enumDef) return null;
181
216
 
217
+ // Scoped-run gate for the flat events file. Unlike models/enums, an event
218
+ // value can't be mapped to a single selected service, so there is no
219
+ // per-event "in scope" signal. The correct Option-B behavior is therefore to
220
+ // keep this aggregate byte-stable in a scoped run: emit only event constants
221
+ // that already existed in the prior events.go (dropping brand-new additions
222
+ // such as `session.reauthenticated` from an out-of-scope service) and never
223
+ // drop a constant that was present before. Equivalent to: in a scoped run,
224
+ // emit exactly the prior file's constant set.
225
+ let priorEventConsts: Set<string> | null = null;
226
+ if (isScopedRun(ctx)) {
227
+ const prior = readPriorFile(EVENTS_FILE_PATH, ctx);
228
+ if (prior !== null) {
229
+ priorEventConsts = collectPriorEventConstNames(prior);
230
+ }
231
+ }
232
+
182
233
  const lines: string[] = [];
183
234
  lines.push('package events');
184
235
  lines.push('');
@@ -195,6 +246,13 @@ function generateEventConstantsFile(enums: Enum[]): GeneratedFile | null {
195
246
  seenValues.add(valueStr);
196
247
 
197
248
  const constName = uniqueEventConstantName(valueStr, usedNames);
249
+
250
+ // Scoped run: skip a constant that wasn't in the prior file (a brand-new,
251
+ // out-of-scope event). When there's no prior file to compare against, fall
252
+ // through and emit everything (first generation / non-target run).
253
+ if (priorEventConsts !== null && !priorEventConsts.has(constName)) {
254
+ continue;
255
+ }
198
256
  usedNames.add(constName);
199
257
 
200
258
  if (value.description) {
@@ -213,12 +271,27 @@ function generateEventConstantsFile(enums: Enum[]): GeneratedFile | null {
213
271
  lines.push('');
214
272
 
215
273
  return {
216
- path: 'pkg/events/events.go',
274
+ path: EVENTS_FILE_PATH,
217
275
  content: lines.join('\n'),
218
276
  overwriteExisting: true,
219
277
  };
220
278
  }
221
279
 
280
+ /**
281
+ * Collect the constant names declared in a prior `pkg/events/events.go`. Each
282
+ * event constant is emitted as `\tConstName = "wire.value"` inside the single
283
+ * `const (...)` block, so a simple line scan recovers the prior name set used
284
+ * to keep the file byte-stable across scoped runs.
285
+ */
286
+ function collectPriorEventConstNames(content: string): Set<string> {
287
+ const names = new Set<string>();
288
+ for (const raw of content.split('\n')) {
289
+ const m = raw.trim().match(/^(\w+)\s*=\s*"/);
290
+ if (m) names.add(m[1]);
291
+ }
292
+ return names;
293
+ }
294
+
222
295
  function findWebhookEventEnum(enums: Enum[]): Enum | null {
223
296
  return (
224
297
  enums.find((enumDef) => enumDef.name === 'CreateWebhookEndpointEvents') ??
@@ -1,7 +1,8 @@
1
- import type { Model, TypeRef, Enum } from '@workos/oagen';
1
+ import type { Model, TypeRef, Enum, EmitterContext } from '@workos/oagen';
2
2
  import { fileName, domainFieldName } from './naming.js';
3
3
  import { isListMetadataModel, isListWrapperModel } from './models.js';
4
4
  import { collectNonPaginatedResponseModelNames, collectReferencedListMetadataModels } from '../shared/model-utils.js';
5
+ import { isModelInScope, fileExistsAfterRun } from '../shared/resolved-ops.js';
5
6
 
6
7
  /**
7
8
  * Prefix mapping for generating realistic ID fixture values.
@@ -24,8 +25,18 @@ export const ID_PREFIXES: Record<string, string> = {
24
25
 
25
26
  /**
26
27
  * Generate JSON fixture files for test data.
28
+ *
29
+ * Scoped runs only emit a fixture for a model whose per-model fixture FILE will
30
+ * exist on disk after the run (in-scope, or already present from a prior run —
31
+ * the fixture path is per-model so the prior manifest records it directly).
32
+ * This drops brand-new out-of-scope fixtures (the round-trip ADDITION case) and
33
+ * keeps prior fixtures untouched, mirroring the Rust fixtures fix. `ctx` is
34
+ * optional so unit tests that assert raw content can call it for a full run.
27
35
  */
28
- export function generateFixtures(spec: { models: Model[]; enums: Enum[]; services: any[] }): {
36
+ export function generateFixtures(
37
+ spec: { models: Model[]; enums: Enum[]; services: any[] },
38
+ ctx?: EmitterContext,
39
+ ): {
29
40
  files: { path: string; content: string }[];
30
41
  pathRewrites: Map<string, string>;
31
42
  } {
@@ -38,14 +49,21 @@ export function generateFixtures(spec: { models: Model[]; enums: Enum[]; service
38
49
  const nonPaginatedRefs = collectNonPaginatedResponseModelNames(spec.services);
39
50
  const listMetadataNeeded = collectReferencedListMetadataModels(spec.models, nonPaginatedRefs);
40
51
 
52
+ // Full run (no ctx / no scoping): every fixture is in scope.
53
+ const fixtureInScope = (relPath: string, modelName: string): boolean =>
54
+ !ctx || fileExistsAfterRun(relPath, isModelInScope(modelName, ctx), ctx);
55
+
41
56
  for (const model of spec.models) {
42
57
  if (isListMetadataModel(model) && !listMetadataNeeded.has(model.name)) continue;
43
58
  if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
44
59
 
60
+ const path = `testdata/${fileName(model.name)}.json`;
61
+ if (!fixtureInScope(path, model.name)) continue;
62
+
45
63
  const fixture = model.fields.length === 0 ? {} : generateModelFixture(model, modelMap, enumMap);
46
64
 
47
65
  files.push({
48
- path: `testdata/${fileName(model.name)}.json`,
66
+ path,
49
67
  content: JSON.stringify(fixture, null, 2),
50
68
  });
51
69
  }
@@ -67,6 +85,10 @@ export function generateFixtures(spec: { models: Model[]; enums: Enum[]; service
67
85
  const path = `testdata/list_${fileName(itemModel.name)}.json`;
68
86
  if (seenListPaths.has(path)) continue;
69
87
  seenListPaths.add(path);
88
+ // Scoped run: only emit a list fixture whose file will exist after the
89
+ // run (in-scope item model, or already on disk). Keyed on the item
90
+ // model so an out-of-scope paginated endpoint doesn't add a new file.
91
+ if (!fixtureInScope(path, itemModel.name)) continue;
70
92
  const fixture = generateModelFixture(itemModel, modelMap, enumMap);
71
93
  const listFixture = {
72
94
  data: [fixture],