@workos/oagen 0.29.1 → 0.29.3

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.
@@ -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 = /* @__PURE__ */ new Map();
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 fieldSchema = variantFieldSchemas.get(pName);
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 allParams = [...pathLevelParams, ...op.parameters ?? []];
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 allIRParams = [
2335
+ const queryParamGroups = extractParameterGroups(op, [
2312
2336
  ...pathParams,
2313
2337
  ...queryParams,
2314
2338
  ...headerParams,
2315
2339
  ...cookieParams
2316
- ];
2317
- const opLabel = op.operationId ?? `${method.toUpperCase()} ${path}`;
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
  }
@@ -8165,7 +8493,10 @@ function apiSurfaceToSnapshot(surface) {
8165
8493
  }
8166
8494
  return {
8167
8495
  schemaVersion: "1",
8168
- source: { extractedAt: surface.extractedAt },
8496
+ source: {
8497
+ extractedAt: surface.extractedAt,
8498
+ language
8499
+ },
8169
8500
  policies: getDefaultPolicy(language),
8170
8501
  symbols
8171
8502
  };
@@ -8395,10 +8726,138 @@ function severityMeetsThreshold(severity, threshold) {
8395
8726
  //#endregion
8396
8727
  //#region src/compat/classify.ts
8397
8728
  /**
8729
+ * Normalize a type annotation string to a canonical form before equality
8730
+ * comparison. Emitters evolve their annotation style across releases (e.g.
8731
+ * Python's PEP 604 `X | None` replacing `Optional[X]`, PEP 585 lowercase
8732
+ * generics, or dropping quotes from forward refs); such changes are cosmetic
8733
+ * and must not register as breaking type changes. Only Python needs this
8734
+ * today — other languages encode optionality with a single stable spelling
8735
+ * (`*Foo`, `Option<T>`, `T?`, `T.nilable(Foo)`) so they use exact comparison.
8736
+ * String-literal *values* inside `Literal[...]` are preserved verbatim so a
8737
+ * genuine change to a wire value stays visible. When `language` is absent
8738
+ * (older snapshots) no normalization is applied, preserving prior behaviour.
8739
+ */
8740
+ function normalizeType(type, language) {
8741
+ if (!type) return type;
8742
+ if (language !== "python") return type;
8743
+ return canonicalizePythonType(type);
8744
+ }
8745
+ /** PEP 585 capitalized builtins that map to their lowercase spellings, so
8746
+ * `List[str]` and `list[str]` compare equal. */
8747
+ const PEP585_BUILTINS = {
8748
+ List: "list",
8749
+ Dict: "dict",
8750
+ Tuple: "tuple",
8751
+ Set: "set",
8752
+ FrozenSet: "frozenset",
8753
+ Type: "type"
8754
+ };
8755
+ /** Recursively canonicalize a Python annotation. Structural forms are
8756
+ * normalized (`Optional[X]`→`X | None`, `Union[...]`→` | ` joins, PEP 585
8757
+ * builtins, quoted forward refs, parenthesized unions), but `Literal[...]`
8758
+ * arguments are preserved verbatim — their quotes and inner whitespace are
8759
+ * part of the wire value, not cosmetic. */
8760
+ function canonicalizePythonType(s) {
8761
+ const t = s.trim();
8762
+ if (t.length === 0) return t;
8763
+ if (t.startsWith("(") && t.endsWith(")")) return canonicalizePythonType(t.slice(1, -1));
8764
+ if (t.startsWith("Literal[") && t.endsWith("]")) return `Literal[${splitTopLevel(t.slice(8, -1), ",").map((p) => p.trim()).filter((p) => p.length > 0).map(canonicalizeLiteralArg).join(", ")}]`;
8765
+ if (t.startsWith("Optional[") && t.endsWith("]")) return `${canonicalizePythonType(t.slice(9, -1))} | None`;
8766
+ if (t.startsWith("Union[") && t.endsWith("]")) return splitTopLevel(t.slice(6, -1), ",").map((p) => p.trim()).filter((p) => p.length > 0).map(canonicalizePythonType).join(" | ");
8767
+ if (t.includes("|")) {
8768
+ const arms = splitTopLevel(t, "|").map((p) => p.trim()).filter((p) => p.length > 0);
8769
+ if (arms.length > 1) return arms.map(canonicalizePythonType).join(" | ");
8770
+ }
8771
+ const open = t.indexOf("[");
8772
+ if (open !== -1 && t.endsWith("]")) {
8773
+ const outer = t.slice(0, open).trim();
8774
+ const inner = t.slice(open + 1, -1);
8775
+ return `${PEP585_BUILTINS[outer] ?? outer}[${splitTopLevel(inner, ",").map((p) => p.trim()).filter((p) => p.length > 0).map(canonicalizePythonType).join(", ")}]`;
8776
+ }
8777
+ if (t.startsWith("\"") && t.endsWith("\"") && t.length >= 2 || t.startsWith("'") && t.endsWith("'") && t.length >= 2) return t.slice(1, -1);
8778
+ return PEP585_BUILTINS[t] ?? t;
8779
+ }
8780
+ /** Canonicalize one `Literal[...]` argument so semantically identical string
8781
+ * values compare equal regardless of quote style or escape form. The value
8782
+ * is decoded (backslash escapes processed) then re-encoded in a canonical
8783
+ * double-quoted form, so `Literal['say "hi"']` and `Literal["say \\"hi\\""]`
8784
+ * both canonicalize to `"say \"hi\""`. Bare (unquoted) arguments — e.g. an
8785
+ * enum name or a number — are returned unchanged. */
8786
+ function canonicalizeLiteralArg(arg) {
8787
+ const a = arg.trim();
8788
+ if (a.length < 2) return a;
8789
+ let quote = null;
8790
+ if (a.startsWith("\"") && a.endsWith("\"")) quote = "\"";
8791
+ else if (a.startsWith("'") && a.endsWith("'")) quote = "'";
8792
+ if (!quote) return a;
8793
+ return `"${encodeDoubleQuoted(decodeStringLiteral(a.slice(1, -1)))}"`;
8794
+ }
8795
+ /** Decode the backslash escapes inside a Python string literal's content.
8796
+ * Only the delimiter-relevant escapes (`\\`, `\'`, `\"`) are processed;
8797
+ * other sequences (`\n`, `\t`, `\u…`) are kept verbatim since they're
8798
+ * written identically regardless of quote style and so compare equal already. */
8799
+ function decodeStringLiteral(content) {
8800
+ let out = "";
8801
+ for (let i = 0; i < content.length; i++) {
8802
+ const c = content[i];
8803
+ if (c === "\\" && i + 1 < content.length) {
8804
+ const next = content[i + 1];
8805
+ if (next === "\\" || next === "\"" || next === "'") {
8806
+ out += next;
8807
+ i++;
8808
+ continue;
8809
+ }
8810
+ out += c + next;
8811
+ i++;
8812
+ continue;
8813
+ }
8814
+ out += c;
8815
+ }
8816
+ return out;
8817
+ }
8818
+ /** Re-encode a decoded value as a canonical double-quoted Python string. */
8819
+ function encodeDoubleQuoted(value) {
8820
+ return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
8821
+ }
8822
+ /** Split on a separator that sits at bracket-depth 0 and outside string
8823
+ * literals, so commas/pipes inside `Literal["a,b"]` or `"x|y"` are not split.
8824
+ * An escaped matching quote (`\"` inside a double-quoted string) does not
8825
+ * close the string: only a quote preceded by an even number of backslashes
8826
+ * (zero counts) is a real closing delimiter. */
8827
+ function splitTopLevel(s, sep) {
8828
+ const parts = [];
8829
+ let depth = 0;
8830
+ let inStr = null;
8831
+ let start = 0;
8832
+ for (let i = 0; i < s.length; i++) {
8833
+ const c = s[i];
8834
+ if (inStr) {
8835
+ if (c === inStr) {
8836
+ let precedingBackslashes = 0;
8837
+ for (let j = i - 1; j >= 0 && s[j] === "\\"; j--) precedingBackslashes++;
8838
+ if (precedingBackslashes % 2 === 0) inStr = null;
8839
+ }
8840
+ continue;
8841
+ }
8842
+ if (c === "\"" || c === "'") {
8843
+ inStr = c;
8844
+ continue;
8845
+ }
8846
+ if (c === "[") depth++;
8847
+ else if (c === "]") depth--;
8848
+ else if (c === sep && depth === 0) {
8849
+ parts.push(s.slice(start, i));
8850
+ start = i + 1;
8851
+ }
8852
+ }
8853
+ parts.push(s.slice(start));
8854
+ return parts;
8855
+ }
8856
+ /**
8398
8857
  * Classify changes between a baseline and candidate symbol.
8399
8858
  * Returns one or more classified changes for the diff.
8400
8859
  */
8401
- function classifySymbolChanges(baseline, candidate, policy) {
8860
+ function classifySymbolChanges(baseline, candidate, policy, language) {
8402
8861
  const changes = [];
8403
8862
  const specRef = baseline.schemaName ?? candidate?.schemaName;
8404
8863
  if (!candidate) {
@@ -8422,8 +8881,8 @@ function classifySymbolChanges(baseline, candidate, policy) {
8422
8881
  policy,
8423
8882
  specRef
8424
8883
  }));
8425
- if (baseline.parameters && candidate.parameters) changes.push(...classifyParameterChanges(baseline, candidate, policy, specRef));
8426
- if (baseline.returns && candidate.returns && baseline.returns.name !== candidate.returns.name) changes.push(makeChange({
8884
+ if (baseline.parameters && candidate.parameters) changes.push(...classifyParameterChanges(baseline, candidate, policy, specRef, language));
8885
+ if (baseline.returns && candidate.returns && normalizeType(baseline.returns.name, language) !== normalizeType(candidate.returns.name, language)) changes.push(makeChange({
8427
8886
  category: "return_type_changed",
8428
8887
  symbol: baseline.fqName,
8429
8888
  old: { returnType: baseline.returns.name },
@@ -8441,7 +8900,7 @@ function classifySymbolChanges(baseline, candidate, policy) {
8441
8900
  policy,
8442
8901
  specRef
8443
8902
  }));
8444
- if (baseline.typeRef && candidate.typeRef && baseline.typeRef.name !== candidate.typeRef.name) changes.push(makeChange({
8903
+ if (baseline.typeRef && candidate.typeRef && normalizeType(baseline.typeRef.name, language) !== normalizeType(candidate.typeRef.name, language)) changes.push(makeChange({
8445
8904
  category: "field_type_changed",
8446
8905
  symbol: baseline.fqName,
8447
8906
  old: { type: baseline.typeRef.name },
@@ -8463,7 +8922,7 @@ function classifySymbolChanges(baseline, candidate, policy) {
8463
8922
  /**
8464
8923
  * Classify parameter-level changes between two symbol versions.
8465
8924
  */
8466
- function classifyParameterChanges(baseline, candidate, policy, specRef) {
8925
+ function classifyParameterChanges(baseline, candidate, policy, specRef, language) {
8467
8926
  const changes = [];
8468
8927
  const baseParams = baseline.parameters ?? [];
8469
8928
  const candParams = candidate.parameters ?? [];
@@ -8512,7 +8971,7 @@ function classifyParameterChanges(baseline, candidate, policy, specRef) {
8512
8971
  policy,
8513
8972
  specRef
8514
8973
  }));
8515
- if (baseParam.type.name !== candParam.type.name) changes.push(makeChange({
8974
+ if (normalizeType(baseParam.type.name, language) !== normalizeType(candParam.type.name, language)) changes.push(makeChange({
8516
8975
  category: "parameter_type_narrowed",
8517
8976
  symbol: baseline.fqName,
8518
8977
  old: {
@@ -8869,7 +9328,7 @@ function diffSnapshots(baseline, candidate, policy) {
8869
9328
  for (const baseSym of baseline.symbols) {
8870
9329
  if (isServiceWrapperConstructor(baseSym, serviceAccessors)) continue;
8871
9330
  const candSym = candById.get(baseSym.id) ?? candByFqName.get(baseSym.fqName);
8872
- changes.push(...classifySymbolChanges(baseSym, candSym, effectivePolicy));
9331
+ changes.push(...classifySymbolChanges(baseSym, candSym, effectivePolicy, baseline.source.language));
8873
9332
  }
8874
9333
  for (const candSym of candidate.symbols) if (!baseByFqName.has(candSym.fqName)) {
8875
9334
  if (isServiceWrapperConstructor(candSym, serviceAccessors)) continue;