@workos/oagen-emitters 0.18.4 → 0.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-Cciic50q.mjs → plugin-DXIciTnN.mjs} +668 -164
- package/dist/plugin-DXIciTnN.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +4 -4
- package/src/dotnet/enums.ts +11 -5
- package/src/dotnet/fixtures.ts +28 -7
- package/src/dotnet/index.ts +42 -1
- package/src/dotnet/models.ts +11 -5
- package/src/dotnet/resources.ts +3 -3
- package/src/dotnet/tests.ts +4 -4
- 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/resources.ts +3 -3
- package/src/go/tests.ts +7 -5
- package/src/kotlin/enums.ts +21 -11
- package/src/kotlin/models.ts +53 -11
- package/src/kotlin/resources.ts +2 -2
- package/src/kotlin/tests.ts +38 -3
- package/src/node/enums.ts +8 -5
- package/src/node/models.ts +29 -21
- package/src/node/resources.ts +12 -1
- package/src/node/tests.ts +7 -2
- package/src/php/enums.ts +18 -5
- package/src/php/index.ts +11 -3
- package/src/php/models.ts +11 -5
- package/src/php/resources.ts +6 -4
- package/src/php/tests.ts +6 -3
- package/src/python/enums.ts +39 -28
- package/src/python/fixtures.ts +34 -6
- package/src/python/models.ts +138 -45
- package/src/python/resources.ts +3 -3
- package/src/python/tests.ts +31 -12
- package/src/ruby/enums.ts +28 -19
- package/src/ruby/models.ts +23 -12
- package/src/ruby/rbi.ts +17 -6
- package/src/ruby/resources.ts +2 -2
- package/src/ruby/tests.ts +37 -4
- package/src/rust/enums.ts +29 -7
- package/src/rust/fixtures.ts +12 -3
- package/src/rust/models.ts +37 -6
- package/src/rust/resources.ts +8 -1
- package/src/rust/tests.ts +3 -3
- package/src/shared/resolved-ops.ts +104 -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/test/shared/synthetic-enum-seed.test.ts +79 -0
- package/dist/plugin-Cciic50q.mjs.map +0 -1
package/src/python/models.ts
CHANGED
|
@@ -2,8 +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, isEnumInScope, fileExistsAfterRun, priorManifestBasenames } from '../shared/resolved-ops.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Generate Python dataclass model files from IR Model definitions.
|
|
@@ -169,26 +170,38 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
169
170
|
dispLines.push(` return cast("${variantTypeName}", dispatch_cls.from_dict(data))`);
|
|
170
171
|
dispLines.push(` return ${unknownClassName}.from_dict(data)`);
|
|
171
172
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
173
|
+
// FR-1.4: write the file only when in scope; the barrel tracking below is
|
|
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) {
|
|
179
|
+
files.push({
|
|
180
|
+
path: `src/${ctx.namespace}/${dirName}/models/${fileName(model.name)}.py`,
|
|
181
|
+
content: dispLines.join('\n'),
|
|
182
|
+
integrateTarget: true,
|
|
183
|
+
overwriteExisting: true,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
178
186
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
+
}
|
|
192
205
|
}
|
|
193
206
|
continue;
|
|
194
207
|
}
|
|
@@ -216,16 +229,24 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
216
229
|
}
|
|
217
230
|
lines.push('');
|
|
218
231
|
lines.push(`${modelClassName}: TypeAlias = ${canonicalClassName}`);
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
232
|
+
const aliasInScope = isModelInScope(model.name, ctx);
|
|
233
|
+
const aliasPath = `src/${ctx.namespace}/${dirName}/models/${fileName(model.name)}.py`;
|
|
234
|
+
if (aliasInScope) {
|
|
235
|
+
files.push({
|
|
236
|
+
path: aliasPath,
|
|
237
|
+
content: lines.join('\n'),
|
|
238
|
+
integrateTarget: true,
|
|
239
|
+
overwriteExisting: true,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
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
|
+
}
|
|
229
250
|
continue;
|
|
230
251
|
}
|
|
231
252
|
|
|
@@ -457,16 +478,25 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
457
478
|
|
|
458
479
|
lines.push(' return result');
|
|
459
480
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
481
|
+
const regularInScope = isModelInScope(model.name, ctx);
|
|
482
|
+
if (regularInScope) {
|
|
483
|
+
files.push({
|
|
484
|
+
path: modelFilePath,
|
|
485
|
+
content: lines.join('\n'),
|
|
486
|
+
integrateTarget: true,
|
|
487
|
+
overwriteExisting: true,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
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
|
+
}
|
|
470
500
|
}
|
|
471
501
|
|
|
472
502
|
// Generate __init__.py barrel files for each models/ directory
|
|
@@ -474,7 +504,11 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
474
504
|
// A direct symbol lives in the file at `dirPath/<file>.py`. A re-exported
|
|
475
505
|
// symbol was relocated to common/ but is being mirrored from its natural
|
|
476
506
|
// service barrel for backwards compatibility.
|
|
477
|
-
|
|
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 };
|
|
478
512
|
const symbolsByDir = new Map<string, BarrelSymbol[]>();
|
|
479
513
|
for (const [dirName, names] of emittedModelSymbolsByDir) {
|
|
480
514
|
const key = `src/${ctx.namespace}/${dirName}/models`;
|
|
@@ -486,10 +520,57 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
486
520
|
const reachableEnumNames = collectReachableEnumNames(ctx);
|
|
487
521
|
const emittedEnums = ctx.spec.enums.filter((enumDef) => reachableEnumNames.has(enumDef.name));
|
|
488
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
|
+
}
|
|
489
530
|
for (const [dirName, names] of enumSymbolsByDir) {
|
|
490
531
|
const key = `src/${ctx.namespace}/${dirName}/models`;
|
|
491
532
|
if (!symbolsByDir.has(key)) symbolsByDir.set(key, []);
|
|
492
|
-
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
|
+
}
|
|
493
574
|
}
|
|
494
575
|
|
|
495
576
|
// Backwards-compat re-exports: every relocated model is also re-exported
|
|
@@ -561,6 +642,12 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
561
642
|
// Use `import X as X` syntax for explicit re-exports (required by pyright strict)
|
|
562
643
|
const importLines: string[] = [];
|
|
563
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
|
+
}
|
|
564
651
|
const cls = className(sym.name);
|
|
565
652
|
if (sym.reExport) {
|
|
566
653
|
importLines.push(
|
|
@@ -584,9 +671,15 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
584
671
|
// which includes both the resource class re-export and model star import.
|
|
585
672
|
if (!serviceDirModelPaths.has(dirPath)) {
|
|
586
673
|
const parentDir = dirPath.replace(/\/models$/, '');
|
|
587
|
-
const reExports =
|
|
588
|
-
|
|
589
|
-
|
|
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');
|
|
590
683
|
files.push({
|
|
591
684
|
path: `${parentDir}/__init__.py`,
|
|
592
685
|
content: reExports,
|
package/src/python/resources.ts
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
buildResolvedLookup,
|
|
31
31
|
lookupMethodName,
|
|
32
32
|
lookupResolved,
|
|
33
|
-
|
|
33
|
+
scopedMountGroups,
|
|
34
34
|
getOpDefaults,
|
|
35
35
|
getOpInferFromClient,
|
|
36
36
|
buildHiddenParams as buildHiddenParamsShared,
|
|
@@ -1056,12 +1056,12 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
1056
1056
|
const resolvedLookup = buildResolvedLookup(ctx);
|
|
1057
1057
|
const files: GeneratedFile[] = [];
|
|
1058
1058
|
const mountDirMap = buildMountDirMap(ctx);
|
|
1059
|
-
const mountGroups =
|
|
1059
|
+
const mountGroups = scopedMountGroups(ctx);
|
|
1060
1060
|
|
|
1061
1061
|
// Build mount group entries. When resolved operations are available, group by
|
|
1062
1062
|
// mount target. Otherwise fall back to one group per service (for tests).
|
|
1063
1063
|
const entries: Array<{ name: string; operations: Operation[] }> =
|
|
1064
|
-
mountGroups.size > 0
|
|
1064
|
+
mountGroups.size > 0 || ctx.scopedServices?.size
|
|
1065
1065
|
? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
|
|
1066
1066
|
: services.map((s) => ({ name: resolveClassName(s, ctx), operations: s.operations }));
|
|
1067
1067
|
|
package/src/python/tests.ts
CHANGED
|
@@ -25,11 +25,13 @@ import { generateFixtures, generateModelFixture } from './fixtures.js';
|
|
|
25
25
|
import { isListWrapperModel, isListMetadataModel } from './models.js';
|
|
26
26
|
import { collectNonPaginatedResponseModelNames, collectReferencedListMetadataModels } from '../shared/model-utils.js';
|
|
27
27
|
import {
|
|
28
|
-
|
|
28
|
+
scopedMountGroups,
|
|
29
29
|
buildResolvedLookup,
|
|
30
30
|
lookupResolved,
|
|
31
31
|
buildHiddenParams,
|
|
32
32
|
collectGroupedParamNames,
|
|
33
|
+
isModelInScope,
|
|
34
|
+
fileExistsAfterRun,
|
|
33
35
|
} from '../shared/resolved-ops.js';
|
|
34
36
|
import { resolveWrapperParams } from '../shared/wrapper-utils.js';
|
|
35
37
|
import { pythonLiteral } from './wrappers.js';
|
|
@@ -100,7 +102,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
100
102
|
const files: GeneratedFile[] = [];
|
|
101
103
|
|
|
102
104
|
// Generate fixture JSON files
|
|
103
|
-
const fixtures = generateFixtures(spec);
|
|
105
|
+
const fixtures = generateFixtures(spec, ctx);
|
|
104
106
|
for (const fixture of fixtures) {
|
|
105
107
|
files.push({
|
|
106
108
|
path: fixture.path,
|
|
@@ -118,9 +120,9 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
118
120
|
const accessPaths = buildServiceAccessPaths(spec.services, ctx);
|
|
119
121
|
|
|
120
122
|
// Generate per-mount-target test files (merges all sub-services into one file)
|
|
121
|
-
const mountGroups =
|
|
123
|
+
const mountGroups = scopedMountGroups(ctx);
|
|
122
124
|
const testEntries: Array<{ name: string; operations: Operation[]; resolvedOps?: ResolvedOperation[] }> =
|
|
123
|
-
mountGroups.size > 0
|
|
125
|
+
mountGroups.size > 0 || ctx.scopedServices?.size
|
|
124
126
|
? [...mountGroups].map(([name, group]) => ({
|
|
125
127
|
name,
|
|
126
128
|
operations: group.operations,
|
|
@@ -1461,22 +1463,39 @@ function generateModelRoundTripTests(spec: ApiSpec, ctx: EmitterContext): Genera
|
|
|
1461
1463
|
|
|
1462
1464
|
const nonPaginatedRefs = collectNonPaginatedResponseModelNames(spec.services);
|
|
1463
1465
|
const listMetadataNeeded = collectReferencedListMetadataModels(spec.models, nonPaginatedRefs);
|
|
1464
|
-
const models = spec.models.filter(
|
|
1465
|
-
(m) =>
|
|
1466
|
-
!(isListWrapperModel(m) && !nonPaginatedRefs.has(m.name)) &&
|
|
1467
|
-
!(isListMetadataModel(m) && !listMetadataNeeded.has(m.name)) &&
|
|
1468
|
-
!requestOnlyModelNames.has(m.name),
|
|
1469
|
-
);
|
|
1470
|
-
if (models.length === 0) return null;
|
|
1471
1466
|
|
|
1472
1467
|
// The round-trip test imports models from their *natural* (pre-relocation)
|
|
1473
1468
|
// service so existing callers keep working — those imports resolve via the
|
|
1474
1469
|
// BC re-exports that the model emitter writes into each service barrel.
|
|
1475
|
-
const
|
|
1470
|
+
const placement = computeSchemaPlacement(spec, ctx);
|
|
1471
|
+
const modelToService = placement.originalModelToService;
|
|
1472
|
+
// The per-model FILE is written to the RELOCATED dir; use it to compute the
|
|
1473
|
+
// gate's relPath so it lines up with the prior manifest.
|
|
1474
|
+
const relocatedModelToService = placement.modelToService;
|
|
1476
1475
|
const roundTripDirMap = buildMountDirMap(ctx);
|
|
1477
1476
|
const resolveDir = (irService: string | undefined) =>
|
|
1478
1477
|
irService ? (roundTripDirMap.get(irService) ?? 'common') : 'common';
|
|
1479
1478
|
|
|
1479
|
+
// Scoped runs: the round-trip test must only reference a model whose per-model
|
|
1480
|
+
// file exists on disk after the run (in-scope, or already present from the
|
|
1481
|
+
// prior manifest). A brand-new out-of-scope model is excluded — its file is
|
|
1482
|
+
// never emitted, so importing it would raise ModuleNotFoundError. A full run
|
|
1483
|
+
// keeps every model (fileExistsAfterRun ⇒ true).
|
|
1484
|
+
const modelFileExists = (name: string): boolean => {
|
|
1485
|
+
const dir = resolveDir(relocatedModelToService.get(name));
|
|
1486
|
+
const path = `src/${ctx.namespace}/${dir}/models/${fileName(name)}.py`;
|
|
1487
|
+
return fileExistsAfterRun(path, isModelInScope(name, ctx), ctx);
|
|
1488
|
+
};
|
|
1489
|
+
|
|
1490
|
+
const models = spec.models.filter(
|
|
1491
|
+
(m) =>
|
|
1492
|
+
!(isListWrapperModel(m) && !nonPaginatedRefs.has(m.name)) &&
|
|
1493
|
+
!(isListMetadataModel(m) && !listMetadataNeeded.has(m.name)) &&
|
|
1494
|
+
!requestOnlyModelNames.has(m.name) &&
|
|
1495
|
+
modelFileExists(m.name),
|
|
1496
|
+
);
|
|
1497
|
+
if (models.length === 0) return null;
|
|
1498
|
+
|
|
1480
1499
|
const lines: string[] = [];
|
|
1481
1500
|
lines.push('"""Model round-trip tests: from_dict(to_dict()) preserves data."""');
|
|
1482
1501
|
lines.push('');
|
package/src/ruby/enums.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
2
|
import { toUpperSnakeCase } from '@workos/oagen';
|
|
3
3
|
import { className, fileName } from './naming.js';
|
|
4
|
+
import { isEnumInScope } from '../shared/resolved-ops.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Generate Ruby enum class files.
|
|
@@ -9,7 +10,6 @@ import { className, fileName } from './naming.js';
|
|
|
9
10
|
* and a frozen `ALL` array of all values.
|
|
10
11
|
*/
|
|
11
12
|
export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
12
|
-
void ctx;
|
|
13
13
|
if (enums.length === 0) return [];
|
|
14
14
|
|
|
15
15
|
const files: GeneratedFile[] = [];
|
|
@@ -17,6 +17,9 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
17
17
|
|
|
18
18
|
for (const enumDef of enums) {
|
|
19
19
|
const cls = className(enumDef.name);
|
|
20
|
+
// FR-1.4: write the per-enum file only when in scope. Out-of-scope enum
|
|
21
|
+
// files are left untouched on disk; Zeitwerk autoloads them by path.
|
|
22
|
+
const enumInScope = isEnumInScope(enumDef.name, ctx);
|
|
20
23
|
|
|
21
24
|
// If this enum duplicates another (by value set), emit a Ruby constant
|
|
22
25
|
// alias. Zeitwerk autoloads the canonical when the alias is first
|
|
@@ -30,12 +33,14 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
30
33
|
lines.push(` ${cls} = ${canonicalCls}`);
|
|
31
34
|
lines.push(' end');
|
|
32
35
|
lines.push('end');
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
if (enumInScope) {
|
|
37
|
+
files.push({
|
|
38
|
+
path: `lib/workos/types/${fileName(enumDef.name)}.rb`,
|
|
39
|
+
content: lines.join('\n'),
|
|
40
|
+
integrateTarget: true,
|
|
41
|
+
overwriteExisting: true,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
39
44
|
continue;
|
|
40
45
|
}
|
|
41
46
|
|
|
@@ -60,12 +65,14 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
60
65
|
lines.push(' end');
|
|
61
66
|
lines.push(' end');
|
|
62
67
|
lines.push('end');
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
if (enumInScope) {
|
|
69
|
+
files.push({
|
|
70
|
+
path: `lib/workos/types/${fileName(enumDef.name)}.rb`,
|
|
71
|
+
content: lines.join('\n'),
|
|
72
|
+
integrateTarget: true,
|
|
73
|
+
overwriteExisting: true,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
69
76
|
continue;
|
|
70
77
|
}
|
|
71
78
|
|
|
@@ -108,12 +115,14 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
108
115
|
lines.push(' end');
|
|
109
116
|
lines.push('end');
|
|
110
117
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
118
|
+
if (enumInScope) {
|
|
119
|
+
files.push({
|
|
120
|
+
path: `lib/workos/types/${fileName(enumDef.name)}.rb`,
|
|
121
|
+
content: lines.join('\n'),
|
|
122
|
+
integrateTarget: true,
|
|
123
|
+
overwriteExisting: true,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
117
126
|
}
|
|
118
127
|
|
|
119
128
|
return files;
|
package/src/ruby/models.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
isListMetadataModel,
|
|
7
7
|
collectNonPaginatedResponseModelNames,
|
|
8
8
|
} from '../shared/model-utils.js';
|
|
9
|
+
import { isModelInScope } from '../shared/resolved-ops.js';
|
|
9
10
|
|
|
10
11
|
/** Folder under lib/workos/ for models not owned by any service. */
|
|
11
12
|
export const SHARED_MODEL_DIR = 'shared';
|
|
@@ -119,12 +120,17 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
119
120
|
lines.push('module WorkOS');
|
|
120
121
|
lines.push(` ${cls} = ${canonCls}`);
|
|
121
122
|
lines.push('end');
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
123
|
+
// FR-1.4: write the per-model file only when in scope. Zeitwerk autoloads
|
|
124
|
+
// by path, so there is no barrel to keep in sync; out-of-scope alias files
|
|
125
|
+
// are left untouched on disk.
|
|
126
|
+
if (isModelInScope(model.name, ctx)) {
|
|
127
|
+
files.push({
|
|
128
|
+
path: `lib/workos/${dirFor(model.name)}/${file}.rb`,
|
|
129
|
+
content: lines.join('\n'),
|
|
130
|
+
integrateTarget: true,
|
|
131
|
+
overwriteExisting: true,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
128
134
|
continue;
|
|
129
135
|
}
|
|
130
136
|
|
|
@@ -214,12 +220,17 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
214
220
|
lines.push(' end');
|
|
215
221
|
lines.push('end');
|
|
216
222
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
+
// FR-1.4: write the per-model file only when in scope. Zeitwerk autoloads by
|
|
224
|
+
// path, so there is no barrel to keep in sync; out-of-scope model files are
|
|
225
|
+
// left untouched on disk.
|
|
226
|
+
if (isModelInScope(model.name, ctx)) {
|
|
227
|
+
files.push({
|
|
228
|
+
path: `lib/workos/${dirFor(model.name)}/${file}.rb`,
|
|
229
|
+
content: lines.join('\n'),
|
|
230
|
+
integrateTarget: true,
|
|
231
|
+
overwriteExisting: true,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
223
234
|
}
|
|
224
235
|
|
|
225
236
|
return files;
|
package/src/ruby/rbi.ts
CHANGED
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
import {
|
|
16
16
|
buildResolvedLookup,
|
|
17
17
|
groupByMount,
|
|
18
|
+
isMountInScope,
|
|
19
|
+
isModelInScope,
|
|
18
20
|
lookupResolved,
|
|
19
21
|
buildHiddenParams,
|
|
20
22
|
collectGroupedParamNames,
|
|
@@ -116,12 +118,17 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
116
118
|
lines.push(' end');
|
|
117
119
|
lines.push('end');
|
|
118
120
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
121
|
+
// FR-1.4: write the per-model .rbi only when in scope. The client.rbi
|
|
122
|
+
// aggregate (section 3) stays on the full set so sigs for out-of-scope
|
|
123
|
+
// services whose .rb/.rbi still exist keep resolving.
|
|
124
|
+
if (isModelInScope(model.name, ctx)) {
|
|
125
|
+
files.push({
|
|
126
|
+
path: `rbi/workos/${fileName(model.name)}.rbi`,
|
|
127
|
+
content: lines.join('\n'),
|
|
128
|
+
integrateTarget: true,
|
|
129
|
+
overwriteExisting: true,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
125
132
|
}
|
|
126
133
|
|
|
127
134
|
// 2. Generate service RBI files
|
|
@@ -137,6 +144,10 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
137
144
|
const exportedClasses = buildExportedClassNameSet(ctx);
|
|
138
145
|
|
|
139
146
|
for (const [mountTarget, group] of groups) {
|
|
147
|
+
// Scoped run: emit per-service .rbi only for selected mount targets. The
|
|
148
|
+
// client.rbi aggregate loop below intentionally stays on the FULL `groups`
|
|
149
|
+
// set so it keeps emitting sigs for every service whose .rb still exists.
|
|
150
|
+
if (!isMountInScope(mountTarget, ctx)) continue;
|
|
140
151
|
const resolvedTarget = resolveServiceTarget(mountTarget, exportedClasses);
|
|
141
152
|
const cls = className(resolvedTarget);
|
|
142
153
|
const lines: string[] = [];
|
package/src/ruby/resources.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { mapTypeRefForYard } from './type-map.js';
|
|
|
15
15
|
import {
|
|
16
16
|
buildResolvedLookup,
|
|
17
17
|
lookupResolved,
|
|
18
|
-
|
|
18
|
+
scopedMountGroups,
|
|
19
19
|
getOpDefaults,
|
|
20
20
|
getOpInferFromClient,
|
|
21
21
|
buildHiddenParams,
|
|
@@ -33,7 +33,7 @@ import { buildGroupOwnerMap, collectVariantsForMountTarget, emitInlineVariantCla
|
|
|
33
33
|
export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
|
|
34
34
|
const files: GeneratedFile[] = [];
|
|
35
35
|
|
|
36
|
-
const groups =
|
|
36
|
+
const groups = scopedMountGroups(ctx);
|
|
37
37
|
const lookup = buildResolvedLookup(ctx);
|
|
38
38
|
const modelNames = new Set(ctx.spec.models.map((m) => m.name));
|
|
39
39
|
const enumNames = new Set(ctx.spec.enums.map((e) => e.name));
|
package/src/ruby/tests.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ApiSpec, EmitterContext, GeneratedFile, Model, Operation, ResolvedWrapper, TypeRef } from '@workos/oagen';
|
|
2
|
+
import { assignModelsToServices } from '@workos/oagen';
|
|
2
3
|
import {
|
|
3
4
|
className,
|
|
4
5
|
fileName,
|
|
@@ -10,15 +11,19 @@ import {
|
|
|
10
11
|
resolveMethodName,
|
|
11
12
|
buildExportedClassNameSet,
|
|
12
13
|
resolveServiceTarget,
|
|
14
|
+
buildMountDirMap,
|
|
13
15
|
} from './naming.js';
|
|
14
16
|
import {
|
|
15
17
|
buildResolvedLookup,
|
|
16
|
-
|
|
18
|
+
scopedMountGroups,
|
|
17
19
|
lookupResolved,
|
|
18
20
|
buildHiddenParams,
|
|
19
21
|
collectBodyFieldTypes,
|
|
22
|
+
isModelInScope,
|
|
23
|
+
fileExistsAfterRun,
|
|
20
24
|
} from '../shared/resolved-ops.js';
|
|
21
25
|
import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
|
|
26
|
+
import { classifyUnassignedModel } from './models.js';
|
|
22
27
|
import { resolveWrapperParams } from '../shared/wrapper-utils.js';
|
|
23
28
|
import { buildGroupOwnerMap, pickVariantParamType } from './parameter-groups.js';
|
|
24
29
|
|
|
@@ -34,7 +39,7 @@ import { buildGroupOwnerMap, pickVariantParamType } from './parameter-groups.js'
|
|
|
34
39
|
export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
35
40
|
const files: GeneratedFile[] = [];
|
|
36
41
|
|
|
37
|
-
const groups =
|
|
42
|
+
const groups = scopedMountGroups(ctx);
|
|
38
43
|
const models = spec.models as Model[];
|
|
39
44
|
const modelByName = new Map<string, Model>();
|
|
40
45
|
for (const m of models) modelByName.set(m.name, m);
|
|
@@ -203,7 +208,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
203
208
|
});
|
|
204
209
|
}
|
|
205
210
|
|
|
206
|
-
files.push(generateModelRoundTripTest(spec));
|
|
211
|
+
files.push(generateModelRoundTripTest(spec, ctx));
|
|
207
212
|
|
|
208
213
|
return files;
|
|
209
214
|
}
|
|
@@ -212,8 +217,19 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
212
217
|
* Emit test/workos/model_round_trip_test.rb that round-trips every non-wrapper
|
|
213
218
|
* model through `.new(json)` and `.to_json`, asserting the result is a Hash and
|
|
214
219
|
* that required fields appear with the seeded values.
|
|
220
|
+
*
|
|
221
|
+
* This is a whole-suite AGGREGATE: it references `WorkOS::<Class>` for every
|
|
222
|
+
* model. Under a scoped (`--services`) run only the selected services' per-model
|
|
223
|
+
* `.rb` files are (re)written, so a test referencing a brand-new out-of-scope
|
|
224
|
+
* model whose file was never emitted would raise `NameError: uninitialized
|
|
225
|
+
* constant`. Each per-model test is therefore gated by {@link fileExistsAfterRun}
|
|
226
|
+
* on the SAME path `models.ts` writes (`lib/workos/<dir>/<file>.rb`): emit only
|
|
227
|
+
* when that file will exist on disk after the run — in-scope (emitted this run)
|
|
228
|
+
* OR already present from a prior run (`priorTargetManifestPaths`). Brand-new
|
|
229
|
+
* out-of-scope models are excluded; renamed/removed-but-on-disk models are
|
|
230
|
+
* retained. A full run (scoping inert) keeps every model.
|
|
215
231
|
*/
|
|
216
|
-
function generateModelRoundTripTest(spec: ApiSpec): GeneratedFile {
|
|
232
|
+
function generateModelRoundTripTest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
|
|
217
233
|
const lines: string[] = [];
|
|
218
234
|
lines.push(`require 'test_helper'`);
|
|
219
235
|
lines.push('');
|
|
@@ -223,7 +239,24 @@ function generateModelRoundTripTest(spec: ApiSpec): GeneratedFile {
|
|
|
223
239
|
const enumNames = new Set(spec.enums.map((e) => e.name));
|
|
224
240
|
const emitted = new Set<string>();
|
|
225
241
|
|
|
242
|
+
// Resolve each model's per-model file path EXACTLY as models.ts does, so the
|
|
243
|
+
// scope gate keys on the same path the per-model FILE writer uses.
|
|
244
|
+
const modelToService = assignModelsToServices(models, ctx.spec.services, ctx.modelHints);
|
|
245
|
+
const mountDirMap = buildMountDirMap(ctx);
|
|
246
|
+
const dirFor = (modelName: string): string => {
|
|
247
|
+
const service = modelToService.get(modelName);
|
|
248
|
+
if (!service) return classifyUnassignedModel(modelName);
|
|
249
|
+
return mountDirMap.get(service) ?? classifyUnassignedModel(modelName);
|
|
250
|
+
};
|
|
251
|
+
|
|
226
252
|
for (const model of models) {
|
|
253
|
+
// Skip models whose per-model `.rb` file will NOT exist on disk after this
|
|
254
|
+
// run (brand-new + out-of-scope under `--services`). Without this gate the
|
|
255
|
+
// aggregate would reference an undefined constant. The path mirrors
|
|
256
|
+
// models.ts's `lib/workos/${dirFor}/${fileName}.rb`.
|
|
257
|
+
const modelFilePath = `lib/workos/${dirFor(model.name)}/${fileName(model.name)}.rb`;
|
|
258
|
+
if (!fileExistsAfterRun(modelFilePath, isModelInScope(model.name, ctx), ctx)) continue;
|
|
259
|
+
|
|
227
260
|
// Avoid duplicate test names when two IR model names collapse to the same
|
|
228
261
|
// snake_case file name (we use the file name as the test suffix).
|
|
229
262
|
const fileBase = fileName(model.name);
|