@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.
@@ -0,0 +1,324 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import type { EmitterContext, ApiSpec, Model, Enum } from '@workos/oagen';
3
+ import { defaultSdkBehavior } from '@workos/oagen';
4
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { generateModels } from '../../src/go/models.js';
8
+ import { generateEnums } from '../../src/go/enums.js';
9
+
10
+ const emptySpec: ApiSpec = {
11
+ name: 'Test',
12
+ version: '1.0.0',
13
+ baseUrl: '',
14
+ services: [],
15
+ models: [],
16
+ enums: [],
17
+ sdk: defaultSdkBehavior(),
18
+ };
19
+
20
+ function strField(name: string, required = true) {
21
+ return { name, type: { kind: 'primitive', type: 'string' } as const, required };
22
+ }
23
+
24
+ let outputDir: string;
25
+
26
+ beforeEach(() => {
27
+ outputDir = mkdtempSync(join(tmpdir(), 'oagen-go-scope-'));
28
+ });
29
+ afterEach(() => {
30
+ rmSync(outputDir, { recursive: true, force: true });
31
+ });
32
+
33
+ /** Build a scoped EmitterContext pointing at the temp outputDir. */
34
+ function scopedCtx(opts: {
35
+ spec?: ApiSpec;
36
+ scopedServices: string[];
37
+ scopedModelNames?: string[];
38
+ scopedEnumNames?: string[];
39
+ priorManifestPaths: string[];
40
+ }): EmitterContext {
41
+ return {
42
+ namespace: 'workos',
43
+ namespacePascal: 'WorkOS',
44
+ spec: opts.spec ?? emptySpec,
45
+ outputDir,
46
+ scopedServices: new Set(opts.scopedServices),
47
+ scopedModelNames: opts.scopedModelNames ? new Set(opts.scopedModelNames) : undefined,
48
+ scopedEnumNames: opts.scopedEnumNames ? new Set(opts.scopedEnumNames) : undefined,
49
+ priorTargetManifestPaths: new Set(opts.priorManifestPaths),
50
+ };
51
+ }
52
+
53
+ function writePrior(relPath: string, content: string) {
54
+ const abs = join(outputDir, relPath);
55
+ mkdirSync(join(abs, '..'), { recursive: true });
56
+ writeFileSync(abs, content, 'utf-8');
57
+ }
58
+
59
+ describe('go scoped flat-file reconciliation (models.go)', () => {
60
+ it('drops a brand-new out-of-scope model (ADDITION fix / Option B)', () => {
61
+ // Prior models.go has only InScopeThing. The new spec adds OutOfScopeNew,
62
+ // which belongs to a service NOT selected this run.
63
+ writePrior(
64
+ 'models.go',
65
+ [
66
+ '// Code generated by oagen. DO NOT EDIT.',
67
+ '',
68
+ 'package workos',
69
+ '',
70
+ '// InScopeThing represents an in scope thing.',
71
+ 'type InScopeThing struct {',
72
+ '\tID string `json:"id"`',
73
+ '}',
74
+ '',
75
+ ].join('\n'),
76
+ );
77
+
78
+ const models: Model[] = [
79
+ { name: 'InScopeThing', fields: [strField('id')] },
80
+ // Structurally distinct so a "missing" result can't be confused with dedup.
81
+ { name: 'OutOfScopeNew', fields: [strField('id'), strField('extra')] },
82
+ ];
83
+ const ctx = scopedCtx({
84
+ scopedServices: ['InScope'],
85
+ scopedModelNames: ['InScopeThing'], // OutOfScopeNew NOT in scope
86
+ priorManifestPaths: ['models.go'],
87
+ });
88
+
89
+ const content = generateModels(models, ctx)[0].content;
90
+ expect(content).toContain('type InScopeThing struct {');
91
+ // Brand-new out-of-scope model must NOT be added to the flat file.
92
+ expect(content).not.toContain('OutOfScopeNew');
93
+ });
94
+
95
+ it('retains a renamed/removed type still referenced by out-of-scope code (REMOVAL fix)', () => {
96
+ // Prior models.go has OrganizationDomainStandAlone. The new spec renamed it
97
+ // to OrganizationDomain (so the old name is gone from the model set), and
98
+ // OrganizationDomain belongs to an out-of-scope service. The old name must
99
+ // be carried over so out-of-scope resource code keeps compiling.
100
+ writePrior(
101
+ 'models.go',
102
+ [
103
+ '// Code generated by oagen. DO NOT EDIT.',
104
+ '',
105
+ 'package workos',
106
+ '',
107
+ '// InScopeThing represents an in scope thing.',
108
+ 'type InScopeThing struct {',
109
+ '\tID string `json:"id"`',
110
+ '}',
111
+ '',
112
+ '// OrganizationDomainStandAlone represents an organization domain stand alone.',
113
+ 'type OrganizationDomainStandAlone struct {',
114
+ '\tID string `json:"id"`',
115
+ '\tDomain string `json:"domain"`',
116
+ '}',
117
+ '',
118
+ ].join('\n'),
119
+ );
120
+
121
+ const models: Model[] = [
122
+ { name: 'InScopeThing', fields: [strField('id')] },
123
+ // The renamed model exists in the new spec but is out of scope this run.
124
+ { name: 'OrganizationDomain', fields: [strField('id'), strField('domain')] },
125
+ ];
126
+ const ctx = scopedCtx({
127
+ scopedServices: ['InScope'],
128
+ scopedModelNames: ['InScopeThing'],
129
+ priorManifestPaths: ['models.go'],
130
+ });
131
+
132
+ const content = generateModels(models, ctx)[0].content;
133
+ expect(content).toContain('type InScopeThing struct {');
134
+ // The renamed-away type is preserved verbatim from the prior file.
135
+ expect(content).toContain('type OrganizationDomainStandAlone struct {');
136
+ // The new out-of-scope rename is NOT freshly added (it didn't exist before).
137
+ expect(content).not.toContain('type OrganizationDomain struct {');
138
+ });
139
+
140
+ it('regenerates an in-scope model with its new fields', () => {
141
+ writePrior(
142
+ 'models.go',
143
+ [
144
+ 'package workos',
145
+ '',
146
+ '// InScopeThing represents an in scope thing.',
147
+ 'type InScopeThing struct {',
148
+ '\tID string `json:"id"`',
149
+ '}',
150
+ '',
151
+ ].join('\n'),
152
+ );
153
+
154
+ const models: Model[] = [{ name: 'InScopeThing', fields: [strField('id'), strField('new_field')] }];
155
+ const ctx = scopedCtx({
156
+ scopedServices: ['InScope'],
157
+ scopedModelNames: ['InScopeThing'],
158
+ priorManifestPaths: ['models.go'],
159
+ });
160
+
161
+ const content = generateModels(models, ctx)[0].content;
162
+ expect(content).toContain('NewField string `json:"new_field"`');
163
+ });
164
+
165
+ it('force-retains an out-of-scope canonical referenced by an in-scope alias', () => {
166
+ // Two structurally-identical models. The alphabetically-first is the dedup
167
+ // canonical but is brand-new AND out of scope; the other is in scope and is
168
+ // emitted as `type InScopeAliasModel = AaaCanonical`. Without force-retain
169
+ // the canonical would be dropped, leaving the alias dangling
170
+ // (undefined: AaaCanonical) and the package failing to build.
171
+ writePrior('models.go', ['package workos', ''].join('\n'));
172
+
173
+ const fields = [strField('id'), strField('label')];
174
+ const models: Model[] = [
175
+ { name: 'AaaCanonical', fields }, // sorts first -> canonical; out of scope, brand-new
176
+ { name: 'InScopeAliasModel', fields }, // in scope -> alias to AaaCanonical
177
+ ];
178
+ const ctx = scopedCtx({
179
+ scopedServices: ['InScope'],
180
+ scopedModelNames: ['InScopeAliasModel'],
181
+ priorManifestPaths: ['models.go'],
182
+ });
183
+
184
+ const content = generateModels(models, ctx)[0].content;
185
+ expect(content).toContain('type InScopeAliasModel = AaaCanonical');
186
+ // The canonical must be present so the alias resolves.
187
+ expect(content).toContain('type AaaCanonical struct {');
188
+ });
189
+
190
+ it('full run (no scoping) emits every model unchanged', () => {
191
+ const models: Model[] = [
192
+ { name: 'InScopeThing', fields: [strField('id')] },
193
+ // Structurally distinct so it isn't deduped into a type alias.
194
+ { name: 'OutOfScopeNew', fields: [strField('id'), strField('extra')] },
195
+ ];
196
+ const ctx: EmitterContext = { namespace: 'workos', namespacePascal: 'WorkOS', spec: emptySpec };
197
+ const content = generateModels(models, ctx)[0].content;
198
+ expect(content).toContain('type InScopeThing struct {');
199
+ expect(content).toContain('type OutOfScopeNew struct {');
200
+ });
201
+ });
202
+
203
+ describe('go scoped flat-file reconciliation (enums.go)', () => {
204
+ it('drops a brand-new out-of-scope enum and retains a renamed-away one', () => {
205
+ writePrior(
206
+ 'enums.go',
207
+ [
208
+ 'package workos',
209
+ '',
210
+ '// InScopeStatus represents in scope status values.',
211
+ 'type InScopeStatus string',
212
+ '',
213
+ 'const (',
214
+ '\tInScopeStatusActive InScopeStatus = "active"',
215
+ ')',
216
+ '',
217
+ '// OldRenamedState represents old renamed state values.',
218
+ 'type OldRenamedState string',
219
+ '',
220
+ 'const (',
221
+ '\tOldRenamedStatePending OldRenamedState = "pending"',
222
+ ')',
223
+ '',
224
+ ].join('\n'),
225
+ );
226
+
227
+ const enums: Enum[] = [
228
+ { name: 'InScopeStatus', values: [{ name: 'active', value: 'active' }] },
229
+ // brand-new, out of scope
230
+ { name: 'OutOfScopeNewEnum', values: [{ name: 'x', value: 'x' }] },
231
+ // renamed away: OldRenamedState -> NewRenamedState (out of scope)
232
+ { name: 'NewRenamedState', values: [{ name: 'pending', value: 'pending' }] },
233
+ ];
234
+ const ctx = scopedCtx({
235
+ scopedServices: ['InScope'],
236
+ scopedEnumNames: ['InScopeStatus'],
237
+ priorManifestPaths: ['enums.go'],
238
+ });
239
+
240
+ const content = generateEnums(enums, ctx).find((f) => f.path === 'enums.go')!.content;
241
+ expect(content).toContain('type InScopeStatus string');
242
+ // brand-new out-of-scope enum dropped
243
+ expect(content).not.toContain('type OutOfScopeNewEnum string');
244
+ // renamed-away enum retained verbatim from prior file
245
+ expect(content).toContain('type OldRenamedState string');
246
+ // the new (out-of-scope) name is not freshly added
247
+ expect(content).not.toContain('type NewRenamedState string');
248
+ });
249
+
250
+ it('force-retains an out-of-scope canonical enum referenced by an in-scope alias', () => {
251
+ writePrior('enums.go', ['package workos', ''].join('\n'));
252
+
253
+ const enums: Enum[] = [
254
+ // identical values -> deduped; alphabetically-first is the canonical
255
+ { name: 'AaaCanonicalStatus', values: [{ name: 'active', value: 'active' }] }, // out of scope, brand-new
256
+ { name: 'InScopeAliasStatus', values: [{ name: 'active', value: 'active' }] }, // in scope -> alias
257
+ ];
258
+ const ctx = scopedCtx({
259
+ scopedServices: ['InScope'],
260
+ scopedEnumNames: ['InScopeAliasStatus'],
261
+ priorManifestPaths: ['enums.go'],
262
+ });
263
+
264
+ const content = generateEnums(enums, ctx).find((f) => f.path === 'enums.go')!.content;
265
+ expect(content).toContain('type InScopeAliasStatus = AaaCanonicalStatus');
266
+ // The canonical enum must be present so the alias resolves.
267
+ expect(content).toContain('type AaaCanonicalStatus string');
268
+ });
269
+ });
270
+
271
+ describe('go scoped flat-file reconciliation (events.go)', () => {
272
+ it('keeps the prior event const set: drops new events, retains existing', () => {
273
+ writePrior(
274
+ 'pkg/events/events.go',
275
+ [
276
+ 'package events',
277
+ '',
278
+ '// Event is a WorkOS event type.',
279
+ 'type Event string',
280
+ '',
281
+ 'const (',
282
+ '\tOrganizationCreated = "organization.created"',
283
+ ')',
284
+ '',
285
+ ].join('\n'),
286
+ );
287
+
288
+ const enums: Enum[] = [
289
+ {
290
+ name: 'CreateWebhookEndpointEvents',
291
+ values: [
292
+ { name: 'organization.created', value: 'organization.created' },
293
+ // brand-new event from an out-of-scope service
294
+ { name: 'session.reauthenticated', value: 'session.reauthenticated' },
295
+ ],
296
+ },
297
+ ];
298
+ const ctx = scopedCtx({
299
+ scopedServices: ['InScope'],
300
+ priorManifestPaths: ['pkg/events/events.go'],
301
+ });
302
+
303
+ const events = generateEnums(enums, ctx).find((f) => f.path === 'pkg/events/events.go')!.content;
304
+ expect(events).toContain('OrganizationCreated = "organization.created"');
305
+ // brand-new out-of-scope event must not be added to the flat events file
306
+ expect(events).not.toContain('session.reauthenticated');
307
+ });
308
+
309
+ it('full run emits all events', () => {
310
+ const enums: Enum[] = [
311
+ {
312
+ name: 'CreateWebhookEndpointEvents',
313
+ values: [
314
+ { name: 'organization.created', value: 'organization.created' },
315
+ { name: 'session.reauthenticated', value: 'session.reauthenticated' },
316
+ ],
317
+ },
318
+ ];
319
+ const ctx: EmitterContext = { namespace: 'workos', namespacePascal: 'WorkOS', spec: emptySpec };
320
+ const events = generateEnums(enums, ctx).find((f) => f.path === 'pkg/events/events.go')!.content;
321
+ expect(events).toContain('organization.created');
322
+ expect(events).toContain('session.reauthenticated');
323
+ });
324
+ });
@@ -101,6 +101,80 @@ describe('kotlin/models', () => {
101
101
  expect(filePaths.some((p) => p.includes('ListMetadata.kt'))).toBe(false);
102
102
  });
103
103
 
104
+ it('scoped run: WorkOSEvent registry omits brand-new out-of-scope events but retains on-disk ones', () => {
105
+ // An event envelope model: id + event(literal) + created_at + data.
106
+ const eventModel = (name: string, wire: string): Model => ({
107
+ name,
108
+ fields: [
109
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
110
+ { name: 'event', type: { kind: 'literal', value: wire }, required: true },
111
+ { name: 'created_at', type: { kind: 'primitive', type: 'string', format: 'date-time' }, required: true },
112
+ { name: 'data', type: { kind: 'map', valueType: { kind: 'primitive', type: 'unknown' } }, required: true },
113
+ ],
114
+ });
115
+
116
+ const models: Model[] = [
117
+ eventModel('OrganizationMembershipCreated', 'organization_membership.created'), // in scope
118
+ eventModel('SessionReauthenticated', 'session.reauthenticated'), // brand-new, out of scope, NOT on disk
119
+ eventModel('PipesConnectedAccountConnectionFailed', 'connected_account.connection_failed'), // out of scope, ON disk
120
+ ];
121
+ const spec: ApiSpec = { ...emptySpec, models };
122
+
123
+ // Scoped run: only OrganizationMembershipCreated is in scope this run.
124
+ // PipesConnectedAccountConnectionFailed is out of scope but its .kt file is
125
+ // still on disk (recorded in the prior manifest), so it must be retained.
126
+ // SessionReauthenticated is brand new + out of scope ⇒ its file is never
127
+ // emitted ⇒ it must be omitted from the registry.
128
+ const scopedCtx: EmitterContext = {
129
+ ...ctx,
130
+ spec,
131
+ scopedServices: new Set(['OrganizationMembership']),
132
+ scopedModelNames: new Set(['OrganizationMembershipCreated']),
133
+ priorTargetManifestPaths: new Set(['src/main/kotlin/com/workos/models/PipesConnectedAccountConnectionFailed.kt']),
134
+ };
135
+
136
+ generateEnums([], scopedCtx);
137
+ const files = generateModels(models, scopedCtx);
138
+
139
+ const registry = files.find((f) => f.path.endsWith('WorkOSEvent.kt'));
140
+ expect(registry).toBeDefined();
141
+ const content = registry!.content;
142
+
143
+ // In scope ⇒ listed.
144
+ expect(content).toContain('OrganizationMembershipCreated::class');
145
+ // Out of scope but on disk ⇒ retained.
146
+ expect(content).toContain('PipesConnectedAccountConnectionFailed::class');
147
+ // Brand-new + out of scope ⇒ omitted (this was the build break).
148
+ expect(content).not.toContain('SessionReauthenticated::class');
149
+
150
+ // The per-model FILE for the out-of-scope events must NOT be emitted.
151
+ expect(files.some((f) => f.path.endsWith('SessionReauthenticated.kt'))).toBe(false);
152
+ expect(files.some((f) => f.path.endsWith('PipesConnectedAccountConnectionFailed.kt'))).toBe(false);
153
+ });
154
+
155
+ it('full run: WorkOSEvent registry lists every event model', () => {
156
+ const eventModel = (name: string, wire: string): Model => ({
157
+ name,
158
+ fields: [
159
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
160
+ { name: 'event', type: { kind: 'literal', value: wire }, required: true },
161
+ { name: 'created_at', type: { kind: 'primitive', type: 'string', format: 'date-time' }, required: true },
162
+ { name: 'data', type: { kind: 'map', valueType: { kind: 'primitive', type: 'unknown' } }, required: true },
163
+ ],
164
+ });
165
+ const models: Model[] = [
166
+ eventModel('OrganizationMembershipCreated', 'organization_membership.created'),
167
+ eventModel('SessionReauthenticated', 'session.reauthenticated'),
168
+ ];
169
+ const spec: ApiSpec = { ...emptySpec, models };
170
+
171
+ generateEnums([], { ...ctx, spec });
172
+ const files = generateModels(models, { ...ctx, spec });
173
+ const registry = files.find((f) => f.path.endsWith('WorkOSEvent.kt'))!;
174
+ expect(registry.content).toContain('OrganizationMembershipCreated::class');
175
+ expect(registry.content).toContain('SessionReauthenticated::class');
176
+ });
177
+
104
178
  it('deduplicates structurally identical models preferring shorter names', () => {
105
179
  const models: Model[] = [
106
180
  {
@@ -142,6 +142,39 @@ describe('kotlin/tests', () => {
142
142
  expect(content).toContain('assertEquals(parsed.toInstant(), reparsed.toInstant())');
143
143
  });
144
144
 
145
+ it('scoped run: round-trip test omits brand-new out-of-scope models, retains on-disk ones', () => {
146
+ const roundTripModel = (name: string): Model => ({
147
+ name,
148
+ fields: [
149
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
150
+ { name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
151
+ ],
152
+ });
153
+ const scopedModels: Model[] = [
154
+ roundTripModel('Organization'), // in scope
155
+ roundTripModel('PipesPipe'), // brand-new, out of scope, NOT on disk
156
+ roundTripModel('Directory'), // out of scope but ON disk
157
+ ];
158
+ const scopedSpec: ApiSpec = { ...spec, models: scopedModels };
159
+ const scopedCtx: EmitterContext = {
160
+ ...ctx,
161
+ spec: scopedSpec,
162
+ resolvedOperations: buildResolvedOps(services),
163
+ scopedServices: new Set(['Organizations']),
164
+ scopedModelNames: new Set(['Organization']),
165
+ priorTargetManifestPaths: new Set(['src/main/kotlin/com/workos/models/Directory.kt']),
166
+ };
167
+
168
+ generateEnums([], scopedCtx);
169
+ const files = generateTests(scopedSpec, scopedCtx);
170
+ const roundTrip = files.find((f) => f.path.includes('GeneratedModelRoundTripTest.kt'))!;
171
+ const content = roundTrip.content;
172
+
173
+ expect(content).toContain('Organization round-trips through Jackson');
174
+ expect(content).toContain('Directory round-trips through Jackson'); // retained (on disk)
175
+ expect(content).not.toContain('PipesPipe round-trips through Jackson'); // omitted (brand-new)
176
+ });
177
+
145
178
  it('emits valid ISO-8601 for date-time fields in round-trip fixtures', () => {
146
179
  const dtModels: Model[] = [
147
180
  {
@@ -0,0 +1,205 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateModels } from '../../src/python/models.js';
3
+ import { generateTests } from '../../src/python/tests.js';
4
+ import type { EmitterContext, ApiSpec, Service, Model } from '@workos/oagen';
5
+ import { defaultSdkBehavior } from '@workos/oagen';
6
+
7
+ // A scoped run selects the `Widgets` mount and pulls in only `WidgetA`. The
8
+ // spec ALSO contains models belonging to the out-of-scope `Gadgets` service:
9
+ // - GadgetBrandNew : brand-new, NOT in the prior manifest → must NOT be
10
+ // referenced by any aggregate (the ADDITION bug).
11
+ // - GadgetOnDisk : already on disk (recorded in the prior manifest) →
12
+ // must be RETAINED by the barrel.
13
+ // The prior manifest also records `gadget_renamed.py`, a per-model file for a
14
+ // model that the current spec no longer produces (renamed/removed) → must be
15
+ // RETAINED via a wholesale `import *` so stale out-of-scope code still resolves.
16
+ const services: Service[] = [
17
+ {
18
+ name: 'Widgets',
19
+ operations: [
20
+ {
21
+ name: 'getWidget',
22
+ httpMethod: 'get',
23
+ path: '/widgets/{id}',
24
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
25
+ queryParams: [],
26
+ headerParams: [],
27
+ response: { kind: 'model', name: 'WidgetA' },
28
+ errors: [],
29
+ injectIdempotencyKey: false,
30
+ },
31
+ ],
32
+ },
33
+ {
34
+ name: 'Gadgets',
35
+ operations: [
36
+ {
37
+ name: 'getGadgetBrandNew',
38
+ httpMethod: 'get',
39
+ path: '/gadgets/brand-new/{id}',
40
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
41
+ queryParams: [],
42
+ headerParams: [],
43
+ response: { kind: 'model', name: 'GadgetBrandNew' },
44
+ errors: [],
45
+ injectIdempotencyKey: false,
46
+ },
47
+ {
48
+ name: 'getGadgetOnDisk',
49
+ httpMethod: 'get',
50
+ path: '/gadgets/on-disk/{id}',
51
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
52
+ queryParams: [],
53
+ headerParams: [],
54
+ response: { kind: 'model', name: 'GadgetOnDisk' },
55
+ errors: [],
56
+ injectIdempotencyKey: false,
57
+ },
58
+ ],
59
+ },
60
+ ];
61
+
62
+ const models: Model[] = [
63
+ {
64
+ name: 'WidgetA',
65
+ fields: [
66
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
67
+ { name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
68
+ ],
69
+ },
70
+ {
71
+ name: 'GadgetBrandNew',
72
+ fields: [
73
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
74
+ { name: 'color', type: { kind: 'primitive', type: 'string' }, required: true },
75
+ ],
76
+ },
77
+ {
78
+ name: 'GadgetOnDisk',
79
+ fields: [
80
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
81
+ { name: 'size', type: { kind: 'primitive', type: 'string' }, required: true },
82
+ ],
83
+ },
84
+ ];
85
+
86
+ const spec: ApiSpec = {
87
+ name: 'TestAPI',
88
+ version: '1.0.0',
89
+ baseUrl: 'https://api.workos.com',
90
+ services,
91
+ models,
92
+ enums: [],
93
+ sdk: defaultSdkBehavior(),
94
+ };
95
+
96
+ // Scoped to the Widgets mount: only WidgetA is in-scope. The prior manifest
97
+ // records GadgetOnDisk's file plus a now-removed gadget_renamed.py file.
98
+ function scopedCtx(): EmitterContext {
99
+ return {
100
+ namespace: 'workos',
101
+ namespacePascal: 'WorkOS',
102
+ spec,
103
+ scopedServices: new Set(['Widgets']),
104
+ scopedModelNames: new Set(['WidgetA']),
105
+ scopedEnumNames: new Set<string>(),
106
+ priorTargetManifestPaths: new Set([
107
+ 'src/workos/widgets/models/widget_a.py',
108
+ 'src/workos/gadgets/models/gadget_on_disk.py',
109
+ 'src/workos/gadgets/models/gadget_renamed.py',
110
+ // Fixtures live under tests/fixtures/ and are recorded in the manifest too;
111
+ // the on-disk gadget's fixture is retained the same way its model file is.
112
+ 'tests/fixtures/widget_a.json',
113
+ 'tests/fixtures/gadget_on_disk.json',
114
+ ]),
115
+ } as EmitterContext;
116
+ }
117
+
118
+ describe('python scoped aggregates', () => {
119
+ describe('models barrel (__init__.py)', () => {
120
+ it('writes only the in-scope per-model file', () => {
121
+ const files = generateModels(models, scopedCtx());
122
+ const modelFiles = files.filter((f) => f.path.endsWith('.py') && f.path.includes('/models/'));
123
+ const writtenModelFiles = modelFiles.filter((f) => !f.path.endsWith('__init__.py'));
124
+ // Only WidgetA's file is emitted; the two out-of-scope gadget files are not.
125
+ expect(writtenModelFiles.map((f) => f.path)).toEqual(['src/workos/widgets/models/widget_a.py']);
126
+ });
127
+
128
+ it('does NOT reference a brand-new out-of-scope model in any barrel (ADDITION fix)', () => {
129
+ const files = generateModels(models, scopedCtx());
130
+ const barrels = files.filter((f) => f.path.endsWith('__init__.py'));
131
+ for (const barrel of barrels) {
132
+ expect(barrel.content).not.toContain('GadgetBrandNew');
133
+ expect(barrel.content).not.toContain('gadget_brand_new');
134
+ }
135
+ });
136
+
137
+ it('RETAINS an on-disk out-of-scope model in its barrel (no dangling drop)', () => {
138
+ const files = generateModels(models, scopedCtx());
139
+ const gadgetBarrel = files.find((f) => f.path === 'src/workos/gadgets/models/__init__.py');
140
+ expect(gadgetBarrel).toBeDefined();
141
+ // GadgetOnDisk is still in the spec but out of scope; its file already
142
+ // exists on disk (prior manifest), so the barrel keeps re-exporting it.
143
+ expect(gadgetBarrel!.content).toContain('from .gadget_on_disk import GadgetOnDisk as GadgetOnDisk');
144
+ });
145
+
146
+ it('RETAINS a renamed/removed on-disk file via wholesale import (REMOVAL fix)', () => {
147
+ const files = generateModels(models, scopedCtx());
148
+ const gadgetBarrel = files.find((f) => f.path === 'src/workos/gadgets/models/__init__.py');
149
+ expect(gadgetBarrel).toBeDefined();
150
+ // gadget_renamed.py is on disk (prior manifest) but no longer produced by
151
+ // the spec; retain it wholesale so stale out-of-scope imports resolve.
152
+ expect(gadgetBarrel!.content).toContain('from .gadget_renamed import * # noqa: F401,F403');
153
+ });
154
+
155
+ it('still references the in-scope model in its own barrel', () => {
156
+ const files = generateModels(models, scopedCtx());
157
+ const widgetBarrel = files.find((f) => f.path === 'src/workos/widgets/models/__init__.py');
158
+ expect(widgetBarrel).toBeDefined();
159
+ expect(widgetBarrel!.content).toContain('from .widget_a import WidgetA as WidgetA');
160
+ });
161
+ });
162
+
163
+ describe('round-trip test + fixtures', () => {
164
+ it('does not reference or fixture a brand-new out-of-scope model', () => {
165
+ const files = generateTests(spec, scopedCtx());
166
+ const roundTrip = files.find((f) => f.path === 'tests/test_models_round_trip.py');
167
+ expect(roundTrip).toBeDefined();
168
+ expect(roundTrip!.content).not.toContain('GadgetBrandNew');
169
+ // No fixture for the brand-new out-of-scope model.
170
+ expect(files.find((f) => f.path === 'tests/fixtures/gadget_brand_new.json')).toBeUndefined();
171
+ });
172
+
173
+ it('keeps the in-scope model and retains the on-disk out-of-scope model', () => {
174
+ const files = generateTests(spec, scopedCtx());
175
+ const roundTrip = files.find((f) => f.path === 'tests/test_models_round_trip.py');
176
+ expect(roundTrip!.content).toContain('WidgetA');
177
+ // GadgetOnDisk's per-model file exists on disk (prior manifest), so the
178
+ // round-trip test may still import it and its fixture is emitted.
179
+ expect(roundTrip!.content).toContain('GadgetOnDisk');
180
+ expect(files.find((f) => f.path === 'tests/fixtures/gadget_on_disk.json')).toBeDefined();
181
+ expect(files.find((f) => f.path === 'tests/fixtures/widget_a.json')).toBeDefined();
182
+ });
183
+ });
184
+
185
+ describe('full (non-scoped) run is unaffected', () => {
186
+ it('references every model in its barrel and round-trip test', () => {
187
+ const fullCtx: EmitterContext = {
188
+ namespace: 'workos',
189
+ namespacePascal: 'WorkOS',
190
+ spec,
191
+ };
192
+ const modelFiles = generateModels(models, fullCtx);
193
+ const gadgetBarrel = modelFiles.find((f) => f.path === 'src/workos/gadgets/models/__init__.py');
194
+ expect(gadgetBarrel!.content).toContain('GadgetBrandNew');
195
+ expect(gadgetBarrel!.content).toContain('GadgetOnDisk');
196
+ // No retention import on a full run.
197
+ expect(gadgetBarrel!.content).not.toContain('import *');
198
+
199
+ const testFiles = generateTests(spec, fullCtx);
200
+ const roundTrip = testFiles.find((f) => f.path === 'tests/test_models_round_trip.py');
201
+ expect(roundTrip!.content).toContain('GadgetBrandNew');
202
+ expect(testFiles.find((f) => f.path === 'tests/fixtures/gadget_brand_new.json')).toBeDefined();
203
+ });
204
+ });
205
+ });