@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/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, fileExistsAfterRun, priorManifestBasenames } 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,13 +28,34 @@ 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);
30
- moduleNames.push(mod);
31
31
 
32
- files.push({
33
- path: `src/enums/${mod}.rs`,
34
- content: renderEnum(e),
35
- overwriteExisting: true,
36
- });
32
+ // Only the per-enum `.rs` FILE write is scoped (FR-1.4); an out-of-scope
33
+ // enum's existing `.rs` file stays untouched on disk.
34
+ const inScope = isEnumInScope(e.name, ctx);
35
+ const path = `src/enums/${mod}.rs`;
36
+ if (inScope) {
37
+ files.push({
38
+ path,
39
+ content: renderEnum(e),
40
+ overwriteExisting: true,
41
+ });
42
+ }
43
+
44
+ // Declare the module only if its file will exist on disk after this run, so
45
+ // a scoped run never declares a brand-new out-of-scope enum it doesn't emit.
46
+ if (fileExistsAfterRun(path, inScope, ctx)) {
47
+ moduleNames.push(mod);
48
+ }
49
+ }
50
+
51
+ // Scoped runs: retain barrel entries for enum files still on disk (prior
52
+ // manifest) that the current spec no longer produces (e.g. renamed for
53
+ // another service), since out-of-scope code may still reference them.
54
+ for (const base of priorManifestBasenames(ctx, 'src/enums', '.rs', new Set(['mod']))) {
55
+ if (!seen.has(base)) {
56
+ seen.add(base);
57
+ moduleNames.push(base);
58
+ }
37
59
  }
38
60
 
39
61
  files.push({
@@ -1,12 +1,19 @@
1
- import type { ApiSpec, GeneratedFile, Model, Enum, TypeRef } from '@workos/oagen';
1
+ import type { ApiSpec, GeneratedFile, Model, Enum, TypeRef, EmitterContext } from '@workos/oagen';
2
2
  import { walkTypeRef } from '@workos/oagen';
3
3
  import { moduleName } from './naming.js';
4
+ import { isModelInScope, fileExistsAfterRun } from '../shared/resolved-ops.js';
4
5
 
5
6
  /**
6
7
  * Generate JSON test fixture files under `tests/fixtures/`. The Rust tests
7
8
  * pull these in via `include_str!` so no I/O is required at test time.
9
+ *
10
+ * Scoped runs only emit a fixture for a model whose file will exist on disk
11
+ * after the run (in-scope, or already present from a prior run). Emitting a
12
+ * fixture for a brand-new out-of-scope model would add a stray file for a model
13
+ * the SDK can't even reference yet; in-scope tests only `include_str!` fixtures
14
+ * for in-scope models, so gating here is safe.
8
15
  */
9
- export function generateFixtures(spec: ApiSpec): GeneratedFile[] {
16
+ export function generateFixtures(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
10
17
  const files: GeneratedFile[] = [];
11
18
  const modelMap = new Map(spec.models.map((m) => [m.name, m]));
12
19
  const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
@@ -17,8 +24,10 @@ export function generateFixtures(spec: ApiSpec): GeneratedFile[] {
17
24
  if (model.fields.length === 0) continue;
18
25
  seen.add(model.name);
19
26
 
20
- const fixture = generateModelFixture(model, modelMap, enumMap, new Set());
21
27
  const path = `tests/fixtures/${moduleName(model.name)}.json`;
28
+ if (!fileExistsAfterRun(path, isModelInScope(model.name, ctx), ctx)) continue;
29
+
30
+ const fixture = generateModelFixture(model, modelMap, enumMap, new Set());
22
31
  files.push({
23
32
  path,
24
33
  content: JSON.stringify(fixture, null, 2) + '\n',
@@ -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, fileExistsAfterRun, priorManifestBasenames } from '../shared/resolved-ops.js';
5
6
 
6
7
  const HEADER_PLACEHOLDER = ''; // engine prepends fileHeader()
7
8
 
@@ -32,15 +33,45 @@ 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);
35
- moduleNames.push(mod);
36
36
 
37
+ // renderModel registers inline unions into `registry` as a side effect, and
38
+ // `_unions.rs` is rendered later (in generateClient) from that registry — so
39
+ // it MUST run for every model, even out-of-scope ones, or scoped runs drop
40
+ // unions. Compute content unconditionally; only the per-model `.rs` FILE write
41
+ // is scoped (FR-1.4) — an out-of-scope model's existing `.rs` file stays
42
+ // untouched on disk.
43
+ const inScope = isModelInScope(model.name, ctx);
37
44
  const hintPath = ctx.overlayLookup?.fileBySymbol?.get(model.name);
38
45
  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
- });
46
+ const content = renderModel(model, registry, taggedVariantFields.get(model.name));
47
+ if (inScope) {
48
+ files.push({
49
+ path,
50
+ content,
51
+ overwriteExisting: true,
52
+ });
53
+ }
54
+
55
+ // Declare the module only if its `.rs` file will exist on disk after this
56
+ // run: in-scope (emitted just now) or already present from a prior run. A
57
+ // scoped run must NOT declare a brand-new out-of-scope model whose file it
58
+ // never emits — that dangling `mod` is what broke the build. A full run
59
+ // declares every module (fileExistsAfterRun ⇒ true when scoping is inert).
60
+ if (fileExistsAfterRun(path, inScope, ctx)) {
61
+ moduleNames.push(mod);
62
+ }
63
+ }
64
+
65
+ // Scoped runs: retain barrel entries for model files still on disk (prior
66
+ // manifest) that the current spec no longer produces — e.g. a model renamed
67
+ // for another service. Out-of-scope code we did not regenerate may still
68
+ // reference them, so dropping the `mod` would break the build. De-duped
69
+ // against the modules declared above; a full run yields nothing here.
70
+ for (const base of priorManifestBasenames(ctx, 'src/models', '.rs', new Set([UNIONS_MODULE, 'mod']))) {
71
+ if (!seen.has(base)) {
72
+ seen.add(base);
73
+ moduleNames.push(base);
74
+ }
44
75
  }
45
76
 
46
77
  // 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';
@@ -35,7 +35,7 @@ import { isInlineEnvelopeList } from './resources.js';
35
35
  export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
36
36
  const files: GeneratedFile[] = [];
37
37
 
38
- files.push(...generateFixtures(spec));
38
+ files.push(...generateFixtures(spec, ctx));
39
39
 
40
40
  files.push({
41
41
  path: 'tests/common/mod.rs',
@@ -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,110 @@ 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
+
144
+ /** True when a scoped (`--services`) run is active. */
145
+ export function isScopedRun(ctx: EmitterContext): boolean {
146
+ return !!ctx.scopedServices && ctx.scopedServices.size > 0;
147
+ }
148
+
149
+ /**
150
+ * Barrel/index inclusion gate for one item (model, enum, fixture) under a
151
+ * scoped run. A barrel/index may only reference an item whose per-item FILE
152
+ * EXISTS ON DISK after the run — otherwise the reference dangles and the SDK
153
+ * fails to compile (the bug this guards: a brand-new model belonging to an
154
+ * out-of-scope service gets declared in `mod.rs`/`__init__.py` but its source
155
+ * file is never emitted). That on-disk set is:
156
+ * in-scope items (freshly emitted this run) ∪ items already on disk
157
+ * (recorded in the prior manifest, left untouched because scoped runs never
158
+ * prune).
159
+ * Inactive scoping ⇒ always true (a full run emits and declares everything).
160
+ *
161
+ * `relPath` is the per-item file path the emitter writes (e.g.
162
+ * `src/models/foo.rs`); `inScope` is the per-item scope predicate result
163
+ * (`isModelInScope` / `isEnumInScope`).
164
+ */
165
+ export function fileExistsAfterRun(relPath: string, inScope: boolean, ctx: EmitterContext): boolean {
166
+ if (!isScopedRun(ctx)) return true;
167
+ return inScope || (ctx.priorTargetManifestPaths?.has(relPath) ?? false);
168
+ }
169
+
170
+ /**
171
+ * Per-item basenames recorded in the prior manifest directly under `dir` (e.g.
172
+ * `src/models`) with extension `ext` (e.g. `.rs`), EXCLUDING `reserved` names
173
+ * (barrels such as `mod`, `_unions`, `__init__`). A scoped run uses this to
174
+ * RETAIN barrel declarations for items that were renamed/removed from the spec
175
+ * but whose files still sit on disk — and may still be referenced by
176
+ * out-of-scope code the scoped run did not regenerate (e.g. a stale resource
177
+ * file). Returns `[]` for a full run or when no prior manifest is available;
178
+ * the caller is responsible for de-duping against items it already emitted.
179
+ */
180
+ export function priorManifestBasenames(
181
+ ctx: EmitterContext,
182
+ dir: string,
183
+ ext: string,
184
+ reserved: Set<string> = new Set(),
185
+ ): string[] {
186
+ if (!isScopedRun(ctx)) return [];
187
+ const paths = ctx.priorTargetManifestPaths;
188
+ if (!paths) return [];
189
+ const prefix = dir.endsWith('/') ? dir : `${dir}/`;
190
+ const out: string[] = [];
191
+ for (const p of paths) {
192
+ if (!p.startsWith(prefix) || !p.endsWith(ext)) continue;
193
+ const base = p.slice(prefix.length, p.length - ext.length);
194
+ if (base.includes('/')) continue; // direct children only
195
+ if (reserved.has(base)) continue;
196
+ out.push(base);
197
+ }
198
+ return out;
199
+ }
200
+
97
201
  /**
98
202
  * Get the mount target for an IR service.
99
203
  * Checks the first resolved operation that belongs to this service.
@@ -0,0 +1,247 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { dotnetEmitter } from '../../src/dotnet/index.js';
3
+ import { generateTests } from '../../src/dotnet/tests.js';
4
+ import { discriminatedUnions } from '../../src/dotnet/type-map.js';
5
+ import { primeEnumAliases } from '../../src/dotnet/enums.js';
6
+ import type { EmitterContext, ApiSpec, Model, Service } from '@workos/oagen';
7
+ import { defaultSdkBehavior } from '@workos/oagen';
8
+
9
+ /**
10
+ * Scoped (`--services`) runs emit per-model `Entities/*.cs` only for in-scope
11
+ * models, but the polymorphic-dispatch aggregates (discriminated-union JSON
12
+ * converters, per-model test fixtures) were previously built from the FULL
13
+ * spec. A brand-new out-of-scope variant would then be referenced by a
14
+ * converter whose `.cs` file is never emitted (CS0246). These tests assert the
15
+ * `fileExistsAfterRun` gate excludes brand-new out-of-scope items while
16
+ * retaining renamed/removed-but-still-on-disk ones.
17
+ */
18
+ const emptySpec: ApiSpec = {
19
+ name: 'Test',
20
+ version: '1.0.0',
21
+ baseUrl: '',
22
+ services: [],
23
+ models: [],
24
+ enums: [],
25
+ sdk: defaultSdkBehavior(),
26
+ };
27
+
28
+ describe('dotnet/scoped aggregates', () => {
29
+ beforeEach(() => {
30
+ // discriminatedUnions is module-global and accumulates across mapTypeRef
31
+ // calls; clear it so each test sees only the unions it registers.
32
+ discriminatedUnions.clear();
33
+ primeEnumAliases([]);
34
+ });
35
+
36
+ it('gates the discriminated-union converter to variants whose .cs exists after a scoped run', () => {
37
+ // Parent model has a field typed as a discriminated union over three
38
+ // event-payload variants:
39
+ // - UserCreated: in-scope this run (emitted now)
40
+ // - OrganizationDomainStandAlone: NOT in scope, but on disk (prior run) → retained
41
+ // - SessionReauthenticated: brand-new, NOT in scope, NOT on disk → excluded
42
+ const models: Model[] = [
43
+ {
44
+ name: 'WebhookEnvelope',
45
+ fields: [
46
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
47
+ {
48
+ name: 'payload',
49
+ type: {
50
+ kind: 'union',
51
+ variants: [
52
+ { kind: 'model', name: 'UserCreated' },
53
+ { kind: 'model', name: 'OrganizationDomainStandAlone' },
54
+ { kind: 'model', name: 'SessionReauthenticated' },
55
+ ],
56
+ discriminator: {
57
+ property: 'event',
58
+ mapping: {
59
+ 'user.created': 'UserCreated',
60
+ 'organization_domain.verified': 'OrganizationDomainStandAlone',
61
+ 'session.reauthenticated': 'SessionReauthenticated',
62
+ },
63
+ },
64
+ },
65
+ required: true,
66
+ },
67
+ ],
68
+ },
69
+ {
70
+ name: 'UserCreated',
71
+ fields: [{ name: 'user_id', type: { kind: 'primitive', type: 'string' }, required: true }],
72
+ },
73
+ {
74
+ name: 'OrganizationDomainStandAlone',
75
+ fields: [{ name: 'domain', type: { kind: 'primitive', type: 'string' }, required: true }],
76
+ },
77
+ {
78
+ name: 'SessionReauthenticated',
79
+ fields: [{ name: 'session_id', type: { kind: 'primitive', type: 'string' }, required: true }],
80
+ },
81
+ ];
82
+
83
+ const ctx: EmitterContext = {
84
+ namespace: 'workos',
85
+ namespacePascal: 'WorkOS',
86
+ spec: { ...emptySpec, models },
87
+ // Scoped to the WebhookEnvelope + UserCreated surface only.
88
+ scopedServices: new Set(['Webhooks']),
89
+ scopedModelNames: new Set(['WebhookEnvelope', 'UserCreated']),
90
+ // OrganizationDomainStandAlone exists on disk from a prior full run; the
91
+ // brand-new SessionReauthenticated does not.
92
+ priorTargetManifestPaths: new Set([
93
+ 'src/WorkOS.net/Entities/WebhookEnvelope.cs',
94
+ 'src/WorkOS.net/Entities/UserCreated.cs',
95
+ 'src/WorkOS.net/Entities/OrganizationDomainStandAlone.cs',
96
+ ]),
97
+ };
98
+
99
+ const files = dotnetEmitter.generateModels!(models, ctx);
100
+ const converter = files.find((f) => f.path.includes('DiscriminatorConverter.cs'));
101
+ expect(converter).toBeDefined();
102
+ const content = converter!.content;
103
+
104
+ // In-scope variant is dispatched.
105
+ expect(content).toContain('case "user.created": return jObject.ToObject<UserCreated>(serializer);');
106
+ // Renamed/removed-but-on-disk variant is retained.
107
+ expect(content).toContain(
108
+ 'case "organization_domain.verified": return jObject.ToObject<OrganizationDomainStandAlone>(serializer);',
109
+ );
110
+ // Brand-new out-of-scope variant is EXCLUDED (its .cs is never emitted → CS0246).
111
+ expect(content).not.toContain('SessionReauthenticated');
112
+ expect(content).not.toContain('session.reauthenticated');
113
+ });
114
+
115
+ it('emits all converter variants on a full (unscoped) run', () => {
116
+ const models: Model[] = [
117
+ {
118
+ name: 'WebhookEnvelope',
119
+ fields: [
120
+ {
121
+ name: 'payload',
122
+ type: {
123
+ kind: 'union',
124
+ variants: [
125
+ { kind: 'model', name: 'UserCreated' },
126
+ { kind: 'model', name: 'SessionReauthenticated' },
127
+ ],
128
+ discriminator: {
129
+ property: 'event',
130
+ mapping: {
131
+ 'user.created': 'UserCreated',
132
+ 'session.reauthenticated': 'SessionReauthenticated',
133
+ },
134
+ },
135
+ },
136
+ required: true,
137
+ },
138
+ ],
139
+ },
140
+ {
141
+ name: 'UserCreated',
142
+ fields: [{ name: 'user_id', type: { kind: 'primitive', type: 'string' }, required: true }],
143
+ },
144
+ {
145
+ name: 'SessionReauthenticated',
146
+ fields: [{ name: 'session_id', type: { kind: 'primitive', type: 'string' }, required: true }],
147
+ },
148
+ ];
149
+
150
+ // No scopedServices → full run; every variant is dispatched.
151
+ const ctx: EmitterContext = {
152
+ namespace: 'workos',
153
+ namespacePascal: 'WorkOS',
154
+ spec: { ...emptySpec, models },
155
+ };
156
+
157
+ const files = dotnetEmitter.generateModels!(models, ctx);
158
+ const converter = files.find((f) => f.path.includes('DiscriminatorConverter.cs'))!;
159
+ expect(converter.content).toContain('ToObject<UserCreated>');
160
+ expect(converter.content).toContain('ToObject<SessionReauthenticated>');
161
+ });
162
+
163
+ it('gates per-model fixtures to models whose file exists after a scoped run', () => {
164
+ const models: Model[] = [
165
+ {
166
+ name: 'OrganizationMembership',
167
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
168
+ },
169
+ {
170
+ name: 'OrganizationDomainStandAlone',
171
+ fields: [{ name: 'domain', type: { kind: 'primitive', type: 'string' }, required: true }],
172
+ },
173
+ {
174
+ name: 'SessionReauthenticated',
175
+ fields: [{ name: 'session_id', type: { kind: 'primitive', type: 'string' }, required: true }],
176
+ },
177
+ ];
178
+
179
+ const services: Service[] = [
180
+ {
181
+ name: 'OrganizationMemberships',
182
+ operations: [
183
+ {
184
+ name: 'getOrganizationMembership',
185
+ httpMethod: 'get',
186
+ path: '/organization_memberships/{id}',
187
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
188
+ queryParams: [],
189
+ headerParams: [],
190
+ response: { kind: 'model', name: 'OrganizationMembership' },
191
+ errors: [],
192
+ injectIdempotencyKey: false,
193
+ },
194
+ ],
195
+ },
196
+ ];
197
+
198
+ const spec: ApiSpec = { ...emptySpec, services, models };
199
+ const ctx: EmitterContext = {
200
+ namespace: 'workos',
201
+ namespacePascal: 'WorkOS',
202
+ spec,
203
+ scopedServices: new Set(['OrganizationMemberships']),
204
+ scopedModelNames: new Set(['OrganizationMembership']),
205
+ // The renamed/removed model is on disk; the brand-new one is not.
206
+ priorTargetManifestPaths: new Set([
207
+ 'test/WorkOSTests/testdata/organization_membership.json',
208
+ 'test/WorkOSTests/testdata/organization_domain_stand_alone.json',
209
+ ]),
210
+ };
211
+
212
+ const files = generateTests(spec, ctx);
213
+ const paths = files.map((f) => f.path);
214
+
215
+ // In-scope model fixture is emitted.
216
+ expect(paths).toContain('testdata/organization_membership.json');
217
+ // Renamed/removed-but-on-disk fixture is retained.
218
+ expect(paths).toContain('testdata/organization_domain_stand_alone.json');
219
+ // Brand-new out-of-scope fixture is EXCLUDED (stray file for an
220
+ // unreferenceable model).
221
+ expect(paths.some((p) => p.includes('session_reauthenticated'))).toBe(false);
222
+ });
223
+
224
+ it('emits all per-model fixtures on a full (unscoped) run', () => {
225
+ const models: Model[] = [
226
+ {
227
+ name: 'OrganizationMembership',
228
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
229
+ },
230
+ {
231
+ name: 'SessionReauthenticated',
232
+ fields: [{ name: 'session_id', type: { kind: 'primitive', type: 'string' }, required: true }],
233
+ },
234
+ ];
235
+ const spec: ApiSpec = { ...emptySpec, models };
236
+ const ctx: EmitterContext = {
237
+ namespace: 'workos',
238
+ namespacePascal: 'WorkOS',
239
+ spec,
240
+ };
241
+
242
+ const files = generateTests(spec, ctx);
243
+ const paths = files.map((f) => f.path);
244
+ expect(paths).toContain('testdata/organization_membership.json');
245
+ expect(paths.some((p) => p.includes('session_reauthenticated'))).toBe(true);
246
+ });
247
+ });