@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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-BXDPA9pJ.mjs → plugin-DXIciTnN.mjs} +535 -96
- package/dist/plugin-DXIciTnN.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +1 -1
- package/src/dotnet/fixtures.ts +28 -7
- package/src/dotnet/index.ts +42 -1
- package/src/dotnet/tests.ts +1 -1
- package/src/go/enums.ts +91 -18
- package/src/go/fixtures.ts +25 -3
- package/src/go/flat-merge.ts +253 -0
- package/src/go/models.ts +85 -20
- package/src/go/tests.ts +4 -2
- package/src/kotlin/models.ts +36 -6
- package/src/kotlin/tests.ts +36 -1
- package/src/python/fixtures.ts +34 -6
- package/src/python/models.ts +118 -34
- package/src/python/tests.ts +28 -9
- package/src/ruby/tests.ts +35 -2
- package/src/rust/enums.ts +29 -15
- package/src/rust/fixtures.ts +12 -3
- package/src/rust/models.ts +26 -8
- package/src/rust/tests.ts +1 -1
- package/src/shared/resolved-ops.ts +57 -0
- package/test/dotnet/scoped-aggregates.test.ts +247 -0
- package/test/go/scoping.test.ts +324 -0
- package/test/kotlin/models.test.ts +74 -0
- package/test/kotlin/tests.test.ts +33 -0
- package/test/python/scoped-aggregates.test.ts +205 -0
- package/test/ruby/tests.test.ts +130 -0
- package/test/rust/fixtures.test.ts +13 -7
- package/dist/plugin-BXDPA9pJ.mjs.map +0 -1
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
|
-
//
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
163
|
-
lines.push('');
|
|
210
|
+
blockLines.push(')');
|
|
164
211
|
} else {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
225
|
+
blockLines.push(`// ${structName} ${lowerFirst(descLines[0])}`);
|
|
176
226
|
for (let i = 1; i < descLines.length; i++) {
|
|
177
|
-
|
|
227
|
+
blockLines.push(`// ${descLines[i].trim()}`);
|
|
178
228
|
}
|
|
179
229
|
} else {
|
|
180
230
|
const humanized = humanize(model.name);
|
|
181
|
-
|
|
231
|
+
blockLines.push(`// ${structName} represents ${articleFor(humanized)} ${humanized}.`);
|
|
182
232
|
}
|
|
183
|
-
|
|
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
|
-
|
|
251
|
+
blockLines.push(`\t// ${fieldDocComment(goFieldName, fdLines[0])}`);
|
|
202
252
|
for (let i = 1; i < fdLines.length; i++) {
|
|
203
|
-
|
|
253
|
+
blockLines.push(`\t// ${fdLines[i].trim()}`);
|
|
204
254
|
}
|
|
205
255
|
}
|
|
206
256
|
if (field.deprecated) {
|
|
207
|
-
if (field.description)
|
|
257
|
+
if (field.description) blockLines.push(`\t//`);
|
|
208
258
|
const deprecationReason = extractDeprecationReason(field.description);
|
|
209
|
-
|
|
259
|
+
blockLines.push(`\t// Deprecated: ${deprecationReason}`);
|
|
210
260
|
}
|
|
211
|
-
|
|
261
|
+
blockLines.push(`\t${goFieldName} ${goType} \`${jsonTag}\``);
|
|
212
262
|
}
|
|
213
263
|
|
|
214
|
-
|
|
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
|
-
|
|
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,
|
package/src/kotlin/models.ts
CHANGED
|
@@ -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
|
-
|
|
268
|
-
const exampleVariantType =
|
|
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);
|
package/src/kotlin/tests.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/python/fixtures.ts
CHANGED
|
@@ -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(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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],
|
package/src/python/models.ts
CHANGED
|
@@ -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
|
-
//
|
|
175
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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:
|
|
236
|
+
path: aliasPath,
|
|
227
237
|
content: lines.join('\n'),
|
|
228
238
|
integrateTarget: true,
|
|
229
239
|
overwriteExisting: true,
|
|
230
240
|
});
|
|
231
241
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
481
|
+
const regularInScope = isModelInScope(model.name, ctx);
|
|
482
|
+
if (regularInScope) {
|
|
468
483
|
files.push({
|
|
469
|
-
path:
|
|
484
|
+
path: modelFilePath,
|
|
470
485
|
content: lines.join('\n'),
|
|
471
486
|
integrateTarget: true,
|
|
472
487
|
overwriteExisting: true,
|
|
473
488
|
});
|
|
474
489
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
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)
|
|
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 =
|
|
597
|
-
|
|
598
|
-
|
|
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,
|