@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/kotlin/resources.ts
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
import {
|
|
35
35
|
buildResolvedLookup,
|
|
36
36
|
lookupResolved,
|
|
37
|
-
|
|
37
|
+
scopedMountGroups,
|
|
38
38
|
buildHiddenParams,
|
|
39
39
|
getOpDefaults,
|
|
40
40
|
getOpInferFromClient,
|
|
@@ -97,7 +97,7 @@ function promoteFieldType(f: Field): Field {
|
|
|
97
97
|
export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
|
|
98
98
|
if (services.length === 0) return [];
|
|
99
99
|
|
|
100
|
-
const mountGroups =
|
|
100
|
+
const mountGroups = scopedMountGroups(ctx);
|
|
101
101
|
if (mountGroups.size === 0) return [];
|
|
102
102
|
|
|
103
103
|
const files: GeneratedFile[] = [];
|
package/src/kotlin/tests.ts
CHANGED
|
@@ -22,11 +22,14 @@ import {
|
|
|
22
22
|
} from './naming.js';
|
|
23
23
|
import { mapTypeRef } from './type-map.js';
|
|
24
24
|
import {
|
|
25
|
-
|
|
25
|
+
scopedMountGroups,
|
|
26
26
|
lookupResolved,
|
|
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.
|
|
@@ -76,7 +96,7 @@ function promoteIso8601TypeRef(type: TypeRef, description: string | undefined):
|
|
|
76
96
|
*/
|
|
77
97
|
export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
78
98
|
const files: GeneratedFile[] = [];
|
|
79
|
-
const mountGroups =
|
|
99
|
+
const mountGroups = scopedMountGroups(ctx);
|
|
80
100
|
const resolvedLookup = buildResolvedLookup(ctx);
|
|
81
101
|
|
|
82
102
|
const exportedClasses = buildExportedClassNameSet(ctx);
|
|
@@ -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/node/enums.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { docComment, assignModelsToEmittableServices } from './utils.js';
|
|
|
5
5
|
import { isInlineEnum } from './type-map.js';
|
|
6
6
|
import { isNodeOwnedService } from './options.js';
|
|
7
7
|
import { liveSurfaceConstEnumMembers, liveSurfaceInterfacePath } from './live-surface.js';
|
|
8
|
+
import { isEnumInScope } from '../shared/resolved-ops.js';
|
|
8
9
|
|
|
9
10
|
export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
10
11
|
if (enums.length === 0) return [];
|
|
@@ -120,11 +121,13 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
120
121
|
lines.push(` (typeof ${enumDef.name})[keyof typeof ${enumDef.name}];`);
|
|
121
122
|
}
|
|
122
123
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
124
|
+
if (isEnumInScope(enumDef.name, ctx)) {
|
|
125
|
+
files.push({
|
|
126
|
+
path: `src/${dirName}/interfaces/${fileName(enumDef.name)}.interface.ts`,
|
|
127
|
+
content: lines.join('\n'),
|
|
128
|
+
skipIfExists: !hasNewValues,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
128
131
|
}
|
|
129
132
|
|
|
130
133
|
return files;
|
package/src/node/models.ts
CHANGED
|
@@ -45,7 +45,7 @@ import {
|
|
|
45
45
|
import { liveSurfaceHasExistingSdk, liveSurfaceHasManagedFile, liveSurfaceInterfacePath } from './live-surface.js';
|
|
46
46
|
import { isNodeOwnedService, isHandOwnedType } from './options.js';
|
|
47
47
|
import { unwrapListModel } from './fixtures.js';
|
|
48
|
-
import { groupByMount, buildResolvedLookup, lookupResolved } from '../shared/resolved-ops.js';
|
|
48
|
+
import { groupByMount, buildResolvedLookup, lookupResolved, isModelInScope } from '../shared/resolved-ops.js';
|
|
49
49
|
import { resolveWrapperParams } from '../shared/wrapper-utils.js';
|
|
50
50
|
import { collectWrapperResponseModels } from './wrappers.js';
|
|
51
51
|
import { resolveResourceClassName } from './resources.js';
|
|
@@ -308,11 +308,13 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
308
308
|
const aliasLines = importSymbols
|
|
309
309
|
? [`import type { ${importSymbols} } from '${canonRelPath}';`, '', ...aliasExports]
|
|
310
310
|
: [...aliasExports];
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
311
|
+
if (isModelInScope(model.name, ctx)) {
|
|
312
|
+
files.push({
|
|
313
|
+
path: aliasPath,
|
|
314
|
+
content: aliasLines.join('\n'),
|
|
315
|
+
overwriteExisting: true,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
316
318
|
continue;
|
|
317
319
|
}
|
|
318
320
|
|
|
@@ -745,11 +747,13 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
745
747
|
}
|
|
746
748
|
}
|
|
747
749
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
750
|
+
if (isModelInScope(model.name, ctx)) {
|
|
751
|
+
files.push({
|
|
752
|
+
path: filePath,
|
|
753
|
+
content: pruneUnusedImports(lines).join('\n'),
|
|
754
|
+
overwriteExisting: true,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
753
757
|
}
|
|
754
758
|
|
|
755
759
|
return files;
|
|
@@ -943,11 +947,13 @@ export function generateSerializers(
|
|
|
943
947
|
parts.push(`serialize${canonDomainName} as serialize${domainName}`);
|
|
944
948
|
}
|
|
945
949
|
const reexportContent = `export { ${parts.join(', ')} } from '${rel}';`;
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
950
|
+
if (isModelInScope(model.name, ctx)) {
|
|
951
|
+
files.push({
|
|
952
|
+
path: serializerPath,
|
|
953
|
+
content: reexportContent,
|
|
954
|
+
overwriteExisting: true,
|
|
955
|
+
});
|
|
956
|
+
}
|
|
951
957
|
continue;
|
|
952
958
|
}
|
|
953
959
|
// The alias is response-reachable, but the canonical model is
|
|
@@ -997,11 +1003,13 @@ export function generateSerializers(
|
|
|
997
1003
|
),
|
|
998
1004
|
];
|
|
999
1005
|
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1006
|
+
if (isModelInScope(model.name, ctx)) {
|
|
1007
|
+
files.push({
|
|
1008
|
+
path: serializerPath,
|
|
1009
|
+
content: pruneUnusedImports(lines).join('\n'),
|
|
1010
|
+
overwriteExisting: true,
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1005
1013
|
}
|
|
1006
1014
|
|
|
1007
1015
|
(ctx as any)._skippedSerializeModels = skippedSerializeModels;
|
package/src/node/resources.ts
CHANGED
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
buildResolvedLookup,
|
|
79
79
|
lookupResolved,
|
|
80
80
|
groupByMount,
|
|
81
|
+
isMountInScope,
|
|
81
82
|
getOpDefaults,
|
|
82
83
|
getOpInferFromClient,
|
|
83
84
|
} from '../shared/resolved-ops.js';
|
|
@@ -718,11 +719,18 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
718
719
|
// multiple IR services mount to the same resource class.
|
|
719
720
|
const mountGroups = groupByMount(ctx);
|
|
720
721
|
const mergedServices: Service[] =
|
|
721
|
-
mountGroups.size > 0
|
|
722
|
+
mountGroups.size > 0 || ctx.scopedServices?.size
|
|
723
|
+
? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
|
|
724
|
+
: services;
|
|
722
725
|
|
|
723
726
|
const topLevelEnumNames = new Set(ctx.spec.enums.map((e) => e.name));
|
|
724
727
|
|
|
725
728
|
for (const service of mergedServices) {
|
|
729
|
+
// Scope gate: in a scoped (`--services`) run, only emit per-service resource
|
|
730
|
+
// files for the selected post-mount names. `service.name` is the mount-group
|
|
731
|
+
// key (the POST-MOUNT name that matches `ctx.scopedServices`). Applied as an
|
|
732
|
+
// additional early continue ahead of the node-owned/coverage skip logic.
|
|
733
|
+
if (!isMountInScope(service.name, ctx)) continue;
|
|
726
734
|
const isOwnedService = isNodeOwnedService(ctx, service.name, resolveResourceClassName(service, ctx));
|
|
727
735
|
if (!isOwnedService && isServiceCoveredByExisting(service, ctx)) {
|
|
728
736
|
if (!hasMethodsAbsentFromBaseline(service, ctx)) {
|
|
@@ -770,6 +778,9 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
770
778
|
// stable. Placing them under `interfaces/` lets the per-service barrel
|
|
771
779
|
// pick them up automatically.
|
|
772
780
|
for (const service of mergedServices) {
|
|
781
|
+
// Scope gate: keep the per-service options interfaces aligned with the
|
|
782
|
+
// resource files emitted above — only the selected post-mount names.
|
|
783
|
+
if (!isMountInScope(service.name, ctx)) continue;
|
|
773
784
|
const isOwnedService = isNodeOwnedService(ctx, service.name, resolveResourceClassName(service, ctx));
|
|
774
785
|
if (!isOwnedService && isServiceCoveredByExisting(service, ctx) && !hasMethodsAbsentFromBaseline(service, ctx))
|
|
775
786
|
continue;
|
package/src/node/tests.ts
CHANGED
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
modelHasNewFields,
|
|
36
36
|
computeNonEventReachable,
|
|
37
37
|
} from './utils.js';
|
|
38
|
-
import { groupByMount, buildResolvedLookup, lookupResolved } from '../shared/resolved-ops.js';
|
|
38
|
+
import { groupByMount, buildResolvedLookup, lookupResolved, isMountInScope } from '../shared/resolved-ops.js';
|
|
39
39
|
import { isNodeOwnedService, nodeOptions, planOperationFor } from './options.js';
|
|
40
40
|
|
|
41
41
|
type BaselineMethod = {
|
|
@@ -137,7 +137,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
const testEntries: Array<{ name: string; operations: Operation[] }> =
|
|
140
|
-
mountGroups.size > 0
|
|
140
|
+
mountGroups.size > 0 || ctx.scopedServices?.size
|
|
141
141
|
? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
|
|
142
142
|
: spec.services.map((s) => ({ name: resolveResourceClassName(s, ctx), operations: s.operations }));
|
|
143
143
|
|
|
@@ -159,6 +159,11 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
for (const { name: mountName, operations } of testEntries) {
|
|
162
|
+
// Scope gate: in a scoped (`--services`) run, only emit per-service test
|
|
163
|
+
// files for the selected post-mount names. `mountName` is the mount-group
|
|
164
|
+
// key (the POST-MOUNT name that matches `ctx.scopedServices`). Applied as an
|
|
165
|
+
// additional early continue ahead of the node-owned/coverage skip logic.
|
|
166
|
+
if (!isMountInScope(mountName, ctx)) continue;
|
|
162
167
|
if (operations.length === 0) continue;
|
|
163
168
|
const mergedService: Service = { name: mountName, operations };
|
|
164
169
|
const isOwnedService = isNodeOwnedService(ctx, mountName, resolveResourceClassName(mergedService, ctx));
|
package/src/php/enums.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
|
2
2
|
import { toPascalCase } from '@workos/oagen';
|
|
3
3
|
import { className, resolveEnumName } from './naming.js';
|
|
4
4
|
import { phpDocComment } from './utils.js';
|
|
5
|
+
import { isEnumInScope } from '../shared/resolved-ops.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Generate PHP enum files from IR enums.
|
|
@@ -17,6 +18,16 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
17
18
|
if (emittedCanonical.has(canonical)) continue; // skip aliases
|
|
18
19
|
emittedCanonical.add(canonical);
|
|
19
20
|
|
|
21
|
+
// FR-1.4: write the per-enum FILE only when in scope. PHP dedupes
|
|
22
|
+
// value-identical enums onto a single canonical class, so the canonical
|
|
23
|
+
// file is needed when EITHER the canonical name OR any alias resolving to
|
|
24
|
+
// it is reachable from the selected services. PSR-4 (one class per file
|
|
25
|
+
// under lib/Resource/, no barrel) means an out-of-scope enum is simply
|
|
26
|
+
// left untouched on disk and stays loadable.
|
|
27
|
+
const enumInScope = enums.some(
|
|
28
|
+
(other) => resolveEnumName(other.name) === canonical && isEnumInScope(other.name, ctx),
|
|
29
|
+
);
|
|
30
|
+
|
|
20
31
|
const name = className(canonical);
|
|
21
32
|
const _isAllStrings = e.values.every((v) => typeof v.value === 'string');
|
|
22
33
|
const isAllInts = e.values.every((v) => typeof v.value === 'number' && Number.isInteger(v.value));
|
|
@@ -56,11 +67,13 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
56
67
|
|
|
57
68
|
lines.push('}');
|
|
58
69
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
70
|
+
if (enumInScope) {
|
|
71
|
+
files.push({
|
|
72
|
+
path: `lib/Resource/${name}.php`,
|
|
73
|
+
content: lines.join('\n'),
|
|
74
|
+
overwriteExisting: true,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
64
77
|
}
|
|
65
78
|
|
|
66
79
|
return files;
|
package/src/php/index.ts
CHANGED
|
@@ -42,9 +42,17 @@ function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
|
|
|
42
42
|
* classes (no sum types), so a discriminated base whose IR fields the
|
|
43
43
|
* parser stripped (post-allOf-aware detection) gets its original fields
|
|
44
44
|
* restored to avoid silently dropping variant data.
|
|
45
|
+
*
|
|
46
|
+
* `enums` is forwarded to seed `enrichModelsFromSpec`'s collision set: an
|
|
47
|
+
* inline oneOf enum whose synthetic name (`Parent_field`) snake-collapses
|
|
48
|
+
* onto an existing IR enum (e.g. `DataIntegrationAccessTokenResponse_error`
|
|
49
|
+
* vs `DataIntegrationAccessTokenResponseError`) must NOT spawn a duplicate
|
|
50
|
+
* synthetic. Otherwise both collapse to the same `lib/Resource/X.php` path
|
|
51
|
+
* and the later writer wins by array order — which differs between a full
|
|
52
|
+
* and a scoped (`--services`) run, producing a non-deterministic case order.
|
|
45
53
|
*/
|
|
46
|
-
function enrichModelsForPhp(models: Model[]): Model[] {
|
|
47
|
-
const enriched = enrichModelsFromSpec(models);
|
|
54
|
+
function enrichModelsForPhp(models: Model[], enums: Enum[]): Model[] {
|
|
55
|
+
const enriched = enrichModelsFromSpec(models, enums);
|
|
48
56
|
const originalByName = new Map(models.map((m) => [m.name, m]));
|
|
49
57
|
return enriched.map((m) => {
|
|
50
58
|
if ((m as { discriminator?: unknown }).discriminator && m.fields.length === 0) {
|
|
@@ -62,7 +70,7 @@ export const phpEmitter: Emitter = {
|
|
|
62
70
|
|
|
63
71
|
generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
64
72
|
ensureNamingInitialized(ctx);
|
|
65
|
-
return ensureTrailingNewlines(generateModels(enrichModelsForPhp(models), ctx));
|
|
73
|
+
return ensureTrailingNewlines(generateModels(enrichModelsForPhp(models, ctx.spec.enums), ctx));
|
|
66
74
|
},
|
|
67
75
|
|
|
68
76
|
generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
package/src/php/models.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
collectNonPaginatedResponseModelNames,
|
|
11
11
|
collectReferencedListMetadataModels,
|
|
12
12
|
} from '../shared/model-utils.js';
|
|
13
|
+
import { isModelInScope } from '../shared/resolved-ops.js';
|
|
13
14
|
export { isListMetadataModel, isListWrapperModel };
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -140,11 +141,16 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
140
141
|
|
|
141
142
|
lines.push('}');
|
|
142
143
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
144
|
+
// FR-1.4: write the per-model FILE only when in scope. PHP uses PSR-4
|
|
145
|
+
// (one class per file under lib/Resource/, no barrel/index), so an
|
|
146
|
+
// out-of-scope model is simply left untouched on disk and stays loadable.
|
|
147
|
+
if (isModelInScope(model.name, ctx)) {
|
|
148
|
+
files.push({
|
|
149
|
+
path: `lib/Resource/${name}.php`,
|
|
150
|
+
content: lines.join('\n'),
|
|
151
|
+
overwriteExisting: true,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
148
154
|
}
|
|
149
155
|
|
|
150
156
|
return files;
|
package/src/php/resources.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { mapTypeRef, mapTypeRefForPHPDoc } from './type-map.js';
|
|
|
12
12
|
import { className, fieldName, resolveMethodName, buildExportedClassNameSet, resolveServiceTarget } from './naming.js';
|
|
13
13
|
import { isListWrapperModel } from './models.js';
|
|
14
14
|
import {
|
|
15
|
-
|
|
15
|
+
scopedMountGroups,
|
|
16
16
|
buildResolvedLookup,
|
|
17
17
|
lookupResolved,
|
|
18
18
|
getOpDefaults,
|
|
@@ -44,10 +44,12 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
44
44
|
const files: GeneratedFile[] = [];
|
|
45
45
|
const modelMap = new Map(ctx.spec.models.map((m) => [m.name, m]));
|
|
46
46
|
|
|
47
|
-
// Group operations by mount target
|
|
48
|
-
|
|
47
|
+
// Group operations by mount target. In a scoped (`--services`) run this
|
|
48
|
+
// returns only the selected post-mount services so we emit per-service
|
|
49
|
+
// resource files for those alone.
|
|
50
|
+
const mountGroups = scopedMountGroups(ctx);
|
|
49
51
|
const entries: Array<{ name: string; operations: Operation[] }> =
|
|
50
|
-
mountGroups.size > 0
|
|
52
|
+
mountGroups.size > 0 || ctx.scopedServices?.size
|
|
51
53
|
? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
|
|
52
54
|
: services.map((s) => ({ name: className(s.name), operations: s.operations }));
|
|
53
55
|
|
package/src/php/tests.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { isListWrapperModel } from './models.js';
|
|
|
20
20
|
import { generateFixtures } from './fixtures.js';
|
|
21
21
|
import {
|
|
22
22
|
getMountTarget,
|
|
23
|
-
|
|
23
|
+
scopedMountGroups,
|
|
24
24
|
buildHiddenParams,
|
|
25
25
|
getOpDefaults,
|
|
26
26
|
getOpInferFromClient,
|
|
@@ -39,9 +39,12 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
39
39
|
|
|
40
40
|
// Collect all operations per mount target using resolved per-operation mounts.
|
|
41
41
|
// This correctly handles operationHint mountOn overrides (e.g., audit_logs_retention → AuditLogs).
|
|
42
|
-
|
|
42
|
+
// In a scoped (`--services`) run this returns only the selected post-mount
|
|
43
|
+
// services so we emit per-service test files for those alone. ClientTest.php
|
|
44
|
+
// and fixtures below are built from `spec` and stay full.
|
|
45
|
+
const mountGroupsFromResolved = scopedMountGroups(ctx);
|
|
43
46
|
const mountGroups = new Map<string, { op: Operation; service: Service; resolvedOp?: ResolvedOperation }[]>();
|
|
44
|
-
if (mountGroupsFromResolved.size > 0) {
|
|
47
|
+
if (mountGroupsFromResolved.size > 0 || ctx.scopedServices?.size) {
|
|
45
48
|
for (const [target, group] of mountGroupsFromResolved) {
|
|
46
49
|
mountGroups.set(
|
|
47
50
|
target,
|
package/src/python/enums.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
|
2
2
|
import { toUpperSnakeCase } from '@workos/oagen';
|
|
3
3
|
import { className, fileName, buildMountDirMap, dirToModule } from './naming.js';
|
|
4
4
|
import { computeSchemaPlacement } from './shared-schemas.js';
|
|
5
|
+
import { isEnumInScope } from '../shared/resolved-ops.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Convert a PascalCase class name to a human-readable lowercase string,
|
|
@@ -38,6 +39,10 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
38
39
|
for (const enumDef of enums) {
|
|
39
40
|
const service = enumToService.get(enumDef.name);
|
|
40
41
|
const dirName = resolveDir(service);
|
|
42
|
+
// FR-1.4: write per-enum FILES only when in scope; the enum barrel is built
|
|
43
|
+
// separately (collectGeneratedEnumSymbolsByDir over the full set), so an
|
|
44
|
+
// out-of-scope enum left on disk stays exported and importable.
|
|
45
|
+
const enumInScope = isEnumInScope(enumDef.name, ctx);
|
|
41
46
|
|
|
42
47
|
// If this enum is an alias for a canonical enum, generate a type alias file
|
|
43
48
|
const canonicalName = aliasOf.get(enumDef.name);
|
|
@@ -73,12 +78,14 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
73
78
|
lines.push(' raise AttributeError(f"module {__name__!r} has no attribute {name!r}")');
|
|
74
79
|
}
|
|
75
80
|
lines.push(`__all__ = ["${aliasCls}"]`);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
if (enumInScope) {
|
|
82
|
+
files.push({
|
|
83
|
+
path: `src/${ctx.namespace}/${dirName}/models/${fileName(enumDef.name)}.py`,
|
|
84
|
+
content: lines.join('\n'),
|
|
85
|
+
integrateTarget: true,
|
|
86
|
+
overwriteExisting: true,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
82
89
|
|
|
83
90
|
// Also generate compat alias files for dedup aliases (they may have compat aliases too)
|
|
84
91
|
for (const aliasName of compatAliases.get(enumDef.name) ?? []) {
|
|
@@ -107,12 +114,14 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
107
114
|
`__all__ = ["${aliasName}"]`,
|
|
108
115
|
].join('\n');
|
|
109
116
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
117
|
+
if (enumInScope) {
|
|
118
|
+
files.push({
|
|
119
|
+
path: `src/${ctx.namespace}/${dirName}/models/${fileName(aliasName)}.py`,
|
|
120
|
+
content: compatContent,
|
|
121
|
+
integrateTarget: true,
|
|
122
|
+
overwriteExisting: true,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
116
125
|
}
|
|
117
126
|
|
|
118
127
|
continue;
|
|
@@ -241,26 +250,28 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
241
250
|
);
|
|
242
251
|
}
|
|
243
252
|
|
|
244
|
-
|
|
245
|
-
path: `src/${ctx.namespace}/${dirName}/models/${fileName(enumDef.name)}.py`,
|
|
246
|
-
content: lines.join('\n'),
|
|
247
|
-
integrateTarget: true,
|
|
248
|
-
overwriteExisting: true,
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
for (const aliasName of compatAliases.get(enumDef.name) ?? []) {
|
|
253
|
+
if (enumInScope) {
|
|
252
254
|
files.push({
|
|
253
|
-
path: `src/${ctx.namespace}/${dirName}/models/${fileName(
|
|
254
|
-
content:
|
|
255
|
-
'from typing import TypeAlias',
|
|
256
|
-
`from .${fileName(enumDef.name)} import ${cls}`,
|
|
257
|
-
'',
|
|
258
|
-
`${aliasName}: TypeAlias = ${cls}`,
|
|
259
|
-
`__all__ = ["${aliasName}"]`,
|
|
260
|
-
].join('\n'),
|
|
255
|
+
path: `src/${ctx.namespace}/${dirName}/models/${fileName(enumDef.name)}.py`,
|
|
256
|
+
content: lines.join('\n'),
|
|
261
257
|
integrateTarget: true,
|
|
262
258
|
overwriteExisting: true,
|
|
263
259
|
});
|
|
260
|
+
|
|
261
|
+
for (const aliasName of compatAliases.get(enumDef.name) ?? []) {
|
|
262
|
+
files.push({
|
|
263
|
+
path: `src/${ctx.namespace}/${dirName}/models/${fileName(aliasName)}.py`,
|
|
264
|
+
content: [
|
|
265
|
+
'from typing import TypeAlias',
|
|
266
|
+
`from .${fileName(enumDef.name)} import ${cls}`,
|
|
267
|
+
'',
|
|
268
|
+
`${aliasName}: TypeAlias = ${cls}`,
|
|
269
|
+
`__all__ = ["${aliasName}"]`,
|
|
270
|
+
].join('\n'),
|
|
271
|
+
integrateTarget: true,
|
|
272
|
+
overwriteExisting: true,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
264
275
|
}
|
|
265
276
|
}
|
|
266
277
|
|
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],
|