@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.
package/src/go/models.ts CHANGED
@@ -3,6 +3,8 @@ import { walkTypeRef } from '@workos/oagen';
3
3
  import { mapTypeRef } from './type-map.js';
4
4
  import { className, domainFieldName } from './naming.js';
5
5
  import { lowerFirstForDoc, fieldDocComment, articleFor } from '../shared/naming-utils.js';
6
+ import { isModelInScope, isScopedRun } from '../shared/resolved-ops.js';
7
+ import { reconcileFlatBlocks, readPriorFile, parseFlatGoBlocks, type NamedBlock } from './flat-merge.js';
6
8
 
7
9
  // Import and re-export shared model detection utilities
8
10
  import {
@@ -114,6 +116,17 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
114
116
  // Pick canonical for each duplicate group.
115
117
  // Empty structs (hash '') are now properly populated by oneOf flattening,
116
118
  // so we still skip aliasing them to avoid aliasing truly empty structs.
119
+ // For the batched-alias block below: in a scoped run, only emit an alias that
120
+ // is in scope OR already on disk (present in the prior models.go). Otherwise a
121
+ // `>= 5` structural group containing a single in-scope alias would drag every
122
+ // brand-new out-of-scope alias in the group into the file. A full run keeps
123
+ // every alias (isScopedRun is false).
124
+ const priorModelNames = isScopedRun(ctx)
125
+ ? new Set(parseFlatGoBlocks(readPriorFile('models.go', ctx) ?? '').blocks.flatMap((b) => b.names))
126
+ : new Set<string>();
127
+ const aliasEmitted = (name: string): boolean =>
128
+ !isScopedRun(ctx) || isModelInScope(name, ctx) || priorModelNames.has(className(name));
129
+
117
130
  const aliasOf = new Map<string, string>();
118
131
  for (const [hash, names] of hashGroups) {
119
132
  if (names.length <= 1) continue;
@@ -125,6 +138,30 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
125
138
  }
126
139
  }
127
140
 
141
+ // The structural-dedup canonical is chosen alphabetically, independent of
142
+ // scope, so an in-scope alias can point at a canonical that is itself out of
143
+ // scope and brand-new — which the reconciler would drop, leaving the alias
144
+ // `type InScopeModel = Canonical` dangling (`undefined: Canonical`). Force-
145
+ // retain any canonical referenced by an in-scope alias. The canonical is
146
+ // structurally identical to that alias, so its field types are reachable from
147
+ // the in-scope service and therefore also emitted. In a mixed batched-alias
148
+ // block the in-scope member forces the shared canonical, covering its on-disk
149
+ // siblings that emit fresh in the same block.
150
+ const forcedCanonicals = new Set<string>();
151
+ if (isScopedRun(ctx)) {
152
+ for (const [aliasName, canonical] of aliasOf) {
153
+ if (isModelInScope(aliasName, ctx)) forcedCanonicals.add(canonical);
154
+ }
155
+ }
156
+
157
+ // Build one NamedBlock per emitted model type. In a scoped run these blocks
158
+ // are reconciled against the prior models.go (see reconcileFlatBlocks): blocks
159
+ // for out-of-scope models that didn't exist before are dropped, and prior
160
+ // blocks for renamed/removed types are carried over verbatim. A full run emits
161
+ // every block unchanged. Tracking the IR model name(s) per block (a batched
162
+ // alias group declares several) is what lets the reconciler gate by scope.
163
+ const modelBlocks: NamedBlock[] = [];
164
+
128
165
  const batchedAliases = new Set<string>();
129
166
  for (const model of models) {
130
167
  if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
@@ -149,38 +186,51 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
149
186
  const groupNames = hashGroups.get(hash) ?? [];
150
187
  const aliases = groupNames.filter((n) => aliasOf.has(n) && className(n) !== className(aliasOf.get(n)!));
151
188
 
189
+ const blockLines: string[] = [];
190
+ const blockNames: string[] = [];
191
+ let blockInScope = false;
152
192
  if (aliases.length >= 5) {
153
- // Batch emit all aliases for this group at once
193
+ // Mark the whole group consumed so its members aren't re-emitted as
194
+ // singles, but only EMIT aliases that are in scope or already on disk —
195
+ // dropping brand-new out-of-scope aliases (scope leak fix). On-disk
196
+ // aliases are retained so out-of-scope code that references them still
197
+ // compiles.
154
198
  for (const aliasName of aliases) {
155
199
  batchedAliases.add(aliasName);
156
200
  }
157
- lines.push(`// The following types are structurally identical to ${canonicalStruct}.`);
158
- lines.push('type (');
159
- for (const aliasName of aliases) {
160
- lines.push(`\t${className(aliasName)} = ${canonicalStruct}`);
201
+ const emittedAliases = aliases.filter(aliasEmitted);
202
+ if (emittedAliases.length === 0) continue;
203
+ blockLines.push(`// The following types are structurally identical to ${canonicalStruct}.`);
204
+ blockLines.push('type (');
205
+ for (const aliasName of emittedAliases) {
206
+ blockLines.push(`\t${className(aliasName)} = ${canonicalStruct}`);
207
+ blockNames.push(className(aliasName));
208
+ if (isModelInScope(aliasName, ctx)) blockInScope = true;
161
209
  }
162
- lines.push(')');
163
- lines.push('');
210
+ blockLines.push(')');
164
211
  } else {
165
- lines.push(`// ${structName} is an alias for ${canonicalStruct}.`);
166
- lines.push(`type ${structName} = ${canonicalStruct}`);
167
- lines.push('');
212
+ blockLines.push(`// ${structName} is an alias for ${canonicalStruct}.`);
213
+ blockLines.push(`type ${structName} = ${canonicalStruct}`);
214
+ blockNames.push(structName);
215
+ if (isModelInScope(model.name, ctx)) blockInScope = true;
168
216
  }
217
+ modelBlocks.push({ names: blockNames, text: blockLines.join('\n'), inScope: blockInScope });
169
218
  continue;
170
219
  }
171
220
 
172
221
  // Emit struct
222
+ const blockLines: string[] = [];
173
223
  if (model.description) {
174
224
  const descLines = model.description.split('\n').filter((l) => l.trim());
175
- lines.push(`// ${structName} ${lowerFirst(descLines[0])}`);
225
+ blockLines.push(`// ${structName} ${lowerFirst(descLines[0])}`);
176
226
  for (let i = 1; i < descLines.length; i++) {
177
- lines.push(`// ${descLines[i].trim()}`);
227
+ blockLines.push(`// ${descLines[i].trim()}`);
178
228
  }
179
229
  } else {
180
230
  const humanized = humanize(model.name);
181
- lines.push(`// ${structName} represents ${articleFor(humanized)} ${humanized}.`);
231
+ blockLines.push(`// ${structName} represents ${articleFor(humanized)} ${humanized}.`);
182
232
  }
183
- lines.push(`type ${structName} struct {`);
233
+ blockLines.push(`type ${structName} struct {`);
184
234
 
185
235
  // Deduplicate fields by Go field name
186
236
  const seenFieldNames = new Set<string>();
@@ -198,20 +248,35 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
198
248
 
199
249
  if (field.description) {
200
250
  const fdLines = field.description.split('\n').filter((l) => l.trim());
201
- lines.push(`\t// ${fieldDocComment(goFieldName, fdLines[0])}`);
251
+ blockLines.push(`\t// ${fieldDocComment(goFieldName, fdLines[0])}`);
202
252
  for (let i = 1; i < fdLines.length; i++) {
203
- lines.push(`\t// ${fdLines[i].trim()}`);
253
+ blockLines.push(`\t// ${fdLines[i].trim()}`);
204
254
  }
205
255
  }
206
256
  if (field.deprecated) {
207
- if (field.description) lines.push(`\t//`);
257
+ if (field.description) blockLines.push(`\t//`);
208
258
  const deprecationReason = extractDeprecationReason(field.description);
209
- lines.push(`\t// Deprecated: ${deprecationReason}`);
259
+ blockLines.push(`\t// Deprecated: ${deprecationReason}`);
210
260
  }
211
- lines.push(`\t${goFieldName} ${goType} \`${jsonTag}\``);
261
+ blockLines.push(`\t${goFieldName} ${goType} \`${jsonTag}\``);
212
262
  }
213
263
 
214
- lines.push('}');
264
+ blockLines.push('}');
265
+ modelBlocks.push({
266
+ names: [structName],
267
+ text: blockLines.join('\n'),
268
+ inScope: isModelInScope(model.name, ctx) || forcedCanonicals.has(model.name),
269
+ });
270
+ }
271
+
272
+ // Scoped runs: drop brand-new out-of-scope blocks; carry over prior blocks the
273
+ // new spec renamed/removed (still referenced by un-regenerated resource code).
274
+ // Full runs return every block unchanged.
275
+ // `PaginationParams` is emitted separately below (not part of modelBlocks),
276
+ // so exclude it from carry-over or the prior copy would be redeclared.
277
+ const reconciled = reconcileFlatBlocks(modelBlocks, 'models.go', ctx, new Set(['PaginationParams']));
278
+ for (const text of reconciled) {
279
+ lines.push(text);
215
280
  lines.push('');
216
281
  }
217
282
 
package/src/go/tests.ts CHANGED
@@ -71,8 +71,10 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
71
71
  overwriteExisting: true,
72
72
  });
73
73
 
74
- // Generate fixture JSON files
75
- const { files: fixtures, pathRewrites: fixtureRewrites } = generateFixtures(spec);
74
+ // Generate fixture JSON files. Pass ctx so a scoped run only emits fixtures
75
+ // for in-scope models (or ones already on disk), dropping brand-new
76
+ // out-of-scope fixtures while leaving prior fixtures untouched.
77
+ const { files: fixtures, pathRewrites: fixtureRewrites } = generateFixtures(spec, ctx);
76
78
  for (const fixture of fixtures) {
77
79
  files.push({
78
80
  path: fixture.path,
@@ -8,12 +8,23 @@ import {
8
8
  collectNonPaginatedResponseModelNames,
9
9
  collectReferencedListMetadataModels,
10
10
  } from '../shared/model-utils.js';
11
- import { isModelInScope } from '../shared/resolved-ops.js';
11
+ import { isModelInScope, fileExistsAfterRun } from '../shared/resolved-ops.js';
12
12
 
13
13
  const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
14
14
  const MODELS_PACKAGE = 'com.workos.models';
15
15
  const MODELS_DIR = 'com/workos/models';
16
16
 
17
+ /**
18
+ * The relative path (target-root-relative, matching the prior manifest) of the
19
+ * per-model `.kt` FILE the emitter writes for a model. The aggregate gate
20
+ * ({@link fileExistsAfterRun}) checks this exact path against the freshly-emitted
21
+ * in-scope set and the prior manifest. Must stay in sync with the path used in
22
+ * {@link emitDataClass} / {@link emitSealedUnion} / the typealias branch.
23
+ */
24
+ function modelFilePath(modelName: string): string {
25
+ return `${KOTLIN_SRC_PREFIX}${MODELS_DIR}/${className(modelName)}.kt`;
26
+ }
27
+
17
28
  /**
18
29
  * Some specs leave string fields without `format: date-time` even though the
19
30
  * description (or the example) makes clear they carry an ISO-8601 timestamp.
@@ -133,7 +144,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
133
144
  // Parent of a discriminated union: emit a sealed class.
134
145
  if (model.fields.length === 0 && discriminatedUnions.has(typeName)) {
135
146
  if (modelInScope) {
136
- files.push(emitSealedUnion(typeName, discriminatedUnions.get(typeName)!));
147
+ files.push(emitSealedUnion(typeName, discriminatedUnions.get(typeName)!, ctx));
137
148
  }
138
149
  continue;
139
150
  }
@@ -168,11 +179,22 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
168
179
  // Generate the sealed WorkOSEvent interface. Collect all event envelope
169
180
  // models that have a literal `event` field and build the @JsonSubTypes
170
181
  // mapping so Jackson can deserialize directly to the correct concrete type.
182
+ //
183
+ // This is an AGGREGATE: it enumerates many event models by name. A scoped
184
+ // (`--services`) run emits per-model `.kt` files only for in-scope models, so
185
+ // listing a brand-new OUT-OF-SCOPE event model here would reference a
186
+ // `ModelName::class` whose file is never written → "Unresolved reference"
187
+ // (the WorkOSEvent.kt build break). Gate each entry so it appears only if its
188
+ // model file will EXIST on disk after the run = in-scope (emitted now) ∪
189
+ // already-on-disk (prior manifest; scoped runs never prune). Renamed/
190
+ // removed-but-on-disk models still present under the same name in the spec are
191
+ // retained; full runs include everything (gate is inert).
171
192
  const eventMapping: Array<{ wireValue: string; modelName: string }> = [];
172
193
  for (const model of models) {
173
194
  if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
174
195
  if (aliasOf.has(model.name)) continue;
175
196
  if (!isEventEnvelopeModel(model)) continue;
197
+ if (!fileExistsAfterRun(modelFilePath(model.name), isModelInScope(model.name, ctx), ctx)) continue;
176
198
  const eventField = model.fields.find((f) => f.name === 'event');
177
199
  if (eventField && eventField.type.kind === 'literal' && typeof eventField.type.value === 'string') {
178
200
  eventMapping.push({ wireValue: eventField.type.value, modelName: model.name });
@@ -254,6 +276,7 @@ function emitDataClass(model: Model): GeneratedFile {
254
276
  function emitSealedUnion(
255
277
  typeName: string,
256
278
  disc: { property: string; mapping: Record<string, string>; variantTypes: string[] },
279
+ ctx: EmitterContext,
257
280
  ): GeneratedFile {
258
281
  const lines: string[] = [];
259
282
  lines.push(`package ${MODELS_PACKAGE}`);
@@ -261,11 +284,19 @@ function emitSealedUnion(
261
284
  lines.push('import com.fasterxml.jackson.annotation.JsonSubTypes');
262
285
  lines.push('import com.fasterxml.jackson.annotation.JsonTypeInfo');
263
286
  lines.push('');
287
+ // AGGREGATE gate: @JsonSubTypes enumerates each variant model by name
288
+ // (`VariantClass::class`). The sealed parent itself is in scope here, but a
289
+ // scoped run may not emit a brand-new OUT-OF-SCOPE variant's `.kt` file — so
290
+ // only list variants whose model file will exist on disk after the run
291
+ // (in-scope ∪ prior manifest). A full run keeps every variant (gate is inert).
292
+ const entries = Object.entries(disc.mapping).filter(([, modelName]) =>
293
+ fileExistsAfterRun(modelFilePath(modelName), isModelInScope(modelName, ctx), ctx),
294
+ );
264
295
  // KDoc with worked Kotlin + Java consumption examples. These unions are
265
296
  // returned by Jackson; callers branch on the concrete subtype to access
266
- // variant-specific data.
267
- const exampleVariantWire = Object.keys(disc.mapping)[0];
268
- const exampleVariantType = exampleVariantWire ? className(disc.mapping[exampleVariantWire]) : null;
297
+ // variant-specific data. Use a surviving variant so the example never names a
298
+ // class whose file the scoped run skipped.
299
+ const exampleVariantType = entries.length > 0 ? className(entries[0][1]) : null;
269
300
  lines.push('/**');
270
301
  lines.push(` * Discriminated union over ${typeName} variants. Selected by \`${disc.property}\`.`);
271
302
  if (exampleVariantType) {
@@ -294,7 +325,6 @@ function emitSealedUnion(
294
325
  lines.push(' visible = true');
295
326
  lines.push(')');
296
327
  lines.push('@JsonSubTypes(');
297
- const entries = Object.entries(disc.mapping);
298
328
  for (let i = 0; i < entries.length; i++) {
299
329
  const [wireValue, modelName] = entries[i];
300
330
  const variantType = className(modelName);
@@ -27,6 +27,9 @@ import {
27
27
  buildResolvedLookup,
28
28
  buildHiddenParams,
29
29
  collectGroupedParamNames,
30
+ isModelInScope,
31
+ isEnumInScope,
32
+ fileExistsAfterRun,
30
33
  } from '../shared/resolved-ops.js';
31
34
  import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
32
35
  import { resolveWrapperParams } from '../shared/wrapper-utils.js';
@@ -34,6 +37,23 @@ import { isHandwrittenOverride } from './overrides.js';
34
37
 
35
38
  const TEST_PREFIX = 'src/test/kotlin/';
36
39
 
40
+ // Per-item FILE paths (target-root-relative, matching the prior manifest) the
41
+ // model/enum emitters write. The whole-suite test aggregates below reference
42
+ // these classes by name, so they may only list an item whose file will EXIST
43
+ // on disk after the run (in-scope ∪ prior manifest) — otherwise a scoped
44
+ // (`--services`) run references a `Class::class.java` whose `.kt` was never
45
+ // emitted. Must stay in sync with the paths in `models.ts` / `enums.ts`.
46
+ const MODELS_FILE_PREFIX = 'src/main/kotlin/com/workos/models/';
47
+ const ENUMS_FILE_PREFIX = 'src/main/kotlin/com/workos/types/';
48
+
49
+ function modelFilePath(modelName: string): string {
50
+ return `${MODELS_FILE_PREFIX}${className(modelName)}.kt`;
51
+ }
52
+
53
+ function enumFilePath(enumName: string): string {
54
+ return `${ENUMS_FILE_PREFIX}${className(enumName)}.kt`;
55
+ }
56
+
37
57
  /**
38
58
  * Mirror the ISO-8601 hint promotion the resource/model emitters use so tests
39
59
  * synthesize values whose Kotlin type matches the generated method signature.
@@ -1029,6 +1049,11 @@ function generateModelRoundTripTest(spec: ApiSpec, ctx: EmitterContext): Generat
1029
1049
  for (const m of spec.models) {
1030
1050
  if (isListWrapperModel(m) || isListMetadataModel(m)) continue;
1031
1051
  if (m.fields.length === 0) continue;
1052
+ // AGGREGATE gate: this whole-suite test references `${cls}::class.java`. In a
1053
+ // scoped run only in-scope model files are emitted, so skip a brand-new
1054
+ // OUT-OF-SCOPE model whose `.kt` won't exist on disk. In-scope ∪ prior
1055
+ // manifest is retained; a full run keeps everything (gate is inert).
1056
+ if (!fileExistsAfterRun(modelFilePath(m.name), isModelInScope(m.name, ctx), ctx)) continue;
1032
1057
  const cls = className(m.name);
1033
1058
  if (seenModelClassNames.has(cls)) continue;
1034
1059
  seenModelClassNames.add(cls);
@@ -1092,10 +1117,20 @@ const MAX_ENUM_FORWARD_COMPAT = 15;
1092
1117
 
1093
1118
  function generateForwardCompatTest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile | null {
1094
1119
  // Select multiple enums for forward-compat testing, not just the first.
1095
- const enumTargets = spec.enums.filter((e) => e.values.length > 0).slice(0, MAX_ENUM_FORWARD_COMPAT);
1120
+ // AGGREGATE gate: each selected enum is imported as `com.workos.types.X`. In a
1121
+ // scoped run only in-scope enum files are emitted, so skip a brand-new
1122
+ // OUT-OF-SCOPE enum whose `.kt` won't exist on disk (in-scope ∪ prior manifest
1123
+ // retained; full run keeps everything).
1124
+ const enumTargets = spec.enums
1125
+ .filter((e) => e.values.length > 0)
1126
+ .filter((e) => fileExistsAfterRun(enumFilePath(e.name), isEnumInScope(e.name, ctx), ctx))
1127
+ .slice(0, MAX_ENUM_FORWARD_COMPAT);
1096
1128
  const modelTarget = spec.models.find((m) => {
1097
1129
  if (isListWrapperModel(m) || isListMetadataModel(m)) return false;
1098
1130
  if (m.fields.length === 0) return false;
1131
+ // Same aggregate gate as the round-trip test: the model is referenced as
1132
+ // `${cls}::class.java`, so it must exist on disk after the run.
1133
+ if (!fileExistsAfterRun(modelFilePath(m.name), isModelInScope(m.name, ctx), ctx)) return false;
1099
1134
  return synthJsonForModelName(m.name, ctx, new Set()) !== null;
1100
1135
  });
1101
1136
  if (enumTargets.length === 0 && !modelTarget) return null;
@@ -1,8 +1,9 @@
1
- import type { Model, TypeRef, Enum } from '@workos/oagen';
1
+ import type { Model, TypeRef, Enum, EmitterContext } from '@workos/oagen';
2
2
 
3
3
  import { fileName, domainFieldName } from './naming.js';
4
4
  import { isListMetadataModel, isListWrapperModel } from './models.js';
5
5
  import { collectNonPaginatedResponseModelNames, collectReferencedListMetadataModels } from '../shared/model-utils.js';
6
+ import { isModelInScope, fileExistsAfterRun } from '../shared/resolved-ops.js';
6
7
 
7
8
  /**
8
9
  * Prefix mapping for generating realistic ID fixture values.
@@ -25,18 +26,39 @@ export const ID_PREFIXES: Record<string, string> = {
25
26
 
26
27
  /**
27
28
  * Generate JSON fixture files for test data.
29
+ *
30
+ * `ctx` is optional so unit tests can call with a bare spec; when supplied, a
31
+ * scoped (`--services`) run only emits a fixture for a model whose per-model
32
+ * file will exist on disk after the run (in-scope, or already present from a
33
+ * prior manifest). Brand-new out-of-scope models are skipped: the round-trip
34
+ * test that consumes these fixtures is gated the same way, and the per-service
35
+ * tests only reference fixtures for their (in-scope) models.
28
36
  */
29
- export function generateFixtures(spec: {
30
- models: Model[];
31
- enums: Enum[];
32
- services: any[];
33
- }): { path: string; content: string }[] {
37
+ export function generateFixtures(
38
+ spec: {
39
+ models: Model[];
40
+ enums: Enum[];
41
+ services: any[];
42
+ },
43
+ ctx?: EmitterContext,
44
+ ): { path: string; content: string }[] {
34
45
  if (spec.models.length === 0) return [];
35
46
 
36
47
  const modelMap = new Map(spec.models.map((m) => [m.name, m]));
37
48
  const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
38
49
  const files: { path: string; content: string }[] = [];
39
50
 
51
+ // Gate a fixture by ITS OWN path (recorded verbatim in the prior manifest),
52
+ // scoped on the owning model. A base-model fixture and its `list_*` fixture
53
+ // are distinct files, so each must be checked against its own path — keying a
54
+ // list fixture off the base path would leak a brand-new `list_*` fixture for
55
+ // an out-of-scope model that merely already had its base fixture on disk.
56
+ // When ctx is absent (unit tests) every fixture is in scope.
57
+ const fixtureEmitted = (path: string, modelName: string): boolean => {
58
+ if (!ctx) return true;
59
+ return fileExistsAfterRun(path, isModelInScope(modelName, ctx), ctx);
60
+ };
61
+
40
62
  const nonPaginatedRefs = collectNonPaginatedResponseModelNames(spec.services);
41
63
  const listMetadataNeeded = collectReferencedListMetadataModels(spec.models, nonPaginatedRefs);
42
64
 
@@ -47,6 +69,8 @@ export function generateFixtures(spec: {
47
69
  // with hand-maintained @oagen-ignore overrides; generated empty fixtures
48
70
  // would not match the override's required fields.
49
71
  if (model.fields.length === 0) continue;
72
+ // Scoped run: skip a fixture for a brand-new out-of-scope model.
73
+ if (!fixtureEmitted(`tests/fixtures/${fileName(model.name)}.json`, model.name)) continue;
50
74
 
51
75
  const fixture = generateModelFixture(model, modelMap, enumMap);
52
76
 
@@ -65,6 +89,10 @@ export function generateFixtures(spec: {
65
89
  const unwrapped = unwrapListModel(itemModel, modelMap);
66
90
  if (unwrapped) itemModel = unwrapped;
67
91
  if (itemModel.fields.length === 0) continue;
92
+ // Scoped run: a list fixture for a brand-new out-of-scope item model
93
+ // would be referenced by no emitted test; gate it on the LIST
94
+ // fixture's own path (not the base model fixture's).
95
+ if (!fixtureEmitted(`tests/fixtures/list_${fileName(itemModel.name)}.json`, itemModel.name)) continue;
68
96
  const fixture = generateModelFixture(itemModel, modelMap, enumMap);
69
97
  const listFixture = {
70
98
  data: [fixture],
@@ -2,9 +2,9 @@ import type { Model, EmitterContext, GeneratedFile } from '@workos/oagen';
2
2
  import { collectFieldDependencies, walkTypeRef } from '@workos/oagen';
3
3
  import { mapTypeRef } from './type-map.js';
4
4
  import { className, domainFieldName, fileName, buildMountDirMap, dirToModule } from './naming.js';
5
- import { collectGeneratedEnumSymbolsByDir } from './enums.js';
5
+ import { collectGeneratedEnumSymbolsByDir, collectCompatEnumAliases } from './enums.js';
6
6
  import { computeSchemaPlacement } from './shared-schemas.js';
7
- import { isModelInScope } from '../shared/resolved-ops.js';
7
+ import { isModelInScope, isEnumInScope, fileExistsAfterRun, priorManifestBasenames } from '../shared/resolved-ops.js';
8
8
 
9
9
  /**
10
10
  * Generate Python dataclass model files from IR Model definitions.
@@ -171,8 +171,11 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
171
171
  dispLines.push(` return ${unknownClassName}.from_dict(data)`);
172
172
 
173
173
  // FR-1.4: write the file only when in scope; the barrel tracking below is
174
- // unconditional so out-of-scope models (left on disk) stay exported.
175
- if (isModelInScope(model.name, ctx)) {
174
+ // gated on the file existing after the run so out-of-scope models that
175
+ // are already on disk stay exported, but brand-new out-of-scope models
176
+ // (whose file is never emitted) are NOT referenced by the barrel.
177
+ const dispInScope = isModelInScope(model.name, ctx);
178
+ if (dispInScope) {
176
179
  files.push({
177
180
  path: `src/${ctx.namespace}/${dirName}/models/${fileName(model.name)}.py`,
178
181
  content: dispLines.join('\n'),
@@ -181,19 +184,24 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
181
184
  });
182
185
  }
183
186
 
184
- if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
185
- emittedModelSymbolsByDir.get(dirName)!.push(model.name);
186
- // Also register the variant type alias and unknown variant in the barrel,
187
- // pointing to the same file as the dispatcher.
188
- emittedModelSymbolsByDir.get(dirName)!.push(variantTypeName);
189
- symbolToFile.set(variantTypeName, fileName(model.name));
190
- emittedModelSymbolsByDir.get(dirName)!.push(unknownClassName);
191
- symbolToFile.set(unknownClassName, fileName(model.name));
192
- const dispatcherNatural = originalModelToService.get(model.name);
193
- if (dispatcherNatural) {
194
- symbolToOriginalService.set(model.name, dispatcherNatural);
195
- symbolToOriginalService.set(variantTypeName, dispatcherNatural);
196
- symbolToOriginalService.set(unknownClassName, dispatcherNatural);
187
+ // Only reference this dispatcher (and its variant/unknown symbols, which
188
+ // live in the same file) from the barrel when that file exists on disk
189
+ // after the run.
190
+ if (fileExistsAfterRun(`src/${ctx.namespace}/${dirName}/models/${fileName(model.name)}.py`, dispInScope, ctx)) {
191
+ if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
192
+ emittedModelSymbolsByDir.get(dirName)!.push(model.name);
193
+ // Also register the variant type alias and unknown variant in the barrel,
194
+ // pointing to the same file as the dispatcher.
195
+ emittedModelSymbolsByDir.get(dirName)!.push(variantTypeName);
196
+ symbolToFile.set(variantTypeName, fileName(model.name));
197
+ emittedModelSymbolsByDir.get(dirName)!.push(unknownClassName);
198
+ symbolToFile.set(unknownClassName, fileName(model.name));
199
+ const dispatcherNatural = originalModelToService.get(model.name);
200
+ if (dispatcherNatural) {
201
+ symbolToOriginalService.set(model.name, dispatcherNatural);
202
+ symbolToOriginalService.set(variantTypeName, dispatcherNatural);
203
+ symbolToOriginalService.set(unknownClassName, dispatcherNatural);
204
+ }
197
205
  }
198
206
  continue;
199
207
  }
@@ -221,18 +229,24 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
221
229
  }
222
230
  lines.push('');
223
231
  lines.push(`${modelClassName}: TypeAlias = ${canonicalClassName}`);
224
- if (isModelInScope(model.name, ctx)) {
232
+ const aliasInScope = isModelInScope(model.name, ctx);
233
+ const aliasPath = `src/${ctx.namespace}/${dirName}/models/${fileName(model.name)}.py`;
234
+ if (aliasInScope) {
225
235
  files.push({
226
- path: `src/${ctx.namespace}/${dirName}/models/${fileName(model.name)}.py`,
236
+ path: aliasPath,
227
237
  content: lines.join('\n'),
228
238
  integrateTarget: true,
229
239
  overwriteExisting: true,
230
240
  });
231
241
  }
232
- if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
233
- emittedModelSymbolsByDir.get(dirName)!.push(model.name);
234
- const aliasNatural = originalModelToService.get(model.name);
235
- if (aliasNatural) symbolToOriginalService.set(model.name, aliasNatural);
242
+ // Reference the alias from the barrel only when its file exists on disk
243
+ // after the run (in-scope, or already present from a prior run).
244
+ if (fileExistsAfterRun(aliasPath, aliasInScope, ctx)) {
245
+ if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
246
+ emittedModelSymbolsByDir.get(dirName)!.push(model.name);
247
+ const aliasNatural = originalModelToService.get(model.name);
248
+ if (aliasNatural) symbolToOriginalService.set(model.name, aliasNatural);
249
+ }
236
250
  continue;
237
251
  }
238
252
 
@@ -464,18 +478,25 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
464
478
 
465
479
  lines.push(' return result');
466
480
 
467
- if (isModelInScope(model.name, ctx)) {
481
+ const regularInScope = isModelInScope(model.name, ctx);
482
+ if (regularInScope) {
468
483
  files.push({
469
- path: `src/${ctx.namespace}/${dirName}/models/${fileName(model.name)}.py`,
484
+ path: modelFilePath,
470
485
  content: lines.join('\n'),
471
486
  integrateTarget: true,
472
487
  overwriteExisting: true,
473
488
  });
474
489
  }
475
- if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
476
- emittedModelSymbolsByDir.get(dirName)!.push(model.name);
477
- const regularNatural = originalModelToService.get(model.name);
478
- if (regularNatural) symbolToOriginalService.set(model.name, regularNatural);
490
+ // Reference the model from the barrel only when its file exists on disk
491
+ // after the run (in-scope, or already present from a prior run). A
492
+ // brand-new out-of-scope model whose file is never emitted must NOT be
493
+ // referenced, or the `from .x import X` line dangles and the import fails.
494
+ if (fileExistsAfterRun(modelFilePath, regularInScope, ctx)) {
495
+ if (!emittedModelSymbolsByDir.has(dirName)) emittedModelSymbolsByDir.set(dirName, []);
496
+ emittedModelSymbolsByDir.get(dirName)!.push(model.name);
497
+ const regularNatural = originalModelToService.get(model.name);
498
+ if (regularNatural) symbolToOriginalService.set(model.name, regularNatural);
499
+ }
479
500
  }
480
501
 
481
502
  // Generate __init__.py barrel files for each models/ directory
@@ -483,7 +504,11 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
483
504
  // A direct symbol lives in the file at `dirPath/<file>.py`. A re-exported
484
505
  // symbol was relocated to common/ but is being mirrored from its natural
485
506
  // service barrel for backwards compatibility.
486
- type BarrelSymbol = { name: string; reExport?: { fromDir: string; file: string } };
507
+ // A `retainBasename` symbol has no known IR name it is a per-item file
508
+ // recorded in the PRIOR manifest (renamed/removed from the current spec but
509
+ // still on disk) that we re-export wholesale via `from .<base> import *` so
510
+ // out-of-scope code the scoped run did not regenerate keeps resolving.
511
+ type BarrelSymbol = { name: string; reExport?: { fromDir: string; file: string }; retainBasename?: string };
487
512
  const symbolsByDir = new Map<string, BarrelSymbol[]>();
488
513
  for (const [dirName, names] of emittedModelSymbolsByDir) {
489
514
  const key = `src/${ctx.namespace}/${dirName}/models`;
@@ -495,10 +520,57 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
495
520
  const reachableEnumNames = collectReachableEnumNames(ctx);
496
521
  const emittedEnums = ctx.spec.enums.filter((enumDef) => reachableEnumNames.has(enumDef.name));
497
522
  const enumSymbolsByDir = collectGeneratedEnumSymbolsByDir(emittedEnums, ctx);
523
+ // Map each compat-alias symbol back to its canonical enum so we can gate it by
524
+ // the canonical enum's scope (the alias file is only written when the
525
+ // canonical enum is in scope; see enums.ts).
526
+ const aliasToCanonicalEnum = new Map<string, string>();
527
+ for (const [canonical, aliasNames] of collectCompatEnumAliases(emittedEnums, ctx)) {
528
+ for (const aliasName of aliasNames) aliasToCanonicalEnum.set(aliasName, canonical);
529
+ }
498
530
  for (const [dirName, names] of enumSymbolsByDir) {
499
531
  const key = `src/${ctx.namespace}/${dirName}/models`;
500
532
  if (!symbolsByDir.has(key)) symbolsByDir.set(key, []);
501
- for (const name of names) symbolsByDir.get(key)!.push({ name });
533
+ for (const name of names) {
534
+ // Reference an enum (or its compat alias) from the barrel only when its
535
+ // per-enum file exists on disk after the run. A brand-new out-of-scope
536
+ // enum whose file is never emitted must NOT be referenced.
537
+ const enumFilePath = `${key}/${fileName(name)}.py`;
538
+ const scopeName = aliasToCanonicalEnum.get(name) ?? name;
539
+ if (fileExistsAfterRun(enumFilePath, isEnumInScope(scopeName, ctx), ctx)) {
540
+ symbolsByDir.get(key)!.push({ name });
541
+ }
542
+ }
543
+ }
544
+
545
+ // Scoped runs: retain barrel entries for per-item files still on disk (prior
546
+ // manifest) that the current spec no longer produces — e.g. a model/enum
547
+ // renamed or removed for an out-of-scope service. Out-of-scope code we did
548
+ // not regenerate may still `from .<base> import X`, so dropping it would
549
+ // break the import. We can't recover the original class name from the
550
+ // basename, so re-export the module wholesale with `import *`. A full run
551
+ // (priorManifestBasenames returns []) yields nothing here. De-duped against
552
+ // files already referenced by an emitted/enum symbol in the same dir.
553
+ //
554
+ // Candidate dirs are every `src/<ns>/<dir>/models` that appears in the prior
555
+ // manifest (a dir may have no in-scope items yet still hold on-disk files
556
+ // referenced by stale out-of-scope code).
557
+ const manifestModelDirs = new Set<string>();
558
+ for (const p of ctx.priorTargetManifestPaths ?? []) {
559
+ const m = p.match(new RegExp(`^(src/${ctx.namespace}/[^/]+/models)/[^/]+\\.py$`));
560
+ if (m) manifestModelDirs.add(m[1]);
561
+ }
562
+ for (const dirPath of manifestModelDirs) {
563
+ if (!symbolsByDir.has(dirPath)) symbolsByDir.set(dirPath, []);
564
+ const referencedBasenames = new Set<string>();
565
+ for (const sym of symbolsByDir.get(dirPath)!) {
566
+ if (sym.reExport || sym.retainBasename) continue;
567
+ referencedBasenames.add(symbolToFile.get(sym.name) ?? fileName(sym.name));
568
+ }
569
+ for (const base of priorManifestBasenames(ctx, dirPath, '.py', new Set(['__init__']))) {
570
+ if (referencedBasenames.has(base)) continue;
571
+ referencedBasenames.add(base);
572
+ symbolsByDir.get(dirPath)!.push({ name: base, retainBasename: base });
573
+ }
502
574
  }
503
575
 
504
576
  // Backwards-compat re-exports: every relocated model is also re-exported
@@ -570,6 +642,12 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
570
642
  // Use `import X as X` syntax for explicit re-exports (required by pyright strict)
571
643
  const importLines: string[] = [];
572
644
  for (const sym of uniqueSymbols) {
645
+ if (sym.retainBasename) {
646
+ // On-disk module renamed/removed from the spec: re-export it wholesale
647
+ // since its concrete symbol names are unknown from the manifest alone.
648
+ importLines.push(`from .${sym.retainBasename} import * # noqa: F401,F403`);
649
+ continue;
650
+ }
573
651
  const cls = className(sym.name);
574
652
  if (sym.reExport) {
575
653
  importLines.push(
@@ -593,9 +671,15 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
593
671
  // which includes both the resource class re-export and model star import.
594
672
  if (!serviceDirModelPaths.has(dirPath)) {
595
673
  const parentDir = dirPath.replace(/\/models$/, '');
596
- const reExports = uniqueSymbols
597
- .map((sym) => `from .models import ${className(sym.name)} as ${className(sym.name)}`)
598
- .join('\n');
674
+ const reExports = [
675
+ ...new Set(
676
+ uniqueSymbols.map((sym) =>
677
+ sym.retainBasename
678
+ ? 'from .models import * # noqa: F401,F403'
679
+ : `from .models import ${className(sym.name)} as ${className(sym.name)}`,
680
+ ),
681
+ ),
682
+ ].join('\n');
599
683
  files.push({
600
684
  path: `${parentDir}/__init__.py`,
601
685
  content: reExports,