@kubb/ast 5.0.0-beta.2 → 5.0.0-beta.20

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,12 +1,11 @@
1
1
  {
2
2
  "name": "@kubb/ast",
3
- "version": "5.0.0-beta.2",
4
- "description": "Spec-agnostic AST layer for Kubb. Defines nodes, visitor pattern, and factory functions used across codegen plugins.",
3
+ "version": "5.0.0-beta.20",
4
+ "description": "Spec-agnostic AST layer for Kubb. Defines the node tree, visitor pattern, factory functions, and type guards used across all code generation plugins.",
5
5
  "keywords": [
6
6
  "ast",
7
7
  "codegen",
8
8
  "kubb",
9
- "openapi",
10
9
  "typescript"
11
10
  ],
12
11
  "license": "MIT",
@@ -40,7 +39,7 @@
40
39
  "registry": "https://registry.npmjs.org/"
41
40
  },
42
41
  "devDependencies": {
43
- "@types/node": "^25.6.0",
42
+ "@types/node": "^22.19.19",
44
43
  "@internals/utils": "0.0.0"
45
44
  },
46
45
  "engines": {
package/src/factory.ts CHANGED
@@ -12,7 +12,9 @@ import type {
12
12
  FunctionParameterNode,
13
13
  FunctionParametersNode,
14
14
  ImportNode,
15
+ InputMeta,
15
16
  InputNode,
17
+ InputStreamNode,
16
18
  JsxNode,
17
19
  ObjectSchemaNode,
18
20
  OperationNode,
@@ -84,11 +86,24 @@ export function createInput(overrides: Partial<Omit<InputNode, 'kind'>> = {}): I
84
86
  return {
85
87
  schemas: [],
86
88
  operations: [],
89
+ meta: { circularNames: [], enumNames: [] },
87
90
  ...overrides,
88
91
  kind: 'Input',
89
92
  }
90
93
  }
91
94
 
95
+ /**
96
+ * Creates an `InputStreamNode` from pre-built `AsyncIterable` sources.
97
+ *
98
+ * @example
99
+ * ```ts
100
+ * const node = createStreamInput(schemasIterable, operationsIterable, { title: 'My API' })
101
+ * ```
102
+ */
103
+ export function createStreamInput(schemas: AsyncIterable<SchemaNode>, operations: AsyncIterable<OperationNode>, meta?: InputMeta): InputStreamNode {
104
+ return { kind: 'Input', schemas, operations, meta }
105
+ }
106
+
92
107
  /**
93
108
  * Creates an `OutputNode` with a stable default for `files`.
94
109
  *
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ export {
10
10
  createFunctionParameters,
11
11
  createImport,
12
12
  createInput,
13
+ createStreamInput,
13
14
  createJsx,
14
15
  createOperation,
15
16
  createOutput,
@@ -28,7 +29,7 @@ export { isInputNode, isOperationNode, isOutputNode, isSchemaNode, narrowSchema
28
29
  export { createPrinterFactory, definePrinter } from './printer.ts'
29
30
  export { extractRefName } from './refs.ts'
30
31
  export { childName, collectImports, enumPropName, findDiscriminator } from './resolvers.ts'
31
- export { mergeAdjacentObjects, setDiscriminatorEnum, setEnumName, simplifyUnion } from './transformers.ts'
32
+ export { mergeAdjacentObjects, mergeAdjacentObjectsLazy, setDiscriminatorEnum, setEnumName, simplifyUnion } from './transformers.ts'
32
33
  export type * from './types.ts'
33
34
  export {
34
35
  caseParams,
@@ -43,4 +44,4 @@ export {
43
44
  resolveRefName,
44
45
  syncSchemaRef,
45
46
  } from './utils.ts'
46
- export { collect, transform, walk } from './visitor.ts'
47
+ export { collect, collectLazy, transform, walk } from './visitor.ts'
@@ -19,7 +19,7 @@ export type { OutputNode } from './output.ts'
19
19
  export type { ParameterLocation, ParameterNode } from './parameter.ts'
20
20
  export type { PropertyNode } from './property.ts'
21
21
  export type { ResponseNode } from './response.ts'
22
- export type { InputMeta, InputNode } from './root.ts'
22
+ export type { InputMeta, InputNode, InputStreamNode } from './root.ts'
23
23
  export type {
24
24
  ArraySchemaNode,
25
25
  ComplexSchemaType,
package/src/nodes/root.ts CHANGED
@@ -3,32 +3,62 @@ import type { OperationNode } from './operation.ts'
3
3
  import type { SchemaNode } from './schema.ts'
4
4
 
5
5
  /**
6
- * Basic metadata for an API document.
7
- * Adapters fill fields that exist in their source format.
6
+ * Metadata for an API document, populated by the adapter and available to every generator.
7
+ *
8
+ * All fields are plain JSON-serializable values — no `Set`, no `Map`, no class instances.
9
+ * Computed fields (`circularNames`, `enumNames`) are pre-calculated once during the adapter
10
+ * pre-scan so generators never need to iterate the full schema list themselves.
8
11
  *
9
12
  * @example
10
13
  * ```ts
11
- * const meta: InputMeta = { title: 'Pet API', version: '1.0.0' }
14
+ * const meta: InputMeta = { title: 'Pet Store', version: '1.0.0', baseURL: 'https://petstore.swagger.io/v2', circularNames: [], enumNames: [] }
12
15
  * ```
13
16
  */
14
17
  export type InputMeta = {
15
18
  /**
16
- * API title (from `info.title` in OAS/AsyncAPI).
19
+ * API title from `info.title` in the source document.
17
20
  */
18
21
  title?: string
19
22
  /**
20
- * API description (from `info.description` in OAS/AsyncAPI).
23
+ * API description from `info.description` in the source document.
21
24
  */
22
25
  description?: string
23
26
  /**
24
- * API version string (from `info.version` in OAS/AsyncAPI).
27
+ * API version string from `info.version` in the source document.
25
28
  */
26
29
  version?: string
27
30
  /**
28
- * Resolved API base URL.
29
- * For OpenAPI and AsyncAPI, this comes from the selected server URL.
31
+ * Resolved base URL from the first matching server entry in the source document.
30
32
  */
31
33
  baseURL?: string
34
+ /**
35
+ * Names of schemas that participate in a circular reference chain.
36
+ * Computed once during the adapter pre-scan — use this instead of calling
37
+ * `findCircularSchemas` per generator call.
38
+ *
39
+ * Convert to a `Set` once at the start of a generator, not per-schema,
40
+ * to keep lookup O(1) without repeated allocations.
41
+ *
42
+ * @example Wrap a circular schema in z.lazy()
43
+ * ```ts
44
+ * const circular = new Set(meta.circularNames)
45
+ * if (circular.has(schema.name)) { ... }
46
+ * ```
47
+ */
48
+ circularNames: readonly string[]
49
+ /**
50
+ * Names of schemas whose type is `enum`.
51
+ * Computed once during the adapter pre-scan — use this instead of filtering
52
+ * schemas per generator call.
53
+ *
54
+ * Convert to a `Set` once at the start of a generator when you need repeated
55
+ * membership checks, rather than calling `.includes()` per schema.
56
+ *
57
+ * @example Check if a referenced schema is an enum
58
+ * `const enums = new Set(meta.enumNames)`
59
+ * `const isEnum = enums.has(schemaName)`
60
+ */
61
+ enumNames: readonly string[]
32
62
  }
33
63
 
34
64
  /**
@@ -58,7 +88,39 @@ export type InputNode = BaseNode & {
58
88
  */
59
89
  operations: Array<OperationNode>
60
90
  /**
61
- * Optional document metadata populated by the adapter.
91
+ * Document metadata populated by the adapter.
92
+ */
93
+ meta: InputMeta
94
+ }
95
+
96
+ /**
97
+ * Streaming variant of `InputNode` for memory-efficient processing of large API specs.
98
+ *
99
+ * `schemas` and `operations` are `AsyncIterable` rather than arrays — each `for await`
100
+ * loop creates a fresh parse pass from the cached in-memory document, so multiple
101
+ * consumers (plugins) can iterate independently without keeping all nodes in memory.
102
+ *
103
+ * @example
104
+ * ```ts
105
+ * for await (const schema of inputStreamNode.schemas) {
106
+ * // only this one SchemaNode is live here; previous ones are GC-eligible
107
+ * }
108
+ * ```
109
+ */
110
+ export type InputStreamNode = {
111
+ kind: 'Input'
112
+ /**
113
+ * Lazily parsed schema nodes. Each `for await` creates a fresh parse pass, so
114
+ * multiple plugins can iterate independently without sharing state.
115
+ */
116
+ schemas: AsyncIterable<SchemaNode>
117
+ /**
118
+ * Lazily parsed operation nodes. Each `for await` creates a fresh parse pass, so
119
+ * multiple plugins can iterate independently without sharing state.
120
+ */
121
+ operations: AsyncIterable<OperationNode>
122
+ /**
123
+ * Document metadata available immediately, before the first yielded node.
62
124
  */
63
125
  meta?: InputMeta
64
126
  }
@@ -154,6 +154,10 @@ type SchemaNodeBase = BaseNode & {
154
154
  * For example, this is `'string'` for a `uuid` schema.
155
155
  */
156
156
  primitive?: PrimitiveSchemaType
157
+ /**
158
+ * Schema `format` value.
159
+ */
160
+ format?: string
157
161
  }
158
162
 
159
163
  /**
@@ -73,25 +73,30 @@ export function setDiscriminatorEnum({
73
73
  * ])
74
74
  * ```
75
75
  */
76
- export function mergeAdjacentObjects(members: Array<SchemaNode>): Array<SchemaNode> {
77
- return members.reduce<Array<SchemaNode>>((acc, member) => {
76
+ export function* mergeAdjacentObjectsLazy(members: Iterable<SchemaNode>): Generator<SchemaNode, void, undefined> {
77
+ let acc: SchemaNode | undefined
78
+
79
+ for (const member of members) {
78
80
  const objectMember = narrowSchema(member, 'object')
79
- if (objectMember && !objectMember.name) {
80
- const previous = acc.at(-1)
81
- const previousObject = previous ? narrowSchema(previous, 'object') : undefined
82
-
83
- if (previousObject && !previousObject.name) {
84
- acc[acc.length - 1] = createSchema({
85
- ...previousObject,
86
- properties: [...(previousObject.properties ?? []), ...(objectMember.properties ?? [])],
81
+ if (objectMember && !objectMember.name && acc !== undefined) {
82
+ const accObject = narrowSchema(acc, 'object')
83
+ if (accObject && !accObject.name) {
84
+ acc = createSchema({
85
+ ...accObject,
86
+ properties: [...(accObject.properties ?? []), ...(objectMember.properties ?? [])],
87
87
  })
88
- return acc
88
+ continue
89
89
  }
90
90
  }
91
+ if (acc !== undefined) yield acc
92
+ acc = member
93
+ }
91
94
 
92
- acc.push(member)
93
- return acc
94
- }, [])
95
+ if (acc !== undefined) yield acc
96
+ }
97
+
98
+ export function mergeAdjacentObjects(members: Array<SchemaNode>): Array<SchemaNode> {
99
+ return [...mergeAdjacentObjectsLazy(members)]
95
100
  }
96
101
 
97
102
  /**
package/src/types.ts CHANGED
@@ -26,6 +26,7 @@ export type {
26
26
  ImportNode,
27
27
  InputMeta,
28
28
  InputNode,
29
+ InputStreamNode,
29
30
  IntersectionSchemaNode,
30
31
  Ipv4SchemaNode,
31
32
  Ipv6SchemaNode,
package/src/utils.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { camelCase, isValidVarName } from '@internals/utils'
1
+ import { camelCase, isValidVarName, memoize } from '@internals/utils'
2
2
 
3
3
  import { createFunctionParameter, createFunctionParameters, createParameterGroup, createParamsType, createProperty, createSchema } from './factory.ts'
4
4
  import { narrowSchema } from './guards.ts'
@@ -17,7 +17,7 @@ import type {
17
17
  } from './nodes/index.ts'
18
18
  import type { SchemaType } from './nodes/schema.ts'
19
19
  import { extractRefName } from './refs.ts'
20
- import { collect } from './visitor.ts'
20
+ import { collect, collectLazy } from './visitor.ts'
21
21
 
22
22
  const plainStringTypes = new Set<SchemaType>(['string', 'uuid', 'email', 'url', 'datetime'] as const)
23
23
 
@@ -74,16 +74,18 @@ export function isStringType(node: SchemaNode): boolean {
74
74
  * the desired casing while preserving `OperationNode.parameters` for other consumers.
75
75
  * The input array is not mutated. When `casing` is not set, the original array is returned unchanged.
76
76
  */
77
- export function caseParams(params: Array<ParameterNode>, casing: 'camelcase' | undefined): Array<ParameterNode> {
78
- if (!casing) {
79
- return params
80
- }
77
+ const caseParamsMemo = memoize(new WeakMap<Array<ParameterNode>, (casing: string) => Array<ParameterNode>>(), (params) =>
78
+ memoize(new Map<string, Array<ParameterNode>>(), (casing: string) =>
79
+ params.map((param) => {
80
+ const transformed = casing === 'camelcase' || !isValidVarName(param.name) ? camelCase(param.name) : param.name
81
+ return { ...param, name: transformed }
82
+ }),
83
+ ),
84
+ )
81
85
 
82
- return params.map((param) => {
83
- const transformed = casing === 'camelcase' || !isValidVarName(param.name) ? camelCase(param.name) : param.name
84
-
85
- return { ...param, name: transformed }
86
- })
86
+ export function caseParams(params: Array<ParameterNode>, casing: 'camelcase' | undefined): Array<ParameterNode> {
87
+ if (!casing) return params
88
+ return caseParamsMemo(params)(casing)
87
89
  }
88
90
 
89
91
  /**
@@ -652,6 +654,16 @@ export function combineImports(imports: Array<ImportNode>, exports: Array<Export
652
654
  const exportedNames = new Set(exports.flatMap((e) => (Array.isArray(e.name) ? e.name : e.name ? [e.name] : [])))
653
655
  const isUsed = (importName: string): boolean => !source || source.includes(importName) || exportedNames.has(importName)
654
656
 
657
+ // Memoize object import names so the same logical (propertyName, name) pair always
658
+ // reuses the same object reference — Set-based deduplication then works correctly.
659
+ const importNameMemo = new Map<string, { propertyName: string; name?: string }>()
660
+ const canonicalizeName = (n: string | { propertyName: string; name?: string }): string | { propertyName: string; name?: string } => {
661
+ if (typeof n === 'string') return n
662
+ const key = `${n.propertyName}:${n.name ?? ''}`
663
+ if (!importNameMemo.has(key)) importNameMemo.set(key, n)
664
+ return importNameMemo.get(key)!
665
+ }
666
+
655
667
  const result: Array<ImportNode> = []
656
668
  // Accumulates array-named imports keyed by `path:isTypeOnly` for name-merging
657
669
  const namedByPath = new Map<string, ImportNode>()
@@ -669,7 +681,7 @@ export function combineImports(imports: Array<ImportNode>, exports: Array<Export
669
681
  let { name } = curr
670
682
 
671
683
  if (Array.isArray(name)) {
672
- name = [...new Set(name)].filter((item) => (typeof item === 'string' ? isUsed(item) : isUsed(item.name ?? item.propertyName)))
684
+ name = [...new Set(name.map(canonicalizeName))].filter((item) => (typeof item === 'string' ? isUsed(item) : isUsed(item.name ?? item.propertyName)))
673
685
  if (!name.length) continue
674
686
 
675
687
  const key = pathTypeKey(path, isTypeOnly)
@@ -713,13 +725,18 @@ export function extractStringsFromNodes(nodes: Array<CodeNode> | undefined): str
713
725
  if (node.kind === 'Text') return node.value
714
726
  if (node.kind === 'Break') return ''
715
727
  if (node.kind === 'Jsx') return node.value
728
+
716
729
  const parts: string[] = []
730
+
717
731
  if ('params' in node && node.params) parts.push(node.params)
718
732
  if ('generics' in node && node.generics) parts.push(Array.isArray(node.generics) ? node.generics.join(', ') : node.generics)
719
733
  if ('returnType' in node && node.returnType) parts.push(node.returnType)
720
734
  if ('type' in node && typeof node.type === 'string') parts.push(node.type)
735
+
721
736
  const nested = extractStringsFromNodes(node.nodes)
737
+
722
738
  if (nested) parts.push(nested)
739
+
723
740
  return parts.join('\n')
724
741
  })
725
742
  .filter(Boolean)
@@ -765,18 +782,22 @@ export function resolveRefName(node: SchemaNode | undefined): string | undefined
765
782
  * }
766
783
  * ```
767
784
  */
768
- export function collectReferencedSchemaNames(node: SchemaNode | undefined, out: Set<string> = new Set()): Set<string> {
769
- if (!node) return out
785
+ const collectSchemaRefs = memoize(new WeakMap<SchemaNode, ReadonlySet<string>>(), (node: SchemaNode): ReadonlySet<string> => {
786
+ const refs = new Set<string>()
770
787
  collect<void>(node, {
771
788
  schema(child) {
772
789
  if (child.type === 'ref') {
773
790
  const name = resolveRefName(child)
774
-
775
- if (name) out.add(name)
791
+ if (name) refs.add(name)
776
792
  }
777
- return undefined
778
793
  },
779
794
  })
795
+ return refs
796
+ })
797
+
798
+ export function collectReferencedSchemaNames(node: SchemaNode | undefined, out: Set<string> = new Set()): Set<string> {
799
+ if (!node) return out
800
+ for (const name of collectSchemaRefs(node)) out.add(name)
780
801
  return out
781
802
  }
782
803
 
@@ -793,10 +814,10 @@ export function collectReferencedSchemaNames(node: SchemaNode | undefined, out:
793
814
  *
794
815
  * @example Only generate schemas referenced by included operations
795
816
  * ```ts
796
- * const includedOps = inputNode.operations.filter(op => resolver.resolveOptions(op, { options, include }) !== null)
797
- * const allowed = collectUsedSchemaNames(includedOps, inputNode.schemas)
817
+ * const includedOps = operations.filter(op => resolver.resolveOptions(op, { options, include }) !== null)
818
+ * const allowed = collectUsedSchemaNames(includedOps, schemas)
798
819
  *
799
- * for (const schema of inputNode.schemas) {
820
+ * for (const schema of schemas) {
800
821
  * if (schema.name && !allowed.has(schema.name)) continue
801
822
  * // … generate schema
802
823
  * }
@@ -804,16 +825,18 @@ export function collectReferencedSchemaNames(node: SchemaNode | undefined, out:
804
825
  *
805
826
  * @example Check whether a specific schema is needed
806
827
  * ```ts
807
- * const allowed = collectUsedSchemaNames(includedOps, inputNode.schemas)
828
+ * const allowed = collectUsedSchemaNames(includedOps, schemas)
808
829
  * allowed.has('OrderStatus') // false when no included operation references OrderStatus
809
830
  * ```
810
831
  */
811
- export function collectUsedSchemaNames(operations: ReadonlyArray<OperationNode>, schemas: ReadonlyArray<SchemaNode>): Set<string> {
832
+ const collectUsedSchemaNamesMemo = memoize(new WeakMap<ReadonlyArray<OperationNode>, (schemas: ReadonlyArray<SchemaNode>) => Set<string>>(), (ops) =>
833
+ memoize(new WeakMap<ReadonlyArray<SchemaNode>, Set<string>>(), (schemas) => computeUsedSchemaNames(ops, schemas)),
834
+ )
835
+
836
+ function computeUsedSchemaNames(operations: ReadonlyArray<OperationNode>, schemas: ReadonlyArray<SchemaNode>): Set<string> {
812
837
  const schemaMap = new Map<string, SchemaNode>()
813
838
  for (const schema of schemas) {
814
- if (schema.name) {
815
- schemaMap.set(schema.name, schema)
816
- }
839
+ if (schema.name) schemaMap.set(schema.name, schema)
817
840
  }
818
841
 
819
842
  const result = new Set<string>()
@@ -824,15 +847,13 @@ export function collectUsedSchemaNames(operations: ReadonlyArray<OperationNode>,
824
847
  if (!result.has(name)) {
825
848
  result.add(name)
826
849
  const namedSchema = schemaMap.get(name)
827
- if (namedSchema) {
828
- visitSchema(namedSchema)
829
- }
850
+ if (namedSchema) visitSchema(namedSchema)
830
851
  }
831
852
  }
832
853
  }
833
854
 
834
855
  for (const op of operations) {
835
- for (const schema of collect<SchemaNode>(op, { depth: 'shallow', schema: (node) => node })) {
856
+ for (const schema of collectLazy<SchemaNode>(op, { depth: 'shallow', schema: (node) => node })) {
836
857
  visitSchema(schema)
837
858
  }
838
859
  }
@@ -840,16 +861,13 @@ export function collectUsedSchemaNames(operations: ReadonlyArray<OperationNode>,
840
861
  return result
841
862
  }
842
863
 
843
- /**
844
- * Identifies all schemas that participate in circular dependency chains, including direct self-loops.
845
- *
846
- * Returns a Set of schema names with circular dependencies. Use this to wrap recursive schema positions
847
- * in deferred constructs (lazy getter, `z.lazy(() => …)`) to prevent infinite recursion when generated code runs.
848
- * Refs are followed by name only, keeping the algorithm linear in the schema graph size.
849
- *
850
- * @note Call this once on the full schema graph, then use `containsCircularRef()` to check individual schemas.
851
- */
852
- export function findCircularSchemas(schemas: ReadonlyArray<SchemaNode>): Set<string> {
864
+ export function collectUsedSchemaNames(operations: ReadonlyArray<OperationNode>, schemas: ReadonlyArray<SchemaNode>): Set<string> {
865
+ return collectUsedSchemaNamesMemo(operations)(schemas)
866
+ }
867
+
868
+ const EMPTY_CIRCULAR_SET = new Set<string>()
869
+
870
+ const findCircularSchemasMemo = memoize(new WeakMap<ReadonlyArray<SchemaNode>, Set<string>>(), (schemas: ReadonlyArray<SchemaNode>): Set<string> => {
853
871
  const graph = new Map<string, Set<string>>()
854
872
 
855
873
  for (const schema of schemas) {
@@ -876,6 +894,20 @@ export function findCircularSchemas(schemas: ReadonlyArray<SchemaNode>): Set<str
876
894
  }
877
895
 
878
896
  return circular
897
+ })
898
+
899
+ /**
900
+ * Identifies all schemas that participate in circular dependency chains, including direct self-loops.
901
+ *
902
+ * Returns a Set of schema names with circular dependencies. Use this to wrap recursive schema positions
903
+ * in deferred constructs (lazy getter, `z.lazy(() => …)`) to prevent infinite recursion when generated code runs.
904
+ * Refs are followed by name only, keeping the algorithm linear in the schema graph size.
905
+ *
906
+ * @note Call this once on the full schema graph, then use `containsCircularRef()` to check individual schemas.
907
+ */
908
+ export function findCircularSchemas(schemas: ReadonlyArray<SchemaNode>): Set<string> {
909
+ if (schemas.length === 0) return EMPTY_CIRCULAR_SET
910
+ return findCircularSchemasMemo(schemas)
879
911
  }
880
912
 
881
913
  /**
@@ -892,14 +924,15 @@ export function containsCircularRef(
892
924
  ): boolean {
893
925
  if (!node || circularSchemas.size === 0) return false
894
926
 
895
- const matches = collect<true>(node, {
927
+ for (const _ of collectLazy<true>(node, {
896
928
  schema(child) {
897
929
  if (child.type !== 'ref') return undefined
898
930
  const name = resolveRefName(child)
899
-
900
931
  return name && name !== excludeName && circularSchemas.has(name) ? true : undefined
901
932
  },
902
- })
933
+ })) {
934
+ return true
935
+ }
903
936
 
904
- return matches.length > 0
937
+ return false
905
938
  }