@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.
@@ -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
  }
@@ -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 = i - 1; k >= 0; 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
- let end = edit.end;
4658
- while (end < result.length && (result[end] === " " || result[end] === " ")) end++;
4659
- if (result[end] === "\n") end++;
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);