@kubb/adapter-oas 5.0.0-beta.60 → 5.0.0-beta.62

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubb/adapter-oas",
3
- "version": "5.0.0-beta.60",
3
+ "version": "5.0.0-beta.62",
4
4
  "description": "OpenAPI and Swagger adapter for Kubb. Parses and validates OAS 2.0/3.x specifications into a @kubb/ast RootNode for downstream code generation plugins.",
5
5
  "keywords": [
6
6
  "adapter",
@@ -46,8 +46,8 @@
46
46
  "api-ref-bundler": "0.5.1",
47
47
  "swagger2openapi": "^7.0.8",
48
48
  "yaml": "^2.9.0",
49
- "@kubb/ast": "5.0.0-beta.60",
50
- "@kubb/core": "5.0.0-beta.60"
49
+ "@kubb/ast": "5.0.0-beta.62",
50
+ "@kubb/core": "5.0.0-beta.62"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/json-schema": "^7.0.15",
package/src/dialect.ts CHANGED
@@ -3,6 +3,25 @@ import { isDiscriminator, isNullable, isReference } from './guards.ts'
3
3
  import { resolveRef } from './refs.ts'
4
4
  import type { SchemaObject } from './types.ts'
5
5
 
6
+ /**
7
+ * Derives a schema's `optional`/`nullish` flags from a parent's `required` value and the
8
+ * schema's own `nullable`. How "required" and "nullable" combine is OpenAPI-specific, so it
9
+ * lives in the dialect.
10
+ *
11
+ * - Non-required + non-nullable → `optional: true`.
12
+ * - Non-required + nullable → `nullish: true`.
13
+ * - Required → both flags cleared.
14
+ */
15
+ function optionality(schema: ast.SchemaNode, required: boolean): ast.SchemaNode {
16
+ const nullable = schema.nullable ?? false
17
+
18
+ return {
19
+ ...schema,
20
+ optional: !required && !nullable ? true : undefined,
21
+ nullish: !required && nullable ? true : undefined,
22
+ }
23
+ }
24
+
6
25
  /**
7
26
  * The OpenAPI / Swagger dialect, the default used by `@kubb/adapter-oas`.
8
27
  *
@@ -22,13 +41,16 @@ import type { SchemaObject } from './types.ts'
22
41
  * const parser = createSchemaParser(context, oasDialect) // explicit
23
42
  * ```
24
43
  */
25
- export const oasDialect = ast.defineSchemaDialect({
44
+ export const oasDialect = ast.defineDialect({
26
45
  name: 'oas',
27
- isNullable,
28
- isReference,
29
- isDiscriminator,
30
- isBinary: (schema: SchemaObject) => schema.type === 'string' && schema.contentMediaType === 'application/octet-stream',
31
- resolveRef,
46
+ schema: {
47
+ isNullable,
48
+ isReference,
49
+ isDiscriminator,
50
+ isBinary: (schema: SchemaObject) => schema.type === 'string' && schema.contentMediaType === 'application/octet-stream',
51
+ resolveRef,
52
+ optionality,
53
+ },
32
54
  })
33
55
 
34
56
  /**
package/src/parser.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { pascalCase } from '@internals/utils'
2
- import { childName, enumPropName, extractRefName } from '@kubb/ast/utils'
2
+ import { macroDiscriminatorEnum, macroEnumName, macroSimplifyUnion } from '@kubb/ast/macros'
3
+ import { childName, enumPropName, extractRefName, mergeAdjacentObjectsLazy } from '@kubb/ast/utils'
3
4
  import { ast } from '@kubb/core'
4
5
  import { DEFAULT_PARSER_OPTIONS, enumExtensionKeys, SCHEMA_REF_PREFIX, typeOptionMap } from './constants.ts'
5
6
  import { oasDialect, type OasDialect } from './dialect.ts'
@@ -19,6 +20,7 @@ import {
19
20
  getSchemaType,
20
21
  } from './resolvers.ts'
21
22
  import type { ContentType, Document, Operation, ReferenceObject, SchemaObject } from './types.ts'
23
+ import type { StatusCode } from '@kubb/ast'
22
24
 
23
25
  /**
24
26
  * Parser context holding the raw OpenAPI document and optional content-type override.
@@ -100,6 +102,39 @@ function normalizeArrayEnum(schema: SchemaObject): SchemaObject {
100
102
  return { ...schemaWithoutEnum, items: normalizedItems } as SchemaObject
101
103
  }
102
104
 
105
+ /**
106
+ * Builds a `null` scalar node carrying the schema's documentation. Shared by the `const: null`
107
+ * and the drf-spectacular `NullEnum` (`{ enum: [null] }`) branches, which render identically.
108
+ */
109
+ function createNullSchema(schema: SchemaObject, name: string | null | undefined): ast.SchemaNode {
110
+ return ast.factory.createSchema({
111
+ type: 'null',
112
+ primitive: 'null',
113
+ name,
114
+ title: schema.title,
115
+ description: schema.description,
116
+ deprecated: schema.deprecated,
117
+ format: schema.format,
118
+ })
119
+ }
120
+
121
+ /**
122
+ * Names the inline enums on a property's schema, and on each item when the property is a tuple, from
123
+ * the parent and property name. Wraps `macroEnumName` at the property construction site.
124
+ */
125
+ function nameEnums(node: ast.SchemaNode, options: { parentName: string | null | undefined; propName: string; enumSuffix: string }): ast.SchemaNode {
126
+ const macro = macroEnumName(options)
127
+ const named = ast.applyMacros(node, [macro], { depth: 'shallow' })
128
+ const tupleNode = ast.narrowSchema(named, 'tuple')
129
+ if (tupleNode?.items) {
130
+ const namedItems = tupleNode.items.map((item) => ast.applyMacros(item, [macro], { depth: 'shallow' }))
131
+ if (namedItems.some((item, i) => item !== tupleNode.items![i])) {
132
+ return { ...tupleNode, items: namedItems }
133
+ }
134
+ }
135
+ return named
136
+ }
137
+
103
138
  /**
104
139
  * Factory function that creates schema and operation converters for a given OpenAPI context.
105
140
  *
@@ -144,7 +179,7 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
144
179
  if (refPath && !resolvingRefs.has(refPath)) {
145
180
  if (!resolvedRefCache.has(refPath)) {
146
181
  try {
147
- const referenced = dialect.resolveRef<SchemaObject>(document, refPath)
182
+ const referenced = dialect.schema.resolveRef<SchemaObject>(document, refPath)
148
183
  if (referenced) {
149
184
  resolvingRefs.add(refPath)
150
185
  resolvedSchema = parseSchema({ schema: referenced }, rawOptions)
@@ -205,13 +240,13 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
205
240
  }> = []
206
241
  const allOfMembers: Array<ast.SchemaNode> = (schema.allOf as Array<SchemaObject | ReferenceObject>)
207
242
  .filter((item) => {
208
- if (!dialect.isReference(item) || !name) return true
209
- const deref = dialect.resolveRef<SchemaObject>(document, item.$ref)
210
- if (!deref || !dialect.isDiscriminator(deref)) return true
243
+ if (!dialect.schema.isReference(item) || !name) return true
244
+ const deref = dialect.schema.resolveRef<SchemaObject>(document, item.$ref)
245
+ if (!deref || !dialect.schema.isDiscriminator(deref)) return true
211
246
  const parentUnion = deref.oneOf ?? deref.anyOf
212
247
  if (!parentUnion) return true
213
248
  const childRef = `${SCHEMA_REF_PREFIX}${name}`
214
- const inOneOf = parentUnion.some((oneOfItem) => dialect.isReference(oneOfItem) && oneOfItem.$ref === childRef)
249
+ const inOneOf = parentUnion.some((oneOfItem) => dialect.schema.isReference(oneOfItem) && oneOfItem.$ref === childRef)
215
250
  const inMapping = Object.values(deref.discriminator.mapping ?? {}).some((v) => v === childRef)
216
251
  if (inOneOf || inMapping) {
217
252
  const discriminatorValue = findDiscriminator(deref.discriminator.mapping, childRef)
@@ -235,9 +270,9 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
235
270
 
236
271
  if (missingRequired.length) {
237
272
  const resolvedMembers = (schema.allOf as Array<SchemaObject | ReferenceObject>).flatMap((item) => {
238
- if (!dialect.isReference(item)) return [item as SchemaObject]
239
- const deref = dialect.resolveRef<SchemaObject>(document, item.$ref)
240
- return deref && !dialect.isReference(deref) ? [deref] : []
273
+ if (!dialect.schema.isReference(item)) return [item as SchemaObject]
274
+ const deref = dialect.schema.resolveRef<SchemaObject>(document, item.$ref)
275
+ return deref && !dialect.schema.isReference(deref) ? [deref] : []
241
276
  })
242
277
 
243
278
  for (const key of missingRequired) {
@@ -276,7 +311,7 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
276
311
 
277
312
  return ast.factory.createSchema({
278
313
  type: 'intersection',
279
- members: [...ast.mergeAdjacentObjectsLazy(allOfMembers.slice(0, syntheticStart)), ...ast.mergeAdjacentObjectsLazy(allOfMembers.slice(syntheticStart))],
314
+ members: [...mergeAdjacentObjectsLazy(allOfMembers.slice(0, syntheticStart)), ...mergeAdjacentObjectsLazy(allOfMembers.slice(syntheticStart))],
280
315
  ...buildSchemaNode(schema, name, nullable, defaultValue),
281
316
  })
282
317
  }
@@ -304,10 +339,10 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
304
339
  const strategy: 'one' | 'any' = schema.oneOf ? 'one' : 'any'
305
340
  const unionBase = {
306
341
  ...buildSchemaNode(schema, name, nullable, defaultValue),
307
- discriminatorPropertyName: dialect.isDiscriminator(schema) ? schema.discriminator.propertyName : undefined,
342
+ discriminatorPropertyName: dialect.schema.isDiscriminator(schema) ? schema.discriminator.propertyName : undefined,
308
343
  strategy,
309
344
  }
310
- const discriminator = dialect.isDiscriminator(schema) ? schema.discriminator : undefined
345
+ const discriminator = dialect.schema.isDiscriminator(schema) ? schema.discriminator : undefined
311
346
  const sharedPropertiesNode = schema.properties
312
347
  ? (() => {
313
348
  const { oneOf: _oneOf, anyOf: _anyOf, ...schemaWithoutUnion } = schema
@@ -320,7 +355,7 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
320
355
 
321
356
  if (sharedPropertiesNode || discriminator?.mapping) {
322
357
  const members = unionMembers.map((s) => {
323
- const ref = dialect.isReference(s) ? s.$ref : undefined
358
+ const ref = dialect.schema.isReference(s) ? s.$ref : undefined
324
359
  const discriminatorValue = findDiscriminator(discriminator?.mapping, ref)
325
360
  const memberNode = parseSchema({ schema: s as SchemaObject, name }, rawOptions)
326
361
 
@@ -330,10 +365,8 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
330
365
 
331
366
  const narrowedDiscriminatorNode = sharedPropertiesNode
332
367
  ? pickDiscriminatorPropertyNode(
333
- ast.setDiscriminatorEnum({
334
- node: sharedPropertiesNode,
335
- propertyName: discriminator.propertyName,
336
- values: [discriminatorValue],
368
+ ast.applyMacros(sharedPropertiesNode, [macroDiscriminatorEnum({ propertyName: discriminator.propertyName, values: [discriminatorValue] })], {
369
+ depth: 'shallow',
337
370
  }),
338
371
  discriminator.propertyName,
339
372
  )
@@ -369,11 +402,13 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
369
402
  })
370
403
  }
371
404
 
372
- return ast.factory.createSchema({
405
+ const unionNode = ast.factory.createSchema({
373
406
  type: 'union',
374
407
  ...unionBase,
375
- members: ast.simplifyUnion(unionMembers.map((s) => parseSchema({ schema: s as SchemaObject, name }, rawOptions))),
408
+ members: unionMembers.map((s) => parseSchema({ schema: s as SchemaObject, name }, rawOptions)),
376
409
  })
410
+
411
+ return ast.applyMacros(unionNode, [macroSimplifyUnion], { depth: 'shallow' })
377
412
  }
378
413
 
379
414
  /**
@@ -383,15 +418,7 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
383
418
  const constValue = schema.const
384
419
 
385
420
  if (constValue === null) {
386
- return ast.factory.createSchema({
387
- type: 'null',
388
- primitive: 'null',
389
- name,
390
- title: schema.title,
391
- description: schema.description,
392
- deprecated: schema.deprecated,
393
- format: schema.format,
394
- })
421
+ return createNullSchema(schema, name)
395
422
  }
396
423
 
397
424
  const constPrimitive = getPrimitiveType(typeof constValue === 'number' ? 'number' : typeof constValue === 'boolean' ? 'boolean' : 'string')
@@ -510,15 +537,7 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
510
537
  // render as `never` (plugin-ts) / invalid `z.enum([])` (plugin-zod). Mirror the `const: null`
511
538
  // branch so it renders as a clean `null` (not `z.null().nullable()`).
512
539
  if (nullInEnum && filteredValues.length === 0) {
513
- return ast.factory.createSchema({
514
- type: 'null',
515
- primitive: 'null',
516
- name,
517
- title: schema.title,
518
- description: schema.description,
519
- deprecated: schema.deprecated,
520
- format: schema.format,
521
- })
540
+ return createNullSchema(schema, name)
522
541
  }
523
542
 
524
543
  const enumNullable = nullable || nullInEnum || undefined
@@ -581,30 +600,23 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
581
600
  ? Object.entries(schema.properties).map(([propName, propSchema]) => {
582
601
  const required = Array.isArray(schema.required) ? schema.required.includes(propName) : !!schema.required
583
602
  const resolvedPropSchema = propSchema as SchemaObject
584
- const propNullable = dialect.isNullable(resolvedPropSchema)
603
+ const propNullable = dialect.schema.isNullable(resolvedPropSchema)
585
604
 
586
605
  const resolvedChildName = childName(name, propName)
587
606
  const propNode = parseSchema({ schema: resolvedPropSchema, name: resolvedChildName }, rawOptions)
588
- const schemaNode = (() => {
589
- const node = ast.setEnumName(propNode, name, propName, options.enumSuffix)
590
- const tupleNode = ast.narrowSchema(node, 'tuple')
591
- if (tupleNode?.items) {
592
- const namedItems = tupleNode.items.map((item) => ast.setEnumName(item, name, propName, options.enumSuffix))
593
- if (namedItems.some((item, i) => item !== tupleNode.items![i])) {
594
- return { ...tupleNode, items: namedItems }
595
- }
596
- }
597
- return node
598
- })()
599
-
600
- return ast.factory.createProperty({
601
- name: propName,
602
- schema: {
603
- ...schemaNode,
604
- nullable: schemaNode.type === 'null' ? undefined : propNullable || undefined,
607
+ const schemaNode = nameEnums(propNode, { parentName: name, propName, enumSuffix: options.enumSuffix })
608
+
609
+ return ast.factory.createProperty(
610
+ {
611
+ name: propName,
612
+ schema: {
613
+ ...schemaNode,
614
+ nullable: schemaNode.type === 'null' ? undefined : propNullable || undefined,
615
+ },
616
+ required,
605
617
  },
606
- required,
607
- })
618
+ dialect,
619
+ )
608
620
  })
609
621
  : []
610
622
 
@@ -645,16 +657,11 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
645
657
  ...buildSchemaNode(schema, name, nullable, defaultValue),
646
658
  })
647
659
 
648
- if (dialect.isDiscriminator(schema) && schema.discriminator.mapping) {
660
+ if (dialect.schema.isDiscriminator(schema) && schema.discriminator.mapping) {
649
661
  const discPropName = schema.discriminator.propertyName
650
662
  const values = Object.keys(schema.discriminator.mapping)
651
663
  const enumName = name ? enumPropName(name, discPropName, options.enumSuffix) : undefined
652
- return ast.setDiscriminatorEnum({
653
- node: objectNode,
654
- propertyName: discPropName,
655
- values,
656
- enumName,
657
- })
664
+ return ast.applyMacros(objectNode, [macroDiscriminatorEnum({ propertyName: discPropName, values, enumName })], { depth: 'shallow' })
658
665
  }
659
666
 
660
667
  return objectNode
@@ -792,14 +799,14 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
792
799
  * match/convert/fall-through contract.
793
800
  */
794
801
  const schemaRules: Array<SchemaRule> = [
795
- { name: 'ref', match: ({ schema }) => dialect.isReference(schema), convert: convertRef },
802
+ { name: 'ref', match: ({ schema }) => dialect.schema.isReference(schema), convert: convertRef },
796
803
  { name: 'allOf', match: ({ schema }) => !!schema.allOf?.length, convert: convertAllOf },
797
804
  { name: 'union', match: ({ schema }) => !!(schema.oneOf?.length || schema.anyOf?.length), convert: convertUnion },
798
805
  { name: 'const', match: ({ schema }) => 'const' in schema && schema.const !== undefined, convert: convertConst },
799
806
  { name: 'format', match: ({ schema }) => !!schema.format, convert: convertFormat },
800
807
  {
801
808
  name: 'blob',
802
- match: ({ schema }) => dialect.isBinary(schema),
809
+ match: ({ schema }) => dialect.schema.isBinary(schema),
803
810
  convert: convertBlob,
804
811
  },
805
812
  { name: 'multi-type', match: ({ schema }) => Array.isArray(schema.type) && schema.type.length > 1, convert: convertMultiType },
@@ -845,7 +852,7 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
845
852
  return parseSchema({ schema: flattenedSchema, name }, rawOptions)
846
853
  }
847
854
 
848
- const nullable = dialect.isNullable(schema) || undefined
855
+ const nullable = dialect.schema.isNullable(schema) || undefined
849
856
  const defaultValue = schema.default === null && nullable ? undefined : schema.default
850
857
  const type = Array.isArray(schema.type) ? schema.type[0] : schema.type
851
858
 
@@ -887,15 +894,18 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
887
894
  ? parseSchema({ schema: param['schema'] as SchemaObject, name: schemaName }, options)
888
895
  : ast.factory.createSchema({ type: typeOptionMap.get(options.unknownType)! })
889
896
 
890
- return ast.factory.createParameter({
891
- name: paramName,
892
- in: param['in'] as ast.ParameterLocation,
893
- schema: {
894
- ...schema,
895
- description: (param['description'] as string | undefined) ?? schema.description,
897
+ return ast.factory.createParameter(
898
+ {
899
+ name: paramName,
900
+ in: param['in'] as ast.ParameterLocation,
901
+ schema: {
902
+ ...schema,
903
+ description: (param['description'] as string | undefined) ?? schema.description,
904
+ },
905
+ required,
896
906
  },
897
- required,
898
- })
907
+ dialect,
908
+ )
899
909
  }
900
910
 
901
911
  /**
@@ -943,7 +953,7 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
943
953
  const keys: Array<string> = []
944
954
  for (const key in schema.properties) {
945
955
  const prop = schema.properties[key]
946
- if (prop && !dialect.isReference(prop) && (prop as Record<string, unknown>)[flag]) {
956
+ if (prop && !dialect.schema.isReference(prop) && (prop as Record<string, unknown>)[flag]) {
947
957
  keys.push(key)
948
958
  }
949
959
  }
@@ -972,11 +982,11 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
972
982
  const schema = getRequestSchema(document, operation, { contentType: ct })
973
983
  if (!schema) return []
974
984
  return [
975
- {
985
+ ast.factory.createContent({
976
986
  contentType: ct,
977
- schema: ast.syncOptionality(parseSchema({ schema, name: requestBodyName }, options), requestBodyMeta.required),
987
+ schema: dialect.schema.optionality(parseSchema({ schema, name: requestBodyName }, options), requestBodyMeta.required),
978
988
  keysToOmit: collectPropertyKeysByFlag(schema, 'readOnly'),
979
- },
989
+ }),
980
990
  ]
981
991
  })
982
992
 
@@ -1010,23 +1020,28 @@ export function createSchemaParser(ctx: OasParserContext, dialect: OasDialect =
1010
1020
  // Build one entry per declared response content type so plugins can union the variants.
1011
1021
  // When a global contentType is configured, restrict to that single type (mirrors requestBody).
1012
1022
  const responseContentTypes = ctx.contentType ? [ctx.contentType] : getResponseBodyContentTypes(document, operation, statusCode)
1013
- const content = responseContentTypes.map((contentType) => ({ contentType, ...parseEntrySchema(contentType) }))
1023
+ const content = responseContentTypes.map((contentType) => ast.factory.createContent({ contentType, ...parseEntrySchema(contentType) }))
1014
1024
 
1015
1025
  // Body-less responses keep a single fallback entry so the response still resolves to a
1016
1026
  // (void/any) schema, matching how `requestBody` only carries schemas inside `content`.
1017
1027
  if (content.length === 0) {
1018
- content.push({ contentType: getRequestContentType({ document, operation }) || 'application/json', ...parseEntrySchema(ctx.contentType) })
1028
+ content.push(
1029
+ ast.factory.createContent({
1030
+ contentType: getRequestContentType({ document, operation }) || 'application/json',
1031
+ ...parseEntrySchema(ctx.contentType),
1032
+ }),
1033
+ )
1019
1034
  }
1020
1035
 
1021
1036
  return ast.factory.createResponse({
1022
- statusCode: statusCode as ast.StatusCode,
1037
+ statusCode: statusCode as StatusCode,
1023
1038
  description,
1024
1039
  content,
1025
1040
  })
1026
1041
  })
1027
1042
 
1028
1043
  const pathItem = document.paths?.[operation.path]
1029
- const pathItemDoc = pathItem && !dialect.isReference(pathItem) ? (pathItem as { summary?: unknown; description?: unknown }) : undefined
1044
+ const pathItemDoc = pathItem && !dialect.schema.isReference(pathItem) ? (pathItem as { summary?: unknown; description?: unknown }) : undefined
1030
1045
  const pickDoc = (key: 'summary' | 'description'): string | undefined => {
1031
1046
  const own = operation.schema[key]
1032
1047
  if (typeof own === 'string') return own
package/src/stream.ts CHANGED
@@ -17,52 +17,6 @@ export type PreScanResult = {
17
17
  dedupePlan: ast.DedupePlan | null
18
18
  }
19
19
 
20
- /**
21
- * Builds the deduplication plan from the already-parsed top-level schema nodes plus a
22
- * single extra parse pass over operations (so duplicates in request/response bodies are seen).
23
- *
24
- * Only enums and objects are candidates, and object shapes that reference a circular schema are
25
- * rejected to avoid hoisting recursive structures. Inline shapes reuse their context-derived
26
- * name (collision-resolved against existing component names). Shapes without a name stay inline.
27
- */
28
- function createDedupePlan({
29
- schemaNodes,
30
- operationNodes,
31
- schemaNames,
32
- circularNames,
33
- }: {
34
- schemaNodes: Array<ast.SchemaNode>
35
- operationNodes: Array<ast.OperationNode>
36
- schemaNames: Array<string>
37
- circularNames: Array<string>
38
- }): ast.DedupePlan {
39
- const circularSchemas = new Set(circularNames)
40
- const usedNames = new Set(schemaNames)
41
-
42
- return ast.buildDedupePlan([...schemaNodes, ...operationNodes], {
43
- isCandidate: (node) => {
44
- if (node.type === 'enum') return true
45
- if (node.type !== 'object') return false
46
- // Skip object shapes that are part of a circular chain, hoisting them would break the cycle.
47
- if (node.name && circularSchemas.has(node.name)) return false
48
- return !containsCircularRef(node, { circularSchemas })
49
- },
50
- nameFor: (node) => {
51
- const base = node.name
52
- if (!base) return null
53
-
54
- let name = base
55
- let counter = 2
56
- while (usedNames.has(name)) {
57
- name = `${base}${counter++}`
58
- }
59
- usedNames.add(name)
60
- return name
61
- },
62
- refFor: (name) => `${SCHEMA_REF_PREFIX}${name}`,
63
- })
64
- }
65
-
66
20
  /**
67
21
  * Reads the server URL from the document's `servers` array at `serverIndex`,
68
22
  * interpolating any `serverVariables` into the URL template.
@@ -163,7 +117,34 @@ export function preScan({
163
117
  if (operationNode) operationNodes.push(operationNode)
164
118
  }
165
119
 
166
- dedupePlan = createDedupePlan({ schemaNodes: allNodes, operationNodes, schemaNames: Object.keys(schemas), circularNames })
120
+ // Only enums and objects are candidates, and object shapes that reference a circular schema are
121
+ // rejected to avoid hoisting recursive structures. Inline shapes reuse their context-derived
122
+ // name (collision-resolved against existing component names). Shapes without a name stay inline.
123
+ const circularSchemas = new Set(circularNames)
124
+ const usedNames = new Set(Object.keys(schemas))
125
+
126
+ dedupePlan = ast.buildDedupePlan([...allNodes, ...operationNodes], {
127
+ isCandidate: (node) => {
128
+ if (node.type === 'enum') return true
129
+ if (node.type !== 'object') return false
130
+ // Skip object shapes that are part of a circular chain, hoisting them would break the cycle.
131
+ if (node.name && circularSchemas.has(node.name)) return false
132
+ return !containsCircularRef(node, { circularSchemas })
133
+ },
134
+ nameFor: (node) => {
135
+ const base = node.name
136
+ if (!base) return null
137
+
138
+ let name = base
139
+ let counter = 2
140
+ while (usedNames.has(name)) {
141
+ name = `${base}${counter++}`
142
+ }
143
+ usedNames.add(name)
144
+ return name
145
+ },
146
+ refFor: (name) => `${SCHEMA_REF_PREFIX}${name}`,
147
+ })
167
148
 
168
149
  for (const definition of dedupePlan.hoisted) {
169
150
  if (definition.type === 'enum' && definition.name) enumNames.push(definition.name)