@workos/oagen 0.29.2 → 0.29.4
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/dist/cli/index.mjs +488 -27
- package/dist/cli/index.mjs.map +1 -1
- package/dist/compat/index.d.mts +2 -2
- package/dist/{diff-DqnHHXvr.mjs → diff-B6TjXIwb.mjs} +139 -6
- package/dist/diff-B6TjXIwb.mjs.map +1 -0
- package/dist/{differ-DxWejtEg.d.mts → differ-DHldxZMr.d.mts} +2 -2
- package/dist/{differ-DxWejtEg.d.mts.map → differ-DHldxZMr.d.mts.map} +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +351 -23
- package/dist/index.mjs.map +1 -1
- package/dist/scripts/smoke/baseline.mjs +350 -22
- package/dist/scripts/smoke/baseline.mjs.map +1 -1
- package/dist/smoke.d.mts +20 -0
- package/dist/smoke.d.mts.map +1 -1
- package/dist/smoke.mjs +350 -22
- package/dist/smoke.mjs.map +1 -1
- package/dist/tree-sitter-C6HB4OrC.mjs.map +1 -1
- package/dist/{types-CEMBeTr9.d.mts → types-yrBidOg3.d.mts} +21 -1
- package/dist/{types-CEMBeTr9.d.mts.map → types-yrBidOg3.d.mts.map} +1 -1
- package/dist/verify/index.d.mts +2 -2
- package/dist/verify/index.mjs +1 -1
- package/package.json +1 -1
- package/dist/diff-DqnHHXvr.mjs.map +0 -1
package/dist/cli/index.mjs
CHANGED
|
@@ -2013,6 +2013,30 @@ function extractInlineModel(name, schema) {
|
|
|
2013
2013
|
}
|
|
2014
2014
|
//#endregion
|
|
2015
2015
|
//#region src/parser/operations.ts
|
|
2016
|
+
/**
|
|
2017
|
+
* Resolve a raw parameter entry to a concrete Parameter Object, following
|
|
2018
|
+
* `$ref`s into `components/parameters` when present. Follows chains of refs
|
|
2019
|
+
* (a component that itself is a `$ref` to another component), with a cycle
|
|
2020
|
+
* guard for a spec that refs itself.
|
|
2021
|
+
*
|
|
2022
|
+
* Throws when a `$ref` can't be resolved so a malformed spec fails loudly at
|
|
2023
|
+
* parse time instead of silently losing the parameter (see `extractParams`,
|
|
2024
|
+
* which used to drop any entry with no `in` field — every `$ref` object has
|
|
2025
|
+
* none).
|
|
2026
|
+
*/
|
|
2027
|
+
function resolveParameterRef(raw, componentParameters, operationContext) {
|
|
2028
|
+
const seenRefs = /* @__PURE__ */ new Set();
|
|
2029
|
+
let current = raw;
|
|
2030
|
+
while ("$ref" in current) {
|
|
2031
|
+
if (seenRefs.has(current.$ref)) throw new Error(`Circular parameter $ref chain starting at "${current.$ref}" in ${operationContext}.`);
|
|
2032
|
+
seenRefs.add(current.$ref);
|
|
2033
|
+
const refName = /^#\/components\/parameters\/(.+)$/.exec(current.$ref)?.[1].replace(/~1/g, "/").replace(/~0/g, "~");
|
|
2034
|
+
const resolved = refName ? componentParameters?.[refName] : void 0;
|
|
2035
|
+
if (!resolved) throw new Error(`Unresolved parameter $ref "${current.$ref}" in ${operationContext}: no matching entry under components/parameters.`);
|
|
2036
|
+
current = resolved;
|
|
2037
|
+
}
|
|
2038
|
+
return current;
|
|
2039
|
+
}
|
|
2016
2040
|
const HTTP_METHODS = [
|
|
2017
2041
|
"get",
|
|
2018
2042
|
"post",
|
|
@@ -2023,7 +2047,7 @@ const HTTP_METHODS = [
|
|
|
2023
2047
|
"options",
|
|
2024
2048
|
"trace"
|
|
2025
2049
|
];
|
|
2026
|
-
function extractOperations(paths, operationIdTransform, componentSchemas) {
|
|
2050
|
+
function extractOperations(paths, operationIdTransform, componentSchemas, componentParameters) {
|
|
2027
2051
|
if (!paths) return {
|
|
2028
2052
|
services: [],
|
|
2029
2053
|
inlineModels: []
|
|
@@ -2036,7 +2060,7 @@ function extractOperations(paths, operationIdTransform, componentSchemas) {
|
|
|
2036
2060
|
const op = pathItem[method];
|
|
2037
2061
|
if (!op) continue;
|
|
2038
2062
|
const serviceName = inferServiceName(path, op.tags?.[0]);
|
|
2039
|
-
const { operation, inlineModels: opModels } = buildOperation(method, path, op, pathLevelParams, operationIdTransform, serviceName, componentSchemas);
|
|
2063
|
+
const { operation, inlineModels: opModels } = buildOperation(method, path, op, pathLevelParams, operationIdTransform, serviceName, componentSchemas, componentParameters);
|
|
2040
2064
|
inlineModels.push(...opModels);
|
|
2041
2065
|
const ops = serviceMap.get(serviceName) ?? [];
|
|
2042
2066
|
ops.push(operation);
|
|
@@ -2234,19 +2258,11 @@ function extractParameterGroups(op, allIRParams, operationContext) {
|
|
|
2234
2258
|
* the request body. Emitters use the group's presence to generate sum-type
|
|
2235
2259
|
* interfaces and custom JSON marshalling instead of flat optional fields.
|
|
2236
2260
|
*/
|
|
2237
|
-
function extractBodyParameterGroups(op, operationContext) {
|
|
2261
|
+
function extractBodyParameterGroups(op, operationContext, componentSchemas) {
|
|
2238
2262
|
const raw = op["x-mutually-exclusive-body-groups"];
|
|
2239
2263
|
if (!raw || typeof raw !== "object") return void 0;
|
|
2240
2264
|
const bodySchema = op.requestBody?.content?.["application/json"]?.schema;
|
|
2241
|
-
const variantFieldSchemas =
|
|
2242
|
-
if (bodySchema) {
|
|
2243
|
-
for (const sub of bodySchema.allOf ?? []) for (const variant of sub.oneOf ?? []) if (variant.properties) {
|
|
2244
|
-
for (const [name, fieldSchema] of Object.entries(variant.properties)) if (fieldSchema) variantFieldSchemas.set(name, fieldSchema);
|
|
2245
|
-
}
|
|
2246
|
-
for (const variant of bodySchema.oneOf ?? []) if (variant.properties) {
|
|
2247
|
-
for (const [name, fieldSchema] of Object.entries(variant.properties)) if (fieldSchema) variantFieldSchemas.set(name, fieldSchema);
|
|
2248
|
-
}
|
|
2249
|
-
}
|
|
2265
|
+
const variantFieldSchemas = collectVariantFieldSchemas(bodySchema, componentSchemas);
|
|
2250
2266
|
const groups = [];
|
|
2251
2267
|
for (const [groupName, groupDef] of Object.entries(raw)) {
|
|
2252
2268
|
if (!groupDef || typeof groupDef !== "object") continue;
|
|
@@ -2256,10 +2272,12 @@ function extractBodyParameterGroups(op, operationContext) {
|
|
|
2256
2272
|
const variants = Object.entries(groupDef.variants).map(([variantName, paramNames]) => {
|
|
2257
2273
|
if (!Array.isArray(paramNames) || paramNames.length === 0) throw new Error(`Malformed x-mutually-exclusive-body-groups.${groupName}.variants.${variantName} in ${operationContext}: expected a non-empty array of parameter names.`);
|
|
2258
2274
|
const parameters = paramNames.map((pName) => {
|
|
2259
|
-
const
|
|
2275
|
+
const entry = variantFieldSchemas.get(pName);
|
|
2276
|
+
const fieldSchema = entry?.schema;
|
|
2277
|
+
if (!fieldSchema) console.warn(`[oagen] Warning: x-mutually-exclusive-body-groups.${groupName}.variants.${variantName} references "${pName}" in ${operationContext}, but no oneOf branch of the request body declares that property. Falling back to type: string.`);
|
|
2260
2278
|
return {
|
|
2261
2279
|
name: pName,
|
|
2262
|
-
type: fieldSchema ? schemaToTypeRef(fieldSchema, toPascalCase(pName)) : {
|
|
2280
|
+
type: fieldSchema ? schemaToTypeRef(fieldSchema, toPascalCase(pName), entry?.owner) : {
|
|
2263
2281
|
kind: "primitive",
|
|
2264
2282
|
type: "string"
|
|
2265
2283
|
},
|
|
@@ -2283,8 +2301,14 @@ function extractBodyParameterGroups(op, operationContext) {
|
|
|
2283
2301
|
}
|
|
2284
2302
|
return groups.length > 0 ? groups : void 0;
|
|
2285
2303
|
}
|
|
2286
|
-
function buildOperation(method, path, op, pathLevelParams, operationIdTransform, serviceName, componentSchemas) {
|
|
2287
|
-
const
|
|
2304
|
+
function buildOperation(method, path, op, pathLevelParams, operationIdTransform, serviceName, componentSchemas, componentParameters) {
|
|
2305
|
+
const opLabel = op.operationId ?? `${method.toUpperCase()} ${path}`;
|
|
2306
|
+
const paramsByIdentity = /* @__PURE__ */ new Map();
|
|
2307
|
+
for (const raw of [...pathLevelParams, ...op.parameters ?? []]) {
|
|
2308
|
+
const param = resolveParameterRef(raw, componentParameters, opLabel);
|
|
2309
|
+
paramsByIdentity.set(`${param.in}:${param.name}`, param);
|
|
2310
|
+
}
|
|
2311
|
+
const allParams = [...paramsByIdentity.values()];
|
|
2288
2312
|
const hasIdempotencyHeader = allParams.some((p) => p.in === "header" && p.name.toLowerCase() === "idempotency-key");
|
|
2289
2313
|
const opContext = serviceName;
|
|
2290
2314
|
const pathParams = extractParams(allParams, "path", opContext, componentSchemas);
|
|
@@ -2308,15 +2332,13 @@ function buildOperation(method, path, op, pathLevelParams, operationIdTransform,
|
|
|
2308
2332
|
};
|
|
2309
2333
|
} else if (paginationFromParams) pagination = paginationFromParams;
|
|
2310
2334
|
const security = extractOperationSecurity(op.security);
|
|
2311
|
-
const
|
|
2335
|
+
const queryParamGroups = extractParameterGroups(op, [
|
|
2312
2336
|
...pathParams,
|
|
2313
2337
|
...queryParams,
|
|
2314
2338
|
...headerParams,
|
|
2315
2339
|
...cookieParams
|
|
2316
|
-
];
|
|
2317
|
-
const
|
|
2318
|
-
const queryParamGroups = extractParameterGroups(op, allIRParams, opLabel);
|
|
2319
|
-
const bodyParamGroups = extractBodyParameterGroups(op, opLabel);
|
|
2340
|
+
], opLabel);
|
|
2341
|
+
const bodyParamGroups = extractBodyParameterGroups(op, opLabel, componentSchemas);
|
|
2320
2342
|
const parameterGroups = queryParamGroups || bodyParamGroups ? [...queryParamGroups ?? [], ...bodyParamGroups ?? []] : void 0;
|
|
2321
2343
|
return {
|
|
2322
2344
|
operation: {
|
|
@@ -2399,6 +2421,55 @@ function resolveParamSchemaRef(schema, componentSchemas) {
|
|
|
2399
2421
|
if (!match) return void 0;
|
|
2400
2422
|
return componentSchemas[match[1]];
|
|
2401
2423
|
}
|
|
2424
|
+
/**
|
|
2425
|
+
* Collect the property schemas of every `oneOf` branch reachable from a request
|
|
2426
|
+
* body schema, keyed by property name. These are the candidate members of a
|
|
2427
|
+
* mutually-exclusive body group.
|
|
2428
|
+
*
|
|
2429
|
+
* The bundler runs with `dereference: false`, so the body schema is very often
|
|
2430
|
+
* a bare `{ $ref: '#/components/schemas/XDto' }` and the `allOf`/`oneOf`
|
|
2431
|
+
* composition that actually declares the variant properties lives on the
|
|
2432
|
+
* component. Walking without resolving refs finds nothing, and every group
|
|
2433
|
+
* member then falls back to `string` — an SDK that compiles but types
|
|
2434
|
+
* object-valued members (e.g. a connection's `saml_options`) as plain strings.
|
|
2435
|
+
*
|
|
2436
|
+
* Composition keywords nest and may themselves be refs, so resolve and recurse
|
|
2437
|
+
* at each level. `seen` guards against a schema that (directly or transitively)
|
|
2438
|
+
* refs itself.
|
|
2439
|
+
*/
|
|
2440
|
+
function collectVariantFieldSchemas(schema, componentSchemas, owner = void 0, out = /* @__PURE__ */ new Map(), seen = /* @__PURE__ */ new Set()) {
|
|
2441
|
+
if (!schema) return out;
|
|
2442
|
+
const ref = schema.$ref;
|
|
2443
|
+
if (ref) {
|
|
2444
|
+
if (seen.has(ref)) return out;
|
|
2445
|
+
seen.add(ref);
|
|
2446
|
+
const match = /^#\/components\/schemas\/(.+)$/.exec(ref);
|
|
2447
|
+
return collectVariantFieldSchemas(match && componentSchemas ? componentSchemas[match[1]] : void 0, componentSchemas, match ? resolveSchemaName(match[1]) : owner, out, seen);
|
|
2448
|
+
}
|
|
2449
|
+
for (const variant of schema.oneOf ?? []) {
|
|
2450
|
+
const variantRef = variant.$ref;
|
|
2451
|
+
const refMatch = variantRef ? /^#\/components\/schemas\/(.+)$/.exec(variantRef) : null;
|
|
2452
|
+
const resolved = resolveSchemaRefObject(variant, componentSchemas) ?? variant;
|
|
2453
|
+
const variantOwner = refMatch ? resolveSchemaName(refMatch[1]) : owner;
|
|
2454
|
+
for (const [name, fieldSchema] of Object.entries(resolved.properties ?? {})) if (fieldSchema) out.set(name, {
|
|
2455
|
+
schema: fieldSchema,
|
|
2456
|
+
owner: variantOwner
|
|
2457
|
+
});
|
|
2458
|
+
collectVariantFieldSchemas(variant, componentSchemas, owner, out, seen);
|
|
2459
|
+
}
|
|
2460
|
+
for (const sub of schema.allOf ?? []) collectVariantFieldSchemas(sub, componentSchemas, owner, out, seen);
|
|
2461
|
+
for (const sub of schema.anyOf ?? []) collectVariantFieldSchemas(sub, componentSchemas, owner, out, seen);
|
|
2462
|
+
return out;
|
|
2463
|
+
}
|
|
2464
|
+
/** Resolve `{ $ref: '#/components/schemas/X' }` to its component, else undefined. */
|
|
2465
|
+
function resolveSchemaRefObject(schema, componentSchemas) {
|
|
2466
|
+
if (!schema || !componentSchemas) return void 0;
|
|
2467
|
+
const ref = schema.$ref;
|
|
2468
|
+
if (!ref) return void 0;
|
|
2469
|
+
const match = /^#\/components\/schemas\/(.+)$/.exec(ref);
|
|
2470
|
+
if (!match) return void 0;
|
|
2471
|
+
return componentSchemas[match[1]];
|
|
2472
|
+
}
|
|
2402
2473
|
function extractRequestBody(body, op, inlineModels) {
|
|
2403
2474
|
if (!body?.content) return {};
|
|
2404
2475
|
let encoding = "json";
|
|
@@ -2811,6 +2882,262 @@ function collectInlineEnumsFromOperations(services, enums) {
|
|
|
2811
2882
|
]) collectInlineEnumFromRef(param.type, enums, enumNames);
|
|
2812
2883
|
}
|
|
2813
2884
|
//#endregion
|
|
2885
|
+
//#region src/parser/parameter-group-names.ts
|
|
2886
|
+
/**
|
|
2887
|
+
* Assign `ParameterGroup.wrapperName` across the whole spec.
|
|
2888
|
+
*
|
|
2889
|
+
* Emitters derive a group's generated wrapper type name from the group name
|
|
2890
|
+
* alone, so two operations sharing a group name (create and update both
|
|
2891
|
+
* declare `protocol_options`) collapse onto a single type. That is only sound
|
|
2892
|
+
* while the two declarations carry the same members. Where they differ, one
|
|
2893
|
+
* operation ends up typed with the other's member — e.g. a connection update
|
|
2894
|
+
* forced to pass `CreateConnectionSAMLOptions`, which accepts three fields the
|
|
2895
|
+
* PATCH endpoint rejects.
|
|
2896
|
+
*
|
|
2897
|
+
* This pass fingerprints every group's members structurally and qualifies the
|
|
2898
|
+
* wrapper name with the operation name only for groups that genuinely diverge.
|
|
2899
|
+
* Groups whose declarations agree keep the bare group name, so wrapper types
|
|
2900
|
+
* already published in the SDKs are never renamed.
|
|
2901
|
+
*/
|
|
2902
|
+
function assignParameterGroupWrapperNames(spec) {
|
|
2903
|
+
const modelMap = new Map(spec.models.map((m) => [m.name, m]));
|
|
2904
|
+
const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
|
|
2905
|
+
const fingerprints = /* @__PURE__ */ new Map();
|
|
2906
|
+
const occurrences = [];
|
|
2907
|
+
for (const service of spec.services) for (const op of service.operations) for (const group of op.parameterGroups ?? []) {
|
|
2908
|
+
const fp = fingerprintGroup(group, modelMap, enumMap);
|
|
2909
|
+
let seen = fingerprints.get(group.name);
|
|
2910
|
+
if (!seen) {
|
|
2911
|
+
seen = /* @__PURE__ */ new Set();
|
|
2912
|
+
fingerprints.set(group.name, seen);
|
|
2913
|
+
}
|
|
2914
|
+
seen.add(fp);
|
|
2915
|
+
occurrences.push({
|
|
2916
|
+
service,
|
|
2917
|
+
op,
|
|
2918
|
+
group,
|
|
2919
|
+
fingerprint: fp
|
|
2920
|
+
});
|
|
2921
|
+
}
|
|
2922
|
+
for (const { op, group } of occurrences) group.wrapperName = (fingerprints.get(group.name)?.size ?? 0) > 1 ? `${op.name}_${group.name}` : group.name;
|
|
2923
|
+
qualifyResidualCollisions(occurrences);
|
|
2924
|
+
reconcileSharedGroups(occurrences.map((o) => o.group));
|
|
2925
|
+
}
|
|
2926
|
+
/**
|
|
2927
|
+
* Break the collisions the operation-qualified name can still leave behind.
|
|
2928
|
+
*
|
|
2929
|
+
* `${op.name}_${group.name}` is not a unique key, for two separate reasons:
|
|
2930
|
+
*
|
|
2931
|
+
* - `disambiguateOperationNames` makes operation names unique only *within* a
|
|
2932
|
+
* service, so two services that each own an operation named `create`, both
|
|
2933
|
+
* declaring a `password` group with different members, both land on
|
|
2934
|
+
* `create_password`.
|
|
2935
|
+
* - Within one service it deliberately leaves same-name operations alone when
|
|
2936
|
+
* they share a path (see the `uniquePaths.size <= 1` guard — "same path,
|
|
2937
|
+
* different methods"), so `PUT` and `PATCH` on one path both stay `update`
|
|
2938
|
+
* and a service prefix alone still produces one name for both.
|
|
2939
|
+
*
|
|
2940
|
+
* Either way `reconcileSharedGroups` would then fuse two incompatible
|
|
2941
|
+
* declarations into a single type — the exact failure this pass exists to
|
|
2942
|
+
* prevent — and it does not drop the surplus member, so the wrapper would
|
|
2943
|
+
* expose a field one of the operations rejects.
|
|
2944
|
+
*
|
|
2945
|
+
* So escalate in rounds, adding a qualifier at a time and re-testing what still
|
|
2946
|
+
* collides. `(service, path, method)` identifies an operation uniquely, and a
|
|
2947
|
+
* same-name collision within a service implies a shared path, so service then
|
|
2948
|
+
* method is sufficient to separate every case.
|
|
2949
|
+
*
|
|
2950
|
+
* Each round only touches names that *still* group differing fingerprints, so a
|
|
2951
|
+
* name whose sharers agree is never renamed: sharing one wrapper is exactly what
|
|
2952
|
+
* should happen there, and renaming it would move a type the SDKs already
|
|
2953
|
+
* publish. On a spec where no qualified name collides — the WorkOS spec today —
|
|
2954
|
+
* the whole pass is a no-op.
|
|
2955
|
+
*/
|
|
2956
|
+
function qualifyResidualCollisions(occurrences) {
|
|
2957
|
+
const qualifiers = [(o) => o.service.name, (o) => o.op.httpMethod];
|
|
2958
|
+
for (const qualify of qualifiers) for (const sharers of collidingCandidates(occurrences)) {
|
|
2959
|
+
const movable = sharers.filter((o) => o.group.wrapperName !== o.group.name);
|
|
2960
|
+
for (const o of movable) o.group.wrapperName = `${qualify(o)}_${o.group.wrapperName ?? o.group.name}`;
|
|
2961
|
+
}
|
|
2962
|
+
separateRemainingByStructure(occurrences);
|
|
2963
|
+
}
|
|
2964
|
+
/**
|
|
2965
|
+
* Terminal round: separate whatever the operation attributes could not.
|
|
2966
|
+
*
|
|
2967
|
+
* No combination of operation attributes is unique by construction. Two
|
|
2968
|
+
* operations in one service can share a name, a method *and* differ only by
|
|
2969
|
+
* path — `normalizeOperationIdForNaming` strips the `[N]` suffix, so a NestJS
|
|
2970
|
+
* controller method bound to several routes derives one name for all of them.
|
|
2971
|
+
* The WorkOS spec already contains such a pair (`Authorization`'s
|
|
2972
|
+
* `listRoleAssignments` on two paths); it declares no parameter groups today,
|
|
2973
|
+
* so nothing is wrong in the SDKs, but the shape is present rather than
|
|
2974
|
+
* hypothetical. Adding `path` as a fourth qualifier would only move the
|
|
2975
|
+
* boundary again.
|
|
2976
|
+
*
|
|
2977
|
+
* So stop enumerating attributes and separate by the thing that actually
|
|
2978
|
+
* matters: a name still fusing distinct structures gets one wrapper per
|
|
2979
|
+
* distinct fingerprint. That makes "same `wrapperName` implies same structure"
|
|
2980
|
+
* hold unconditionally, which is the invariant every emitter relies on.
|
|
2981
|
+
*
|
|
2982
|
+
* Suffixes are keyed on the fingerprint, not on a per-occurrence counter, so
|
|
2983
|
+
* declarations that agree keep sharing a wrapper.
|
|
2984
|
+
*
|
|
2985
|
+
* Ordering is by first appearance, not by sorted fingerprint: the structure
|
|
2986
|
+
* declared first keeps the unsuffixed name. Sorting by fingerprint would be
|
|
2987
|
+
* equally deterministic but less stable in the way that matters — adding a
|
|
2988
|
+
* member to one operation changes its fingerprint and could swap which of two
|
|
2989
|
+
* published wrappers holds the bare name. Under first-appearance order only
|
|
2990
|
+
* reordering the operations themselves can shift a name, which is both rarer
|
|
2991
|
+
* and visible in the spec diff.
|
|
2992
|
+
*
|
|
2993
|
+
* Either way this is a no-op wherever an earlier round already sufficed —
|
|
2994
|
+
* including the WorkOS spec, which never reaches this round at all.
|
|
2995
|
+
*/
|
|
2996
|
+
function separateRemainingByStructure(occurrences) {
|
|
2997
|
+
const taken = new Set(occurrences.map((o) => o.group.wrapperName ?? o.group.name));
|
|
2998
|
+
for (const sharers of collidingCandidates(occurrences)) {
|
|
2999
|
+
const base = sharers[0].group.wrapperName ?? sharers[0].group.name;
|
|
3000
|
+
const nameFor = /* @__PURE__ */ new Map();
|
|
3001
|
+
let n = 2;
|
|
3002
|
+
for (const o of sharers) {
|
|
3003
|
+
if (nameFor.has(o.fingerprint)) continue;
|
|
3004
|
+
if (nameFor.size === 0) {
|
|
3005
|
+
nameFor.set(o.fingerprint, base);
|
|
3006
|
+
continue;
|
|
3007
|
+
}
|
|
3008
|
+
while (taken.has(`${base}_${n}`)) n++;
|
|
3009
|
+
const name = `${base}_${n}`;
|
|
3010
|
+
taken.add(name);
|
|
3011
|
+
nameFor.set(o.fingerprint, name);
|
|
3012
|
+
n++;
|
|
3013
|
+
}
|
|
3014
|
+
for (const o of sharers) o.group.wrapperName = nameFor.get(o.fingerprint);
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
/**
|
|
3018
|
+
* Candidate wrapper names shared by declarations that do not agree — the only
|
|
3019
|
+
* ones an escalation round may rename.
|
|
3020
|
+
*/
|
|
3021
|
+
function collidingCandidates(occurrences) {
|
|
3022
|
+
const byCandidate = /* @__PURE__ */ new Map();
|
|
3023
|
+
for (const o of occurrences) {
|
|
3024
|
+
const key = o.group.wrapperName ?? o.group.name;
|
|
3025
|
+
const list = byCandidate.get(key);
|
|
3026
|
+
if (list) list.push(o);
|
|
3027
|
+
else byCandidate.set(key, [o]);
|
|
3028
|
+
}
|
|
3029
|
+
return [...byCandidate.values()].filter((sharers) => sharers.length > 1 && new Set(sharers.map((o) => o.fingerprint)).size > 1);
|
|
3030
|
+
}
|
|
3031
|
+
/**
|
|
3032
|
+
* Make every group that shares a `wrapperName` describe literally the same
|
|
3033
|
+
* type.
|
|
3034
|
+
*
|
|
3035
|
+
* Deciding two declarations may share one wrapper is only half the job. The
|
|
3036
|
+
* fingerprint deliberately ignores nullability and member order, so sharers can
|
|
3037
|
+
* still disagree on both — and each emitter renders the wrapper from whichever
|
|
3038
|
+
* operation it happens to reach first while rendering call sites from their own
|
|
3039
|
+
* operation. Kotlin showed the consequence: `ParentResource.ById.id` emitted as
|
|
3040
|
+
* `String` from one operation, while the Java-friendly overload built from
|
|
3041
|
+
* another passed a `String?` into it, and the SDK stopped compiling.
|
|
3042
|
+
*
|
|
3043
|
+
* So normalize the two axes the fingerprint waived:
|
|
3044
|
+
* - a member the sharers disagree on settles as non-nullable. Widening to
|
|
3045
|
+
* nullable instead reads as the friendlier choice, but the nullable form
|
|
3046
|
+
* doesn't survive every emitter's group paths (Kotlin's query dispatch
|
|
3047
|
+
* builds a `Pair<String, String>` from the member, which a `String?` can't
|
|
3048
|
+
* satisfy), and non-nullable is what all sharers carried before group
|
|
3049
|
+
* member types were resolved at all — so it changes no published surface.
|
|
3050
|
+
* A member every sharer agrees is nullable stays nullable.
|
|
3051
|
+
*
|
|
3052
|
+
* The cost: an operation whose spec really does accept null for a member
|
|
3053
|
+
* (`PATCH` on a resource may clear `parent_resource_id`) can't express that
|
|
3054
|
+
* through the shared wrapper. It never could — the member used to be a flat
|
|
3055
|
+
* `string` everywhere — so nothing regresses, but the limitation is real and
|
|
3056
|
+
* removing it means letting that group's wrapper split per operation.
|
|
3057
|
+
* - member order follows the first sharer, so positional constructors and
|
|
3058
|
+
* their call sites agree.
|
|
3059
|
+
*/
|
|
3060
|
+
function reconcileSharedGroups(groups) {
|
|
3061
|
+
const byWrapper = /* @__PURE__ */ new Map();
|
|
3062
|
+
for (const group of groups) {
|
|
3063
|
+
const key = group.wrapperName ?? group.name;
|
|
3064
|
+
const list = byWrapper.get(key);
|
|
3065
|
+
if (list) list.push(group);
|
|
3066
|
+
else byWrapper.set(key, [group]);
|
|
3067
|
+
}
|
|
3068
|
+
for (const sharers of byWrapper.values()) {
|
|
3069
|
+
if (sharers.length < 2) continue;
|
|
3070
|
+
for (const variant of sharers[0].variants) {
|
|
3071
|
+
const peers = sharers.slice(1).map((g) => g.variants.find((v) => v.name === variant.name)).filter((v) => v !== void 0);
|
|
3072
|
+
if (peers.length === 0) continue;
|
|
3073
|
+
for (const member of variant.parameters) {
|
|
3074
|
+
const all = [member, ...peers.map((p) => p.parameters.find((q) => q.name === member.name)).filter(isParameter)];
|
|
3075
|
+
if (all.every((p) => p.type.kind === "nullable")) continue;
|
|
3076
|
+
for (const p of all) if (p.type.kind === "nullable") p.type = p.type.inner;
|
|
3077
|
+
}
|
|
3078
|
+
const canonical = variant.parameters.map((p) => p.name);
|
|
3079
|
+
for (const peer of peers) peer.parameters.sort((a, b) => {
|
|
3080
|
+
const ia = canonical.indexOf(a.name);
|
|
3081
|
+
const ib = canonical.indexOf(b.name);
|
|
3082
|
+
if (ia === -1 && ib === -1) return 0;
|
|
3083
|
+
if (ia === -1) return 1;
|
|
3084
|
+
if (ib === -1) return -1;
|
|
3085
|
+
return ia - ib;
|
|
3086
|
+
});
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
}
|
|
3090
|
+
function isParameter(p) {
|
|
3091
|
+
return p !== void 0;
|
|
3092
|
+
}
|
|
3093
|
+
/**
|
|
3094
|
+
* Structural fingerprint of a group: its variants, their members, and each
|
|
3095
|
+
* member's resolved shape. Two declarations with the same fingerprint can
|
|
3096
|
+
* safely share one generated wrapper type.
|
|
3097
|
+
*/
|
|
3098
|
+
function fingerprintGroup(group, modelMap, enumMap) {
|
|
3099
|
+
return group.variants.map((v) => {
|
|
3100
|
+
const members = [...v.parameters].sort((a, b) => a.name.localeCompare(b.name)).map((p) => `${p.name}:${fingerprintType(p.type, modelMap, enumMap, /* @__PURE__ */ new Set())}`);
|
|
3101
|
+
const optional = [...v.optionalParameters ?? []].sort();
|
|
3102
|
+
return `${v.name}(${members.join(",")})[${optional.join(",")}]`;
|
|
3103
|
+
}).join("|");
|
|
3104
|
+
}
|
|
3105
|
+
/**
|
|
3106
|
+
* Fingerprint a type by shape rather than by name.
|
|
3107
|
+
*
|
|
3108
|
+
* Names cannot be compared directly because the parser synthesizes a separate
|
|
3109
|
+
* name per declaration site for inline schemas: `create_user` and `update_user`
|
|
3110
|
+
* each get their own `PasswordHashType` enum, spelled `CreateUserPasswordHashType`
|
|
3111
|
+
* and `UpdateUserPasswordHashType`, though their values are identical. Comparing
|
|
3112
|
+
* names would report divergence and rename a wrapper for no reason.
|
|
3113
|
+
*
|
|
3114
|
+
* Nullability is also stripped: an update declaring a member nullable while
|
|
3115
|
+
* create declares it plain is not a reason to split the wrapper type.
|
|
3116
|
+
*/
|
|
3117
|
+
function fingerprintType(ref, modelMap, enumMap, visiting) {
|
|
3118
|
+
switch (ref.kind) {
|
|
3119
|
+
case "nullable": return fingerprintType(ref.inner, modelMap, enumMap, visiting);
|
|
3120
|
+
case "primitive": return `p:${ref.type}`;
|
|
3121
|
+
case "literal": return `l:${typeof ref.value}:${String(ref.value)}`;
|
|
3122
|
+
case "array": return `a<${fingerprintType(ref.items, modelMap, enumMap, visiting)}>`;
|
|
3123
|
+
case "map": return `m<${fingerprintType(ref.valueType, modelMap, enumMap, visiting)}>`;
|
|
3124
|
+
case "union": return `u<${ref.variants.map((v) => fingerprintType(v, modelMap, enumMap, visiting)).sort().join(",")}>`;
|
|
3125
|
+
case "enum": {
|
|
3126
|
+
const e = enumMap.get(ref.name);
|
|
3127
|
+
if (!e) return `e:${ref.name}`;
|
|
3128
|
+
return `e<${e.values.map((v) => `${typeof v.value}:${String(v.value)}`).sort().join(",")}>`;
|
|
3129
|
+
}
|
|
3130
|
+
case "model": {
|
|
3131
|
+
const m = modelMap.get(ref.name);
|
|
3132
|
+
if (!m) return `M:${ref.name}`;
|
|
3133
|
+
if (visiting.has(ref.name)) return `M:${ref.name}`;
|
|
3134
|
+
const next = new Set(visiting).add(ref.name);
|
|
3135
|
+
return `M<${[...m.fields].sort((a, b) => a.name.localeCompare(b.name)).map((f) => `${f.name}${f.required ? "!" : "?"}:${fingerprintType(f.type, modelMap, enumMap, next)}`).join(",")}>`;
|
|
3136
|
+
}
|
|
3137
|
+
default: return "unknown";
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
//#endregion
|
|
2814
3141
|
//#region src/parser/normalize-model-refs.ts
|
|
2815
3142
|
function validateModelRefs(spec) {
|
|
2816
3143
|
const knownNames = /* @__PURE__ */ new Set();
|
|
@@ -2840,7 +3167,7 @@ async function parseSpec(specPath, options) {
|
|
|
2840
3167
|
const version = spec.openapi ?? "";
|
|
2841
3168
|
if (!version.startsWith("3.")) throw new SpecParseError(`Unsupported OpenAPI version: ${version}. oagen requires OpenAPI 3.x`, `Update the spec to OpenAPI 3.0 or 3.1. If you are using Swagger 2.x, convert it first with \`npx swagger2openapi ${specPath}\`.`);
|
|
2842
3169
|
const { models, enums } = extractSchemas(spec.components?.schemas, { schemaNameTransform: options?.schemaNameTransform });
|
|
2843
|
-
const { services, inlineModels } = extractOperations(spec.paths, options?.operationIdTransform, spec.components?.schemas);
|
|
3170
|
+
const { services, inlineModels } = extractOperations(spec.paths, options?.operationIdTransform, spec.components?.schemas, spec.components?.parameters);
|
|
2844
3171
|
const responseNormalizedModels = mergeInlineResponseModels(models, inlineModels);
|
|
2845
3172
|
const fieldInlineModels = extractInlineModelsFromSchemas(spec.components?.schemas);
|
|
2846
3173
|
clearSchemaNameTransform();
|
|
@@ -2865,6 +3192,7 @@ async function parseSpec(specPath, options) {
|
|
|
2865
3192
|
auth,
|
|
2866
3193
|
sdk: defaultSdkBehavior()
|
|
2867
3194
|
};
|
|
3195
|
+
assignParameterGroupWrapperNames(result);
|
|
2868
3196
|
validateModelRefs(result);
|
|
2869
3197
|
return result;
|
|
2870
3198
|
}
|
|
@@ -3447,6 +3775,7 @@ function extractKotlinClassMembers(classBody, source) {
|
|
|
3447
3775
|
const children = classBody.namedChildren;
|
|
3448
3776
|
for (let i = 0; i < children.length; i++) {
|
|
3449
3777
|
const child = children[i];
|
|
3778
|
+
const declIndex = i;
|
|
3450
3779
|
let memberName = null;
|
|
3451
3780
|
if (child.type === "property_declaration") memberName = child.children.find((c) => c.type === "variable_declaration")?.children.find((c) => c.type === "simple_identifier")?.text ?? null;
|
|
3452
3781
|
else if (child.type === "function_declaration") memberName = child.children.find((c) => c.type === "simple_identifier")?.text ?? null;
|
|
@@ -3461,7 +3790,7 @@ function extractKotlinClassMembers(classBody, source) {
|
|
|
3461
3790
|
} else break;
|
|
3462
3791
|
}
|
|
3463
3792
|
let startIdx = child.startIndex;
|
|
3464
|
-
for (let k =
|
|
3793
|
+
for (let k = declIndex - 1; k >= 0; k--) {
|
|
3465
3794
|
const prev = children[k];
|
|
3466
3795
|
if (prev.type === "multiline_comment") {
|
|
3467
3796
|
if (source.slice(prev.startIndex, prev.endIndex).startsWith("/**")) startIdx = prev.startIndex;
|
|
@@ -3530,6 +3859,13 @@ const kotlinMergeAdapter = {
|
|
|
3530
3859
|
isManagedMember(member) {
|
|
3531
3860
|
return KOTLIN_MANAGED_ACCESSOR_DOC.test(member.text);
|
|
3532
3861
|
},
|
|
3862
|
+
importedNames(imp) {
|
|
3863
|
+
const path = imp.text.replace(/^import\s+/, "").trim();
|
|
3864
|
+
const alias = path.match(/\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/);
|
|
3865
|
+
if (alias) return [alias[1]];
|
|
3866
|
+
const last = path.split(".").pop();
|
|
3867
|
+
return last && last !== "*" ? [last] : [];
|
|
3868
|
+
},
|
|
3533
3869
|
shouldSkipDeepMerge(_symbolName, existingMemberKeys, newMembers) {
|
|
3534
3870
|
const newText = newMembers.map((m) => m.text).join("\n");
|
|
3535
3871
|
for (const match of newText.matchAll(/this\.(\w+)/g)) {
|
|
@@ -4412,6 +4748,116 @@ function declarationLineStart(content, declStartIndex) {
|
|
|
4412
4748
|
return prevNewline === -1 ? 0 : prevNewline + 1;
|
|
4413
4749
|
}
|
|
4414
4750
|
/**
|
|
4751
|
+
* Widen a member's byte range so deleting it removes whole lines.
|
|
4752
|
+
*
|
|
4753
|
+
* The raw range starts at the declaration (or its docstring), which sits after
|
|
4754
|
+
* the member's indentation — splicing it out verbatim leaves that indentation
|
|
4755
|
+
* glued to the following line. Extends backwards over the indentation and
|
|
4756
|
+
* forwards over the trailing newline, then absorbs the blank line the removal
|
|
4757
|
+
* would otherwise duplicate (or strand in front of the block's closing brace).
|
|
4758
|
+
*/
|
|
4759
|
+
function expandPruneRange(source, rawStart, rawEnd) {
|
|
4760
|
+
let start = rawStart;
|
|
4761
|
+
while (start > 0 && (source[start - 1] === " " || source[start - 1] === " ")) start--;
|
|
4762
|
+
let end = rawEnd;
|
|
4763
|
+
while (end < source.length && (source[end] === " " || source[end] === " ")) end++;
|
|
4764
|
+
end = skipLineBreak(source, end);
|
|
4765
|
+
if (precededByBlankLine(source, start)) {
|
|
4766
|
+
if (startsWithLineBreak(source, end)) end = skipLineBreak(source, end);
|
|
4767
|
+
else if (/^[ \t]*\}/.test(source.slice(end, end + 40))) start = backOverLineBreak(source, start);
|
|
4768
|
+
}
|
|
4769
|
+
return {
|
|
4770
|
+
start,
|
|
4771
|
+
end
|
|
4772
|
+
};
|
|
4773
|
+
}
|
|
4774
|
+
function startsWithLineBreak(source, idx) {
|
|
4775
|
+
return source[idx] === "\n" || source[idx] === "\r" && source[idx + 1] === "\n";
|
|
4776
|
+
}
|
|
4777
|
+
function skipLineBreak(source, idx) {
|
|
4778
|
+
let i = idx;
|
|
4779
|
+
if (source[i] === "\r") i++;
|
|
4780
|
+
return source[i] === "\n" ? i + 1 : i;
|
|
4781
|
+
}
|
|
4782
|
+
function backOverLineBreak(source, idx) {
|
|
4783
|
+
let i = idx;
|
|
4784
|
+
if (source[i - 1] === "\n") i--;
|
|
4785
|
+
if (source[i - 1] === "\r") i--;
|
|
4786
|
+
return i;
|
|
4787
|
+
}
|
|
4788
|
+
/** Whether the line ending immediately before `idx` (a line start) is empty. */
|
|
4789
|
+
function precededByBlankLine(source, idx) {
|
|
4790
|
+
if (idx === 0 || source[idx - 1] !== "\n") return false;
|
|
4791
|
+
const priorLineEnd = backOverLineBreak(source, idx);
|
|
4792
|
+
return priorLineEnd > 0 && source[priorLineEnd - 1] === "\n";
|
|
4793
|
+
}
|
|
4794
|
+
function referencesIdentifier(source, name) {
|
|
4795
|
+
return new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(source);
|
|
4796
|
+
}
|
|
4797
|
+
/**
|
|
4798
|
+
* Blank out comments and string text, preserving offsets and line structure.
|
|
4799
|
+
*
|
|
4800
|
+
* An identifier lookup over raw source can't tell a live reference from a name
|
|
4801
|
+
* that merely survives in prose. That distinction decides whether an import
|
|
4802
|
+
* stays: keeping one whose only remaining mention is a KDoc leaves an import of
|
|
4803
|
+
* a class the generator no longer emits, which doesn't compile — the very
|
|
4804
|
+
* failure the prune exists to prevent.
|
|
4805
|
+
*
|
|
4806
|
+
* Interpolated expressions are the exception: `"${Agents.NAME}"` is code that
|
|
4807
|
+
* sits inside a string literal, and masking it would drop an import the file
|
|
4808
|
+
* genuinely uses. String extents are masked around those holes, not over them.
|
|
4809
|
+
*
|
|
4810
|
+
* Grammar node names come from the adapter's `urlFingerprintConfig`, which
|
|
4811
|
+
* already inventories exactly these three categories per language. Comments are
|
|
4812
|
+
* matched structurally instead — every grammar we support names them `*comment`.
|
|
4813
|
+
*/
|
|
4814
|
+
function maskNonCode(tree, source, config) {
|
|
4815
|
+
const stringNodeTypes = new Set(config?.stringNodeTypes ?? []);
|
|
4816
|
+
const interpolationNodeTypes = new Set(config?.interpolationNodeTypes ?? []);
|
|
4817
|
+
const spans = [];
|
|
4818
|
+
const collectStringSpans = (node) => {
|
|
4819
|
+
const holes = [];
|
|
4820
|
+
const findHoles = (n) => {
|
|
4821
|
+
if (interpolationNodeTypes.has(n.type)) {
|
|
4822
|
+
holes.push([n.startIndex, n.endIndex]);
|
|
4823
|
+
return;
|
|
4824
|
+
}
|
|
4825
|
+
for (const child of n.children) findHoles(child);
|
|
4826
|
+
};
|
|
4827
|
+
findHoles(node);
|
|
4828
|
+
let cursor = node.startIndex;
|
|
4829
|
+
for (const [holeStart, holeEnd] of holes) {
|
|
4830
|
+
if (holeStart > cursor) spans.push([cursor, holeStart]);
|
|
4831
|
+
cursor = holeEnd;
|
|
4832
|
+
}
|
|
4833
|
+
if (cursor < node.endIndex) spans.push([cursor, node.endIndex]);
|
|
4834
|
+
};
|
|
4835
|
+
const walk = (node) => {
|
|
4836
|
+
if (node.type.includes("comment")) {
|
|
4837
|
+
spans.push([node.startIndex, node.endIndex]);
|
|
4838
|
+
return;
|
|
4839
|
+
}
|
|
4840
|
+
if (stringNodeTypes.has(node.type)) {
|
|
4841
|
+
collectStringSpans(node);
|
|
4842
|
+
return;
|
|
4843
|
+
}
|
|
4844
|
+
for (const child of node.children) walk(child);
|
|
4845
|
+
};
|
|
4846
|
+
walk(tree.rootNode);
|
|
4847
|
+
if (spans.length === 0) return source;
|
|
4848
|
+
let masked = "";
|
|
4849
|
+
let cursor = 0;
|
|
4850
|
+
for (const [spanStart, spanEnd] of spans) {
|
|
4851
|
+
if (spanStart < cursor) continue;
|
|
4852
|
+
masked += source.slice(cursor, spanStart) + source.slice(spanStart, spanEnd).replace(/[^\r\n]/g, " ");
|
|
4853
|
+
cursor = spanEnd;
|
|
4854
|
+
}
|
|
4855
|
+
return masked + source.slice(cursor);
|
|
4856
|
+
}
|
|
4857
|
+
function removeLines(source, lines) {
|
|
4858
|
+
return source.split("\n").filter((line) => !lines.has(line.trim())).join("\n");
|
|
4859
|
+
}
|
|
4860
|
+
/**
|
|
4415
4861
|
* Resolve a docstring range by searching for the nearest matching text before
|
|
4416
4862
|
* the declaration start. Falls back to parser-provided offsets when search fails.
|
|
4417
4863
|
*/
|
|
@@ -4626,6 +5072,7 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
|
|
|
4626
5072
|
let deepAdded = 0;
|
|
4627
5073
|
let deepPruned = 0;
|
|
4628
5074
|
const insertions = [];
|
|
5075
|
+
const prunedTexts = [];
|
|
4629
5076
|
if (adapter.extractMembers) {
|
|
4630
5077
|
const parser = await getParser(language);
|
|
4631
5078
|
const resultTree = safeParse(parser, result);
|
|
@@ -4654,10 +5101,9 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
|
|
|
4654
5101
|
if (pruneEdits.length > 0) {
|
|
4655
5102
|
pruneEdits.sort((a, b) => b.start - a.start);
|
|
4656
5103
|
for (const edit of pruneEdits) {
|
|
4657
|
-
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
result = result.slice(0, edit.start) + result.slice(end);
|
|
5104
|
+
const { start, end } = expandPruneRange(result, edit.start, edit.end);
|
|
5105
|
+
prunedTexts.push(result.slice(start, end));
|
|
5106
|
+
result = result.slice(0, start) + result.slice(end);
|
|
4661
5107
|
}
|
|
4662
5108
|
deepPruned = pruneEdits.length;
|
|
4663
5109
|
const reTree = safeParse(parser, result);
|
|
@@ -4722,6 +5168,21 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
|
|
|
4722
5168
|
result = lines.join("\n");
|
|
4723
5169
|
}
|
|
4724
5170
|
}
|
|
5171
|
+
if (prunedTexts.length > 0 && adapter.importedNames) {
|
|
5172
|
+
const prunedText = prunedTexts.join("\n");
|
|
5173
|
+
const codeOnly = maskNonCode(safeParse(await getParser(language), result), result, adapter.urlFingerprintConfig);
|
|
5174
|
+
const orphaned = /* @__PURE__ */ new Set();
|
|
5175
|
+
for (const imp of existingStatements.imports) {
|
|
5176
|
+
const names = adapter.importedNames(imp);
|
|
5177
|
+
if (names.length === 0) continue;
|
|
5178
|
+
if (!names.some((n) => referencesIdentifier(prunedText, n))) continue;
|
|
5179
|
+
const line = imp.text.trim();
|
|
5180
|
+
const withoutImport = removeLines(codeOnly, new Set([line]));
|
|
5181
|
+
if (names.some((n) => referencesIdentifier(withoutImport, n))) continue;
|
|
5182
|
+
orphaned.add(line);
|
|
5183
|
+
}
|
|
5184
|
+
if (orphaned.size > 0) result = removeLines(result, orphaned);
|
|
5185
|
+
}
|
|
4725
5186
|
let docstringUpdates = 0;
|
|
4726
5187
|
{
|
|
4727
5188
|
const parser = await getParser(language);
|