@workos/oagen-emitters 0.19.0 → 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.
@@ -30,6 +30,8 @@ import {
30
30
  lookupResolved,
31
31
  buildHiddenParams,
32
32
  collectGroupedParamNames,
33
+ isModelInScope,
34
+ fileExistsAfterRun,
33
35
  } from '../shared/resolved-ops.js';
34
36
  import { resolveWrapperParams } from '../shared/wrapper-utils.js';
35
37
  import { pythonLiteral } from './wrappers.js';
@@ -100,7 +102,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
100
102
  const files: GeneratedFile[] = [];
101
103
 
102
104
  // Generate fixture JSON files
103
- const fixtures = generateFixtures(spec);
105
+ const fixtures = generateFixtures(spec, ctx);
104
106
  for (const fixture of fixtures) {
105
107
  files.push({
106
108
  path: fixture.path,
@@ -1461,22 +1463,39 @@ function generateModelRoundTripTests(spec: ApiSpec, ctx: EmitterContext): Genera
1461
1463
 
1462
1464
  const nonPaginatedRefs = collectNonPaginatedResponseModelNames(spec.services);
1463
1465
  const listMetadataNeeded = collectReferencedListMetadataModels(spec.models, nonPaginatedRefs);
1464
- const models = spec.models.filter(
1465
- (m) =>
1466
- !(isListWrapperModel(m) && !nonPaginatedRefs.has(m.name)) &&
1467
- !(isListMetadataModel(m) && !listMetadataNeeded.has(m.name)) &&
1468
- !requestOnlyModelNames.has(m.name),
1469
- );
1470
- if (models.length === 0) return null;
1471
1466
 
1472
1467
  // The round-trip test imports models from their *natural* (pre-relocation)
1473
1468
  // service so existing callers keep working — those imports resolve via the
1474
1469
  // BC re-exports that the model emitter writes into each service barrel.
1475
- const modelToService = computeSchemaPlacement(spec, ctx).originalModelToService;
1470
+ const placement = computeSchemaPlacement(spec, ctx);
1471
+ const modelToService = placement.originalModelToService;
1472
+ // The per-model FILE is written to the RELOCATED dir; use it to compute the
1473
+ // gate's relPath so it lines up with the prior manifest.
1474
+ const relocatedModelToService = placement.modelToService;
1476
1475
  const roundTripDirMap = buildMountDirMap(ctx);
1477
1476
  const resolveDir = (irService: string | undefined) =>
1478
1477
  irService ? (roundTripDirMap.get(irService) ?? 'common') : 'common';
1479
1478
 
1479
+ // Scoped runs: the round-trip test must only reference a model whose per-model
1480
+ // file exists on disk after the run (in-scope, or already present from the
1481
+ // prior manifest). A brand-new out-of-scope model is excluded — its file is
1482
+ // never emitted, so importing it would raise ModuleNotFoundError. A full run
1483
+ // keeps every model (fileExistsAfterRun ⇒ true).
1484
+ const modelFileExists = (name: string): boolean => {
1485
+ const dir = resolveDir(relocatedModelToService.get(name));
1486
+ const path = `src/${ctx.namespace}/${dir}/models/${fileName(name)}.py`;
1487
+ return fileExistsAfterRun(path, isModelInScope(name, ctx), ctx);
1488
+ };
1489
+
1490
+ const models = spec.models.filter(
1491
+ (m) =>
1492
+ !(isListWrapperModel(m) && !nonPaginatedRefs.has(m.name)) &&
1493
+ !(isListMetadataModel(m) && !listMetadataNeeded.has(m.name)) &&
1494
+ !requestOnlyModelNames.has(m.name) &&
1495
+ modelFileExists(m.name),
1496
+ );
1497
+ if (models.length === 0) return null;
1498
+
1480
1499
  const lines: string[] = [];
1481
1500
  lines.push('"""Model round-trip tests: from_dict(to_dict()) preserves data."""');
1482
1501
  lines.push('');
package/src/ruby/tests.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ApiSpec, EmitterContext, GeneratedFile, Model, Operation, ResolvedWrapper, TypeRef } from '@workos/oagen';
2
+ import { assignModelsToServices } from '@workos/oagen';
2
3
  import {
3
4
  className,
4
5
  fileName,
@@ -10,6 +11,7 @@ import {
10
11
  resolveMethodName,
11
12
  buildExportedClassNameSet,
12
13
  resolveServiceTarget,
14
+ buildMountDirMap,
13
15
  } from './naming.js';
14
16
  import {
15
17
  buildResolvedLookup,
@@ -17,8 +19,11 @@ import {
17
19
  lookupResolved,
18
20
  buildHiddenParams,
19
21
  collectBodyFieldTypes,
22
+ isModelInScope,
23
+ fileExistsAfterRun,
20
24
  } from '../shared/resolved-ops.js';
21
25
  import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
26
+ import { classifyUnassignedModel } from './models.js';
22
27
  import { resolveWrapperParams } from '../shared/wrapper-utils.js';
23
28
  import { buildGroupOwnerMap, pickVariantParamType } from './parameter-groups.js';
24
29
 
@@ -203,7 +208,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
203
208
  });
204
209
  }
205
210
 
206
- files.push(generateModelRoundTripTest(spec));
211
+ files.push(generateModelRoundTripTest(spec, ctx));
207
212
 
208
213
  return files;
209
214
  }
@@ -212,8 +217,19 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
212
217
  * Emit test/workos/model_round_trip_test.rb that round-trips every non-wrapper
213
218
  * model through `.new(json)` and `.to_json`, asserting the result is a Hash and
214
219
  * that required fields appear with the seeded values.
220
+ *
221
+ * This is a whole-suite AGGREGATE: it references `WorkOS::<Class>` for every
222
+ * model. Under a scoped (`--services`) run only the selected services' per-model
223
+ * `.rb` files are (re)written, so a test referencing a brand-new out-of-scope
224
+ * model whose file was never emitted would raise `NameError: uninitialized
225
+ * constant`. Each per-model test is therefore gated by {@link fileExistsAfterRun}
226
+ * on the SAME path `models.ts` writes (`lib/workos/<dir>/<file>.rb`): emit only
227
+ * when that file will exist on disk after the run — in-scope (emitted this run)
228
+ * OR already present from a prior run (`priorTargetManifestPaths`). Brand-new
229
+ * out-of-scope models are excluded; renamed/removed-but-on-disk models are
230
+ * retained. A full run (scoping inert) keeps every model.
215
231
  */
216
- function generateModelRoundTripTest(spec: ApiSpec): GeneratedFile {
232
+ function generateModelRoundTripTest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
217
233
  const lines: string[] = [];
218
234
  lines.push(`require 'test_helper'`);
219
235
  lines.push('');
@@ -223,7 +239,24 @@ function generateModelRoundTripTest(spec: ApiSpec): GeneratedFile {
223
239
  const enumNames = new Set(spec.enums.map((e) => e.name));
224
240
  const emitted = new Set<string>();
225
241
 
242
+ // Resolve each model's per-model file path EXACTLY as models.ts does, so the
243
+ // scope gate keys on the same path the per-model FILE writer uses.
244
+ const modelToService = assignModelsToServices(models, ctx.spec.services, ctx.modelHints);
245
+ const mountDirMap = buildMountDirMap(ctx);
246
+ const dirFor = (modelName: string): string => {
247
+ const service = modelToService.get(modelName);
248
+ if (!service) return classifyUnassignedModel(modelName);
249
+ return mountDirMap.get(service) ?? classifyUnassignedModel(modelName);
250
+ };
251
+
226
252
  for (const model of models) {
253
+ // Skip models whose per-model `.rb` file will NOT exist on disk after this
254
+ // run (brand-new + out-of-scope under `--services`). Without this gate the
255
+ // aggregate would reference an undefined constant. The path mirrors
256
+ // models.ts's `lib/workos/${dirFor}/${fileName}.rb`.
257
+ const modelFilePath = `lib/workos/${dirFor(model.name)}/${fileName(model.name)}.rb`;
258
+ if (!fileExistsAfterRun(modelFilePath, isModelInScope(model.name, ctx), ctx)) continue;
259
+
227
260
  // Avoid duplicate test names when two IR model names collapse to the same
228
261
  // snake_case file name (we use the file name as the test suffix).
229
262
  const fileBase = fileName(model.name);
package/src/rust/enums.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
2
2
  import { typeName, moduleName, variantName } from './naming.js';
3
- import { isEnumInScope } from '../shared/resolved-ops.js';
3
+ import { isEnumInScope, fileExistsAfterRun, priorManifestBasenames } from '../shared/resolved-ops.js';
4
4
 
5
5
  /**
6
6
  * Generate one Rust source file per enum under `src/enums/`, plus a
@@ -28,20 +28,34 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
28
28
  const mod = moduleName(e.name);
29
29
  if (seen.has(mod)) continue;
30
30
  seen.add(mod);
31
- // The barrel (`src/enums/mod.rs`) must declare every enum's module so Rust
32
- // compiles even in a scoped run — `moduleNames` is collected from the FULL
33
- // enum set regardless of scope.
34
- moduleNames.push(mod);
35
-
36
- // Only the per-enum `.rs` FILE write is scoped (FR-1.4). In a scoped run we
37
- // skip emitting files for out-of-scope enums, but the barrel above still
38
- // declares their modules (their existing `.rs` files stay untouched on disk).
39
- if (!isEnumInScope(e.name, ctx)) continue;
40
- files.push({
41
- path: `src/enums/${mod}.rs`,
42
- content: renderEnum(e),
43
- overwriteExisting: true,
44
- });
31
+
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
+ }
45
59
  }
46
60
 
47
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,7 +2,7 @@ import type { Model, EmitterContext, GeneratedFile, Field, TypeRef } from '@work
2
2
  import { typeName, domainFieldName, moduleName } from './naming.js';
3
3
  import { mapTypeRef, makeOptional, UnionRegistry } from './type-map.js';
4
4
  import { applySecretRedaction } from './secret.js';
5
- import { isModelInScope } from '../shared/resolved-ops.js';
5
+ import { isModelInScope, fileExistsAfterRun, priorManifestBasenames } from '../shared/resolved-ops.js';
6
6
 
7
7
  const HEADER_PLACEHOLDER = ''; // engine prepends fileHeader()
8
8
 
@@ -33,27 +33,45 @@ export function generateModels(models: Model[], ctx: EmitterContext, registry: U
33
33
  const mod = moduleName(model.name);
34
34
  if (seen.has(mod)) continue;
35
35
  seen.add(mod);
36
- // The barrel (`src/models/mod.rs`) must declare every model's module so
37
- // Rust compiles even in a scoped run — `moduleNames` is collected from the
38
- // FULL model set regardless of scope.
39
- moduleNames.push(mod);
40
36
 
41
37
  // renderModel registers inline unions into `registry` as a side effect, and
42
38
  // `_unions.rs` is rendered later (in generateClient) from that registry — so
43
39
  // it MUST run for every model, even out-of-scope ones, or scoped runs drop
44
40
  // unions. Compute content unconditionally; only the per-model `.rs` FILE write
45
- // is scoped (FR-1.4). The barrel above still declares every module, and an
46
- // out-of-scope model's existing `.rs` file stays untouched on disk.
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);
47
44
  const hintPath = ctx.overlayLookup?.fileBySymbol?.get(model.name);
48
45
  const path = hintPath ?? `src/models/${mod}.rs`;
49
46
  const content = renderModel(model, registry, taggedVariantFields.get(model.name));
50
- if (isModelInScope(model.name, ctx)) {
47
+ if (inScope) {
51
48
  files.push({
52
49
  path,
53
50
  content,
54
51
  overwriteExisting: true,
55
52
  });
56
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
+ }
57
75
  }
58
76
 
59
77
  // Always include the unions module in the barrel so downstream stages
package/src/rust/tests.ts CHANGED
@@ -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',
@@ -141,6 +141,63 @@ export function isEnumInScope(enumName: string, ctx: EmitterContext): boolean {
141
141
  return !scope || scope.has(enumName);
142
142
  }
143
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
+
144
201
  /**
145
202
  * Get the mount target for an IR service.
146
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
+ });