@kubb/core 5.0.0-beta.6 → 5.0.0-beta.60

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.
Files changed (56) hide show
  1. package/LICENSE +17 -10
  2. package/README.md +25 -158
  3. package/dist/diagnostics-B-UZnFqP.d.ts +2906 -0
  4. package/dist/index.cjs +2497 -1071
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.ts +80 -273
  7. package/dist/index.js +2487 -1067
  8. package/dist/index.js.map +1 -1
  9. package/dist/memoryStorage-CUj1hrxa.cjs +823 -0
  10. package/dist/memoryStorage-CUj1hrxa.cjs.map +1 -0
  11. package/dist/memoryStorage-CWFzAz4o.js +714 -0
  12. package/dist/memoryStorage-CWFzAz4o.js.map +1 -0
  13. package/dist/mocks.cjs +79 -19
  14. package/dist/mocks.cjs.map +1 -1
  15. package/dist/mocks.d.ts +35 -9
  16. package/dist/mocks.js +80 -22
  17. package/dist/mocks.js.map +1 -1
  18. package/package.json +8 -28
  19. package/src/FileManager.ts +86 -64
  20. package/src/FileProcessor.ts +170 -44
  21. package/src/KubbDriver.ts +908 -0
  22. package/src/Transform.ts +75 -0
  23. package/src/constants.ts +111 -20
  24. package/src/createAdapter.ts +112 -17
  25. package/src/createKubb.ts +140 -517
  26. package/src/createRenderer.ts +43 -28
  27. package/src/createReporter.ts +134 -0
  28. package/src/createStorage.ts +36 -23
  29. package/src/defineGenerator.ts +147 -17
  30. package/src/defineParser.ts +30 -12
  31. package/src/definePlugin.ts +370 -21
  32. package/src/defineResolver.ts +402 -212
  33. package/src/diagnostics.ts +662 -0
  34. package/src/index.ts +8 -8
  35. package/src/mocks.ts +91 -20
  36. package/src/reporters/cliReporter.ts +89 -0
  37. package/src/reporters/fileReporter.ts +103 -0
  38. package/src/reporters/jsonReporter.ts +20 -0
  39. package/src/reporters/report.ts +85 -0
  40. package/src/storages/fsStorage.ts +23 -55
  41. package/src/types.ts +411 -887
  42. package/dist/PluginDriver-BkTRD2H2.js +0 -946
  43. package/dist/PluginDriver-BkTRD2H2.js.map +0 -1
  44. package/dist/PluginDriver-Cadu4ORh.cjs +0 -1037
  45. package/dist/PluginDriver-Cadu4ORh.cjs.map +0 -1
  46. package/dist/types-DVPKmzw_.d.ts +0 -2159
  47. package/src/Kubb.ts +0 -300
  48. package/src/PluginDriver.ts +0 -426
  49. package/src/defineLogger.ts +0 -19
  50. package/src/defineMiddleware.ts +0 -62
  51. package/src/devtools.ts +0 -59
  52. package/src/renderNode.ts +0 -35
  53. package/src/utils/diagnostics.ts +0 -18
  54. package/src/utils/isInputPath.ts +0 -10
  55. package/src/utils/packageJSON.ts +0 -99
  56. /package/dist/{chunk--u3MIqq1.js → chunk-C0LytTxp.js} +0 -0
@@ -0,0 +1,908 @@
1
+ import { resolve } from 'node:path'
2
+ import { arrayToAsyncIterable, type AsyncEventEmitter, forBatches, getElapsedMs, isPromise, memoize, Url } from '@internals/utils'
3
+ import * as factory from '@kubb/ast/factory'
4
+ import { collectUsedSchemaNames } from '@kubb/ast/utils'
5
+ import type { FileNode, InputMeta, InputNode, OperationNode, SchemaNode } from '@kubb/ast'
6
+ import { OPERATION_FILTER_TYPES, SCHEMA_PARALLEL } from './constants.ts'
7
+ import { type Diagnostic, Diagnostics, type ProblemDiagnostic } from './diagnostics.ts'
8
+ import type { RendererFactory } from './createRenderer.ts'
9
+ import type { Storage } from './createStorage.ts'
10
+ import type { Generator } from './defineGenerator.ts'
11
+ import type { Parser } from './defineParser.ts'
12
+ import type { Plugin } from './definePlugin.ts'
13
+ import { normalizeOutput } from './definePlugin.ts'
14
+ import { defineResolver } from './defineResolver.ts'
15
+ import { FileManager } from './FileManager.ts'
16
+ import { FileProcessor } from './FileProcessor.ts'
17
+ import { Transform } from './Transform.ts'
18
+
19
+ import type {
20
+ Adapter,
21
+ AdapterSource,
22
+ Config,
23
+ GeneratorContext,
24
+ Group,
25
+ KubbHooks,
26
+ KubbPluginSetupContext,
27
+ NormalizedPlugin,
28
+ PluginFactoryOptions,
29
+ Resolver,
30
+ } from './types.ts'
31
+
32
+ type Options = {
33
+ hooks: AsyncEventEmitter<KubbHooks>
34
+ }
35
+
36
+ type HookListener<TArgs extends Array<unknown>, TResult = void> = (...args: TArgs) => TResult | Promise<TResult>
37
+
38
+ type ListenerEntry = [event: keyof KubbHooks & string, handler: HookListener<Array<unknown>, unknown>]
39
+
40
+ type RequirePluginContext = {
41
+ /**
42
+ * Name of the plugin that declared the dependency, included in the error so users can
43
+ * trace which plugin needs the missing one.
44
+ */
45
+ requiredBy?: string
46
+ }
47
+
48
+ function enforceOrder(enforce: 'pre' | 'post' | undefined): number {
49
+ return enforce === 'pre' ? -1 : enforce === 'post' ? 1 : 0
50
+ }
51
+
52
+ export class KubbDriver {
53
+ readonly config: Config
54
+ readonly options: Options
55
+
56
+ /**
57
+ * The streaming `InputNode<true>` produced by the adapter. Set after adapter setup.
58
+ * Parse-only adapters are wrapped automatically.
59
+ */
60
+ inputNode: InputNode<true> | null = null
61
+ adapter: Adapter | null = null
62
+ /**
63
+ * Raw adapter source so `adapter.parse()` / `adapter.stream()` can run lazily.
64
+ * Intentionally outlives the build, cleared by `dispose()`.
65
+ */
66
+ #adapterSource: AdapterSource | null = null
67
+
68
+ /**
69
+ * Central file store for all generated files.
70
+ * Plugins should use `this.addFile()` / `this.upsertFile()` (via their context) to
71
+ * add files. This property gives direct read/write access when needed.
72
+ */
73
+ readonly fileManager = new FileManager()
74
+ readonly plugins = new Map<string, NormalizedPlugin>()
75
+
76
+ /**
77
+ * Tracks which plugins have generators registered via `addGenerator()` (event-based path).
78
+ * Used by the build loop to decide whether to emit generator events for a given plugin.
79
+ */
80
+ readonly #eventGeneratorPlugins = new Set<string>()
81
+ readonly #resolvers = new Map<string, Resolver>()
82
+ readonly #defaultResolvers = new Map<string, Resolver>()
83
+
84
+ /**
85
+ * Tracks every listener the driver added (plugin, generator) so `dispose()` can remove them
86
+ * in one pass. External `hooks.on(...)` listeners are not tracked.
87
+ */
88
+ readonly #listeners: Array<ListenerEntry> = []
89
+
90
+ /**
91
+ * Transform registry. Plugins populate it during `kubb:plugin:setup` via `setTransformer`,
92
+ * and `#runGenerators` reads it once per `(plugin, node)` pair through `applyTo`.
93
+ */
94
+ readonly #transforms = new Transform()
95
+
96
+ constructor(config: Config, options: Options) {
97
+ this.config = config
98
+ this.options = options
99
+ this.adapter = config.adapter ?? null
100
+ }
101
+
102
+ /**
103
+ * Attaches a listener to the shared emitter and tracks it so `dispose()` can remove it later.
104
+ * Listeners attached directly via `hooks.on(...)` are not tracked and survive disposal.
105
+ */
106
+ #trackListener<K extends keyof KubbHooks & string>(event: K, handler: HookListener<KubbHooks[K], unknown>): void {
107
+ this.hooks.on(event, handler as HookListener<KubbHooks[K]>)
108
+ this.#listeners.push([event, handler as HookListener<Array<unknown>, unknown>])
109
+ }
110
+
111
+ async setup() {
112
+ const normalized: Array<NormalizedPlugin> = this.config.plugins.map((rawPlugin) => this.#normalizePlugin(rawPlugin as Plugin))
113
+
114
+ const dependenciesByName = new Map(normalized.map((plugin) => [plugin.name, new Set(plugin.dependencies ?? [])]))
115
+
116
+ normalized.sort((a, b) => {
117
+ if (dependenciesByName.get(b.name)?.has(a.name)) return -1
118
+ if (dependenciesByName.get(a.name)?.has(b.name)) return 1
119
+
120
+ return enforceOrder(a.enforce) - enforceOrder(b.enforce)
121
+ })
122
+
123
+ for (const plugin of normalized) {
124
+ if (plugin.apply) {
125
+ plugin.apply(this.config)
126
+ }
127
+
128
+ this.#registerPlugin(plugin)
129
+ this.plugins.set(plugin.name, plugin)
130
+ }
131
+
132
+ if (this.config.adapter) {
133
+ this.#adapterSource = inputToAdapterSource(this.config)
134
+ }
135
+ }
136
+
137
+ get hooks() {
138
+ return this.options.hooks
139
+ }
140
+
141
+ /**
142
+ * Creates an `NormalizedPlugin` from a hook-style plugin and registers
143
+ * its lifecycle handlers on the `AsyncEventEmitter`.
144
+ */
145
+ #normalizePlugin(plugin: Plugin): NormalizedPlugin {
146
+ const normalized: NormalizedPlugin = {
147
+ name: plugin.name,
148
+ dependencies: plugin.dependencies,
149
+ enforce: plugin.enforce,
150
+ hooks: plugin.hooks,
151
+ options: plugin.options ?? { output: { path: '.', mode: 'directory' }, exclude: [], override: [] },
152
+ } as NormalizedPlugin
153
+
154
+ if ('apply' in plugin && typeof plugin.apply === 'function') {
155
+ normalized.apply = plugin.apply as (config: Config) => boolean
156
+ }
157
+
158
+ return normalized
159
+ }
160
+
161
+ /**
162
+ * Parses the adapter source into `this.inputNode`. Idempotent, so repeated calls from
163
+ * `run` do not re-parse. Adapters with `stream()` are used directly.
164
+ * Adapters with only `parse()` are wrapped via `factory.createInput({ stream: true })` so the dispatch loop
165
+ * stays stream-only.
166
+ */
167
+ async #parseInput(): Promise<void> {
168
+ if (this.inputNode || !this.adapter || !this.#adapterSource) return
169
+
170
+ const adapter = this.adapter
171
+ const source = this.#adapterSource
172
+
173
+ if (adapter.stream) {
174
+ this.inputNode = await adapter.stream(source)
175
+ return
176
+ }
177
+
178
+ const parsed = await adapter.parse(source)
179
+ this.inputNode = factory.createInput({
180
+ stream: true,
181
+ schemas: arrayToAsyncIterable(parsed.schemas),
182
+ operations: arrayToAsyncIterable(parsed.operations),
183
+ meta: parsed.meta,
184
+ })
185
+ }
186
+
187
+ /**
188
+ * Registers a hook-style plugin's lifecycle handlers on the shared `AsyncEventEmitter`.
189
+ *
190
+ * The `kubb:plugin:setup` listener wraps the global context in a plugin-specific one so
191
+ * `addGenerator`, `setResolver`, and `setTransformer` target the right `normalizedPlugin`.
192
+ * Every other `KubbHooks` event registers as a pass-through listener that external tooling
193
+ * can observe via `hooks.on(...)`.
194
+ *
195
+ * @internal
196
+ */
197
+ #registerPlugin(plugin: NormalizedPlugin): void {
198
+ const { hooks } = plugin
199
+
200
+ if (!hooks) return
201
+
202
+ // kubb:plugin:setup gets special treatment: the globally emitted context is wrapped with
203
+ // plugin-specific implementations so that addGenerator / setResolver / etc. target
204
+ // this plugin's normalizedPlugin entry rather than being no-ops.
205
+ if (hooks['kubb:plugin:setup']) {
206
+ const setupHandler = (globalCtx: KubbPluginSetupContext) => {
207
+ const pluginCtx: KubbPluginSetupContext = {
208
+ ...globalCtx,
209
+ options: plugin.options ?? {},
210
+ addGenerator: (gen) => {
211
+ this.registerGenerator(plugin.name, gen)
212
+ },
213
+ setResolver: (resolver) => {
214
+ this.setPluginResolver(plugin.name, resolver)
215
+ },
216
+ setTransformer: (visitor) => {
217
+ this.#transforms.register(plugin.name, visitor)
218
+ },
219
+ setOptions: (opts) => {
220
+ plugin.options = { ...plugin.options, ...opts }
221
+ if (plugin.options.output) {
222
+ const group = 'group' in plugin.options ? (plugin.options.group as Group | null | undefined) : undefined
223
+ plugin.options.output = normalizeOutput({ output: plugin.options.output, group, pluginName: plugin.name })
224
+ }
225
+ },
226
+ injectFile: (userFileNode) => {
227
+ this.fileManager.add(factory.createFile(userFileNode))
228
+ },
229
+ }
230
+ return hooks['kubb:plugin:setup']!(pluginCtx)
231
+ }
232
+
233
+ this.#trackListener('kubb:plugin:setup', setupHandler)
234
+ }
235
+
236
+ // All other hooks are registered as direct pass-through listeners on the shared emitter.
237
+ for (const event of Object.keys(hooks) as Array<keyof KubbHooks & string>) {
238
+ if (event === 'kubb:plugin:setup') continue
239
+ const handler = hooks[event]
240
+ if (!handler) continue
241
+
242
+ this.#trackListener(event, handler as HookListener<KubbHooks[typeof event], unknown>)
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Emits the `kubb:plugin:setup` event so that all registered hook-style plugin listeners
248
+ * can configure generators, resolvers, transformers and renderers before `buildStart` runs.
249
+ *
250
+ * Call this once from `safeBuild` before the plugin execution loop begins.
251
+ */
252
+ async emitSetupHooks(): Promise<void> {
253
+ const noop = () => {}
254
+
255
+ await this.hooks.emit('kubb:plugin:setup', {
256
+ config: this.config,
257
+ options: {},
258
+ addGenerator: noop,
259
+ setResolver: noop,
260
+ setTransformer: noop,
261
+ setOptions: noop,
262
+ injectFile: noop,
263
+ updateConfig: noop,
264
+ })
265
+ }
266
+
267
+ /**
268
+ * Registers a generator for the given plugin on the shared event emitter.
269
+ *
270
+ * The generator's `schema`, `operation`, and `operations` methods are registered as
271
+ * listeners on `kubb:generate:schema`, `kubb:generate:operation`, and `kubb:generate:operations`
272
+ * respectively. Each listener is scoped to the owning plugin via a `ctx.plugin.name` check
273
+ * so that generators from different plugins do not cross-fire.
274
+ *
275
+ * The renderer comes from `generator.renderer`. Set `generator.renderer = null` (or leave it
276
+ * unset) to opt out of rendering.
277
+ *
278
+ * Call this method inside `addGenerator()` (in `kubb:plugin:setup`) to wire up a generator.
279
+ */
280
+ registerGenerator(pluginName: string, generator: Generator): void {
281
+ if (generator.schema) {
282
+ const schemaHandler = async (node: SchemaNode, ctx: GeneratorContext) => {
283
+ if (ctx.plugin.name !== pluginName) return
284
+ const result = await generator.schema!(node, ctx)
285
+
286
+ await this.dispatch({ result, renderer: generator.renderer })
287
+ }
288
+
289
+ this.#trackListener('kubb:generate:schema', schemaHandler)
290
+ }
291
+
292
+ if (generator.operation) {
293
+ const operationHandler = async (node: OperationNode, ctx: GeneratorContext) => {
294
+ if (ctx.plugin.name !== pluginName) return
295
+
296
+ const result = await generator.operation!(node, ctx)
297
+ await this.dispatch({ result, renderer: generator.renderer })
298
+ }
299
+
300
+ this.#trackListener('kubb:generate:operation', operationHandler)
301
+ }
302
+
303
+ if (generator.operations) {
304
+ const operationsHandler = async (nodes: Array<OperationNode>, ctx: GeneratorContext) => {
305
+ if (ctx.plugin.name !== pluginName) return
306
+ const result = await generator.operations!(nodes, ctx)
307
+ await this.dispatch({ result, renderer: generator.renderer })
308
+ }
309
+
310
+ this.#trackListener('kubb:generate:operations', operationsHandler)
311
+ }
312
+
313
+ this.#eventGeneratorPlugins.add(pluginName)
314
+ }
315
+
316
+ /**
317
+ * Returns `true` when at least one generator was registered for the given plugin
318
+ * via `addGenerator()` in `kubb:plugin:setup` (event-based path).
319
+ *
320
+ * Used by the build loop to decide whether to walk the AST and emit generator events
321
+ * for a plugin that has no static `plugin.generators`.
322
+ */
323
+ hasEventGenerators(pluginName: string): boolean {
324
+ return this.#eventGeneratorPlugins.has(pluginName)
325
+ }
326
+
327
+ /**
328
+ * Runs the full plugin pipeline. Returns the diagnostics collected so far even
329
+ * when an outer hook throws, since the orchestrator preserves partial state by capturing
330
+ * the failure as a {@link Diagnostic} instead of propagating. Each plugin also
331
+ * contributes a `timing` diagnostic for the run summary.
332
+ */
333
+ async run({ storage }: { storage: Storage }): Promise<{ diagnostics: Array<Diagnostic> }> {
334
+ const { hooks, config } = this
335
+ const diagnostics: Array<Diagnostic> = []
336
+ const parsersMap = new Map<FileNode['extname'], Parser>()
337
+
338
+ for (const parser of config.parsers) {
339
+ if (parser.extNames) {
340
+ for (const ext of parser.extNames) parsersMap.set(ext, parser)
341
+ }
342
+ }
343
+
344
+ const processor = new FileProcessor({ parsers: parsersMap, storage, extension: config.output.extension })
345
+ // Bridge processor lifecycle to the user-facing kubb hooks so existing listeners on
346
+ // kubb:files:processing:* keep firing.
347
+ processor.hooks.on('start', async (files) => {
348
+ await hooks.emit('kubb:files:processing:start', { files })
349
+ })
350
+ const updateBuffer: Array<{ file: FileNode; source?: string; processed: number; total: number; percentage: number }> = []
351
+ processor.hooks.on('update', (item) => {
352
+ updateBuffer.push(item)
353
+ })
354
+ processor.hooks.on('end', async (files) => {
355
+ await hooks.emit('kubb:files:processing:update', {
356
+ files: updateBuffer.map((item) => ({ ...item, config })),
357
+ })
358
+ updateBuffer.length = 0
359
+ await hooks.emit('kubb:files:processing:end', { files })
360
+ })
361
+ const onFileUpsert = (file: FileNode): void => {
362
+ processor.enqueue(file)
363
+ }
364
+ this.fileManager.hooks.on('upsert', onFileUpsert)
365
+
366
+ // Make `diagnostics` the active sink so deep code (adapter parse, lazily consumed
367
+ // streams, generators) can report into this run via `Diagnostics.report`.
368
+ return Diagnostics.scope(
369
+ (diagnostic) => diagnostics.push(diagnostic),
370
+ async () => {
371
+ try {
372
+ const outputRoot = resolve(config.root, config.output.path)
373
+
374
+ // Parse the adapter source into the streaming `InputNode`.
375
+ await this.#parseInput()
376
+ // Emit `kubb:plugin:setup` so plugins can register transformers via `setTransformer`.
377
+ // Each call writes into `this.#transforms`, which `#runGenerators` later reads through
378
+ // `transforms.applyTo`.
379
+ await this.emitSetupHooks()
380
+
381
+ if (this.adapter && this.inputNode) {
382
+ await hooks.emit(
383
+ 'kubb:build:start',
384
+ Object.assign({ config, adapter: this.adapter, meta: this.inputNode.meta, getPlugin: this.getPlugin.bind(this) }, this.#filesPayload()),
385
+ )
386
+ }
387
+
388
+ const generatorPlugins: Array<{ plugin: NormalizedPlugin; context: Omit<GeneratorContext, 'options'>; hrStart: ReturnType<typeof process.hrtime> }> =
389
+ []
390
+
391
+ for (const plugin of this.plugins.values()) {
392
+ const context = this.getContext(plugin)
393
+ const hrStart = process.hrtime()
394
+
395
+ try {
396
+ await hooks.emit('kubb:plugin:start', { plugin })
397
+ } catch (caughtError) {
398
+ const error = caughtError as Error
399
+ const duration = getElapsedMs(hrStart)
400
+
401
+ await this.#emitPluginEnd({ plugin, duration, success: false, error })
402
+
403
+ diagnostics.push({ ...Diagnostics.from(error), plugin: plugin.name }, Diagnostics.performance({ plugin: plugin.name, duration }))
404
+
405
+ continue
406
+ }
407
+
408
+ if (this.hasEventGenerators(plugin.name)) {
409
+ generatorPlugins.push({ plugin, context, hrStart })
410
+
411
+ continue
412
+ }
413
+
414
+ const duration = getElapsedMs(hrStart)
415
+ diagnostics.push(Diagnostics.performance({ plugin: plugin.name, duration }))
416
+
417
+ await this.#emitPluginEnd({ plugin, duration, success: true })
418
+ }
419
+
420
+ // Stream every node through the transform registry and into each plugin's generators.
421
+ // Handles the empty-entries and missing-`inputNode` cases by closing out each entry's
422
+ // `kubb:plugin:end` directly.
423
+ diagnostics.push(...(await this.#runGenerators(generatorPlugins, () => processor.flush())))
424
+ // Wait for the last in-flight batch and write anything still pending.
425
+ await processor.drain()
426
+
427
+ await hooks.emit('kubb:plugins:end', Object.assign({ config }, this.#filesPayload()))
428
+
429
+ // Plugins-end listeners (barrel plugin etc.) may have queued more files.
430
+ await processor.drain()
431
+
432
+ await hooks.emit('kubb:build:end', { files: this.fileManager.files, config, outputDir: outputRoot })
433
+
434
+ return { diagnostics: Diagnostics.dedupe(diagnostics) }
435
+ } catch (caughtError) {
436
+ diagnostics.push(Diagnostics.from(caughtError))
437
+ return { diagnostics: Diagnostics.dedupe(diagnostics) }
438
+ } finally {
439
+ this.fileManager.hooks.off('upsert', onFileUpsert)
440
+ }
441
+ },
442
+ )
443
+ }
444
+
445
+ // Returns a fresh object with a lazy `files` getter and a bound `upsertFile`.
446
+ // Caller must use `Object.assign(extra, this.#filesPayload())`, not object spread.
447
+ // Spread would eagerly invoke the getter and freeze a stale snapshot into the payload.
448
+ #filesPayload(): { readonly files: Array<FileNode>; upsertFile: (...files: Array<FileNode>) => Array<FileNode> } {
449
+ const driver = this
450
+
451
+ return {
452
+ get files() {
453
+ return driver.fileManager.files
454
+ },
455
+ upsertFile: (...files: Array<FileNode>) => driver.fileManager.upsert(...files),
456
+ }
457
+ }
458
+
459
+ #emitPluginEnd({ plugin, duration, success, error }: { plugin: NormalizedPlugin; duration: number; success: boolean; error?: Error }): Promise<void> | void {
460
+ return this.hooks.emit(
461
+ 'kubb:plugin:end',
462
+ Object.assign({ plugin, duration, success, ...(error ? { error } : {}), config: this.config }, this.#filesPayload()),
463
+ )
464
+ }
465
+
466
+ /**
467
+ * Streams schemas and operations through every plugin's generators. Each node is run
468
+ * through the plugin's transformer (from `this.#transforms`) before the generator sees it,
469
+ * so plugins stay isolated and the hot path stays per-node. Schemas run before operations
470
+ * because the two passes share `flushPending` and the FileProcessor's event emitter.
471
+ * A failing plugin contributes an error diagnostic so the rest of the build continues.
472
+ * Every plugin also contributes a `timing` diagnostic.
473
+ *
474
+ * Plugins run sequentially so `kubb:plugin:end` fires as each plugin completes, instead
475
+ * of all at once after every plugin has marched through the parallel batches together.
476
+ * That ordering is what drives the CLI's `Plugins N/M` counter. Without it the bar would
477
+ * sit at the initial value until the very end of the run.
478
+ *
479
+ * When `entries` is empty or `this.inputNode` is `null`, every entry still gets a
480
+ * `kubb:plugin:end` so post-plugin listeners (the barrel writer and friends) complete.
481
+ */
482
+ async #runGenerators(
483
+ entries: Array<{ plugin: NormalizedPlugin; context: Omit<GeneratorContext, 'options'>; hrStart: ReturnType<typeof process.hrtime> }>,
484
+ flushPending: () => Promise<void>,
485
+ ): Promise<Array<Diagnostic>> {
486
+ const diagnostics: Array<Diagnostic> = []
487
+
488
+ if (entries.length === 0) return diagnostics
489
+
490
+ if (!this.inputNode) {
491
+ for (const { plugin, hrStart } of entries) {
492
+ const duration = getElapsedMs(hrStart)
493
+ diagnostics.push(Diagnostics.performance({ plugin: plugin.name, duration }))
494
+ await this.#emitPluginEnd({ plugin, duration, success: true })
495
+ }
496
+ return diagnostics
497
+ }
498
+
499
+ const transforms = this.#transforms
500
+ const { schemas, operations } = this.inputNode
501
+
502
+ type PluginState = {
503
+ plugin: NormalizedPlugin
504
+ generatorContext: Omit<GeneratorContext, 'options'>
505
+ generators: Array<Generator>
506
+ hrStart: ReturnType<typeof process.hrtime>
507
+ failed: boolean
508
+ error: Error | null
509
+ optionsAreStatic: boolean
510
+ allowedSchemaNames: Set<string> | null
511
+ }
512
+
513
+ const states: Array<PluginState> = entries.map(({ plugin, context, hrStart }) => {
514
+ const { exclude, include, override } = plugin.options
515
+ const hasExclude = Array.isArray(exclude) && exclude.length > 0
516
+ const hasInclude = Array.isArray(include) && include.length > 0
517
+ const hasOverride = Array.isArray(override) && override.length > 0
518
+ return {
519
+ plugin,
520
+ generatorContext: { ...context, resolver: this.getResolver(plugin.name) },
521
+ generators: plugin.generators ?? [],
522
+ hrStart,
523
+ failed: false,
524
+ error: null,
525
+ optionsAreStatic: !hasExclude && !hasInclude && !hasOverride,
526
+ allowedSchemaNames: null,
527
+ }
528
+ })
529
+
530
+ const emitsSchemaHook = this.hooks.listenerCount('kubb:generate:schema') > 0
531
+ const emitsOperationHook = this.hooks.listenerCount('kubb:generate:operation') > 0
532
+ const emitsOperationsHook = this.hooks.listenerCount('kubb:generate:operations') > 0
533
+
534
+ // Buffer the streaming adapter's nodes once. Each plugin reads the same buffer
535
+ // instead of re-parsing the document per pass, and the pruning pre-scan below
536
+ // shares it too (previously it iterated its own copies).
537
+ const schemasBuffer: Array<SchemaNode> = await Array.fromAsync(schemas)
538
+ const operationsBuffer: Array<OperationNode> = await Array.fromAsync(operations)
539
+
540
+ // Pre-scan: plugins with operation-based includes (but no schemaName include) need
541
+ // the reachable schema set. This requires the full schema graph in memory at once,
542
+ // since transitive reachability can't be derived from a single node.
543
+ const pruningStates = states.filter(({ plugin }) => {
544
+ const { include } = plugin.options
545
+ return (include?.some(({ type }) => OPERATION_FILTER_TYPES.has(type)) ?? false) && !(include?.some(({ type }) => type === 'schemaName') ?? false)
546
+ })
547
+
548
+ if (pruningStates.length > 0) {
549
+ const includedOpsByState = new Map<PluginState, Array<OperationNode>>(pruningStates.map((state) => [state, []]))
550
+ for (const operation of operationsBuffer) {
551
+ for (const state of pruningStates) {
552
+ const { exclude, include, override } = state.plugin.options
553
+ const options = state.generatorContext.resolver.resolveOptions(operation, { options: state.plugin.options, exclude, include, override })
554
+ if (options !== null) includedOpsByState.get(state)?.push(operation)
555
+ }
556
+ }
557
+
558
+ for (const state of pruningStates) {
559
+ state.allowedSchemaNames = collectUsedSchemaNames(includedOpsByState.get(state) ?? [], schemasBuffer)
560
+ includedOpsByState.delete(state)
561
+ }
562
+ }
563
+
564
+ // Apply the plugin's transformer, then resolve options (skipping the resolver when
565
+ // optionsAreStatic). Returns null when include/exclude/override rules out the node.
566
+ // The per-node dispatch and the collected-operations tail both go through this so
567
+ // they agree on what a plugin sees.
568
+ const resolveForPlugin = <TNode extends SchemaNode | OperationNode>(
569
+ state: PluginState,
570
+ node: TNode,
571
+ ): { transformedNode: TNode; options: NormalizedPlugin['options'] } | null => {
572
+ const { plugin, generatorContext } = state
573
+ const transformedNode = transforms.applyTo(plugin.name, node)
574
+ if (state.optionsAreStatic) return { transformedNode, options: plugin.options }
575
+
576
+ const { exclude, include, override } = plugin.options
577
+ const options = generatorContext.resolver.resolveOptions(transformedNode, { options: plugin.options, exclude, include, override })
578
+ if (options === null) return null
579
+ return { transformedNode, options }
580
+ }
581
+
582
+ // Schema and operation passes share this body. They differ only in which generator
583
+ // method runs, which hook is emitted, and the schema-only `allowedSchemaNames` prune
584
+ // (operations don't carry that constraint).
585
+ const dispatchNode = async <TNode extends SchemaNode | OperationNode>(
586
+ state: PluginState,
587
+ node: TNode,
588
+ dispatch: {
589
+ method: 'schema' | 'operation'
590
+ checkAllowedNames: boolean
591
+ emit: ((node: TNode, ctx: GeneratorContext) => Promise<void> | void) | null
592
+ },
593
+ ): Promise<void> => {
594
+ if (state.failed) return
595
+ try {
596
+ const resolved = resolveForPlugin(state, node)
597
+ if (!resolved) return
598
+
599
+ const { transformedNode, options } = resolved
600
+ if (
601
+ dispatch.checkAllowedNames &&
602
+ state.allowedSchemaNames !== null &&
603
+ 'name' in transformedNode &&
604
+ transformedNode.name &&
605
+ !state.allowedSchemaNames.has(transformedNode.name)
606
+ ) {
607
+ return
608
+ }
609
+
610
+ const ctx = { ...state.generatorContext, options }
611
+ for (const gen of state.generators) {
612
+ const run = gen[dispatch.method] as ((node: TNode, ctx: GeneratorContext) => unknown) | undefined
613
+ if (!run) continue
614
+ const raw = run(transformedNode, ctx)
615
+ const result = isPromise(raw) ? await raw : raw
616
+ const applied = this.dispatch({ result, renderer: gen.renderer })
617
+ if (isPromise(applied)) await applied
618
+ }
619
+ if (dispatch.emit) await dispatch.emit(transformedNode, ctx)
620
+ } catch (caughtError) {
621
+ state.failed = true
622
+ state.error = caughtError as Error
623
+ }
624
+ }
625
+
626
+ const schemaDispatch = {
627
+ method: 'schema',
628
+ checkAllowedNames: true,
629
+ emit: emitsSchemaHook ? (node: SchemaNode, ctx: GeneratorContext) => this.hooks.emit('kubb:generate:schema', node, ctx) : null,
630
+ } as const
631
+ const operationDispatch = {
632
+ method: 'operation',
633
+ checkAllowedNames: false,
634
+ emit: emitsOperationHook ? (node: OperationNode, ctx: GeneratorContext) => this.hooks.emit('kubb:generate:operation', node, ctx) : null,
635
+ } as const
636
+
637
+ for (const state of states) {
638
+ // Skip building the aggregated operations array when this plugin doesn't consume it.
639
+ // Saves an N-sized allocation when the plugin only defines per-node `gen.operation`.
640
+ const needsCollectedOperations = emitsOperationsHook || state.generators.some((gen) => !!gen.operations)
641
+ const collectedOperations: Array<OperationNode> | undefined = needsCollectedOperations ? [] : undefined
642
+
643
+ // Run schemas before operations: the two passes share `flushPending` and the
644
+ // FileProcessor's event emitter, so running them concurrently would interleave
645
+ // `kubb:files:processing:start|end` events and race on the shared dirty list.
646
+ await forBatches(schemasBuffer, (nodes) => Promise.all(nodes.map((node) => dispatchNode(state, node, schemaDispatch))), {
647
+ concurrency: SCHEMA_PARALLEL,
648
+ flush: flushPending,
649
+ })
650
+
651
+ await forBatches(
652
+ operationsBuffer,
653
+ (nodes) => {
654
+ if (needsCollectedOperations) collectedOperations?.push(...nodes)
655
+ return Promise.all(nodes.map((node) => dispatchNode(state, node, operationDispatch)))
656
+ },
657
+ { concurrency: SCHEMA_PARALLEL, flush: flushPending },
658
+ )
659
+
660
+ if (!state.failed && needsCollectedOperations) {
661
+ try {
662
+ const { plugin, generatorContext, generators } = state
663
+ const ctx = { ...generatorContext, options: plugin.options }
664
+ // Match what the per-node dispatch passes to gen.operation(): the transformed node,
665
+ // already filtered by excludes/includes/overrides.
666
+ const ops = collectedOperations ?? []
667
+ const pluginOperations = ops.reduce<Array<OperationNode>>((acc, node) => {
668
+ const resolved = resolveForPlugin(state, node)
669
+ if (resolved) acc.push(resolved.transformedNode)
670
+ return acc
671
+ }, [])
672
+ for (const gen of generators) {
673
+ if (!gen.operations) continue
674
+ const result = await gen.operations(pluginOperations, ctx)
675
+ await this.dispatch({ result, renderer: gen.renderer })
676
+ }
677
+ await this.hooks.emit('kubb:generate:operations', pluginOperations, ctx)
678
+ } catch (caughtError) {
679
+ state.failed = true
680
+ state.error = caughtError as Error
681
+ }
682
+ }
683
+
684
+ const duration = getElapsedMs(state.hrStart)
685
+ await this.#emitPluginEnd({ plugin: state.plugin, duration, success: !state.failed, error: state.failed && state.error ? state.error : undefined })
686
+
687
+ if (state.failed && state.error) {
688
+ diagnostics.push({ ...Diagnostics.from(state.error), plugin: state.plugin.name })
689
+ }
690
+ diagnostics.push(Diagnostics.performance({ plugin: state.plugin.name, duration }))
691
+ }
692
+
693
+ return diagnostics
694
+ }
695
+
696
+ /**
697
+ * Stores whatever a generator method or `kubb:generate:*` hook returned.
698
+ *
699
+ * - An `Array<FileNode>` goes straight into `fileManager` via `upsert`.
700
+ * - A renderer element runs through `renderer` (the renderer factory, e.g. JSX) and the
701
+ * produced files go to `fileManager.upsert`.
702
+ * - A falsy result is treated as a no-op. The generator wrote files itself via
703
+ * `ctx.upsertFile`.
704
+ *
705
+ * Pass `renderer` when the result may be a renderer element. Generators that only return
706
+ * `Array<FileNode>` do not need one.
707
+ */
708
+ async dispatch<TElement = unknown>({
709
+ result,
710
+ renderer,
711
+ }: {
712
+ result: TElement | Array<FileNode> | undefined | null
713
+ renderer?: RendererFactory<TElement> | null
714
+ }): Promise<void> {
715
+ if (!result) return
716
+
717
+ if (Array.isArray(result)) {
718
+ this.fileManager.upsert(...(result as Array<FileNode>))
719
+ return
720
+ }
721
+
722
+ if (!renderer) {
723
+ return
724
+ }
725
+
726
+ using instance = renderer()
727
+ if (instance.stream) {
728
+ for (const file of instance.stream(result)) {
729
+ this.fileManager.upsert(file)
730
+ }
731
+ return
732
+ }
733
+
734
+ await instance.render(result)
735
+ this.fileManager.upsert(...instance.files)
736
+ }
737
+
738
+ /**
739
+ * Removes every listener the driver added. Listeners attached directly to `hooks` from outside
740
+ * the driver survive. Called at the end of a build to prevent leaks across repeated builds.
741
+ *
742
+ * @internal
743
+ */
744
+ dispose(): void {
745
+ for (const [event, handler] of this.#listeners) {
746
+ this.hooks.off(event, handler as HookListener<KubbHooks[typeof event]>)
747
+ }
748
+ this.#listeners.length = 0
749
+ this.#eventGeneratorPlugins.clear()
750
+ this.#transforms.dispose()
751
+ // Release resolver closures. The driver is rebuilt for each build() call
752
+ // so there is no value in retaining these maps after disposal.
753
+ this.#resolvers.clear()
754
+ this.#defaultResolvers.clear()
755
+ // Release the FileNode cache and parsed adapter graph so memory is reclaimed
756
+ // between builds. The returned `BuildOutput.files` array still references any
757
+ // FileNodes the caller needs to inspect.
758
+ this.fileManager.dispose()
759
+ this.inputNode = null
760
+ this.#adapterSource = null
761
+ }
762
+
763
+ [Symbol.dispose](): void {
764
+ this.dispose()
765
+ }
766
+
767
+ #getDefaultResolver = memoize(
768
+ this.#defaultResolvers,
769
+ (pluginName: string): Resolver => defineResolver<PluginFactoryOptions>(() => ({ name: 'default', pluginName })),
770
+ )
771
+
772
+ /**
773
+ * Merges `partial` with the plugin's default resolver and stores the result.
774
+ * Also mirrors it onto `plugin.resolver` so callers using `getPlugin(name).resolver`
775
+ * get the up-to-date resolver without going through `getResolver()`.
776
+ */
777
+ setPluginResolver(pluginName: string, partial: Partial<Resolver>): void {
778
+ const defaultResolver = this.#getDefaultResolver(pluginName)
779
+ const merged = { ...defaultResolver, ...partial }
780
+ this.#resolvers.set(pluginName, merged)
781
+ const plugin = this.plugins.get(pluginName)
782
+ if (plugin) {
783
+ plugin.resolver = merged
784
+ }
785
+ }
786
+
787
+ /**
788
+ * Returns the resolver for the given plugin.
789
+ *
790
+ * Resolution order: dynamic resolver set via `setPluginResolver` → static resolver on the
791
+ * plugin → lazily created default resolver (identity name, no path transforms).
792
+ */
793
+ getResolver<TName extends keyof Kubb.PluginRegistry>(pluginName: TName): Kubb.PluginRegistry[TName]['resolver']
794
+ getResolver<TResolver extends Resolver = Resolver>(pluginName: string): TResolver
795
+ getResolver(pluginName: string): Resolver {
796
+ return this.#resolvers.get(pluginName) ?? this.plugins.get(pluginName)?.resolver ?? this.#getDefaultResolver(pluginName)
797
+ }
798
+
799
+ getContext<TOptions extends PluginFactoryOptions>(plugin: NormalizedPlugin<TOptions>): Omit<GeneratorContext<TOptions>, 'options'> {
800
+ const driver = this
801
+
802
+ // Collect into the active build only. The host renders each collected diagnostic once after the
803
+ // build (the CLI via `Diagnostics.emit`, the agent via its post-build loop), so emitting a live
804
+ // `kubb:error`/`kubb:warn`/`kubb:info` here would render it twice.
805
+ const report = (diagnostic: Omit<ProblemDiagnostic, 'plugin'>): void => {
806
+ Diagnostics.report({ ...diagnostic, plugin: plugin.name })
807
+ }
808
+
809
+ return {
810
+ config: driver.config,
811
+ get root(): string {
812
+ return resolve(driver.config.root, driver.config.output.path)
813
+ },
814
+ hooks: driver.hooks,
815
+ plugin,
816
+ getPlugin: driver.getPlugin.bind(driver),
817
+ // Close over the owning plugin so a missing dependency error names who required it.
818
+ requirePlugin: ((name: string) => driver.requirePlugin(name, { requiredBy: plugin.name })) as GeneratorContext<TOptions>['requirePlugin'],
819
+ getResolver: driver.getResolver.bind(driver),
820
+ driver,
821
+ addFile: async (...files: Array<FileNode>) => {
822
+ driver.fileManager.add(...files)
823
+ },
824
+ upsertFile: async (...files: Array<FileNode>) => {
825
+ driver.fileManager.upsert(...files)
826
+ },
827
+ get meta(): InputMeta {
828
+ return driver.inputNode?.meta ?? { circularNames: [], enumNames: [] }
829
+ },
830
+ get adapter(): Adapter {
831
+ // Generators only read `adapter` during AST hooks, which run after the
832
+ // adapter is set, so it is guaranteed defined at read time.
833
+ return driver.adapter!
834
+ },
835
+ get resolver() {
836
+ return driver.getResolver(plugin.name)
837
+ },
838
+ get transformer() {
839
+ return driver.#transforms.get(plugin.name)
840
+ },
841
+ warn(message: string) {
842
+ report({ code: Diagnostics.code.pluginWarning, severity: 'warning', message })
843
+ },
844
+ error(error: string | Error) {
845
+ const cause = typeof error === 'string' ? undefined : error
846
+ report({ code: Diagnostics.code.pluginFailed, severity: 'error', message: typeof error === 'string' ? error : error.message, cause })
847
+ },
848
+ info(message: string) {
849
+ report({ code: Diagnostics.code.pluginInfo, severity: 'info', message })
850
+ },
851
+ }
852
+ }
853
+
854
+ getPlugin<TName extends keyof Kubb.PluginRegistry>(pluginName: TName): Plugin<Kubb.PluginRegistry[TName]> | undefined
855
+ getPlugin<TOptions extends PluginFactoryOptions = PluginFactoryOptions>(pluginName: string): Plugin<TOptions> | undefined
856
+ getPlugin(pluginName: string): Plugin | undefined {
857
+ return this.plugins.get(pluginName)
858
+ }
859
+
860
+ /**
861
+ * Like `getPlugin` but throws a descriptive error when the plugin is not found.
862
+ */
863
+ requirePlugin<TName extends keyof Kubb.PluginRegistry>(pluginName: TName, context?: RequirePluginContext): Plugin<Kubb.PluginRegistry[TName]>
864
+ requirePlugin<TOptions extends PluginFactoryOptions = PluginFactoryOptions>(pluginName: string, context?: RequirePluginContext): Plugin<TOptions>
865
+ requirePlugin(pluginName: string, context?: RequirePluginContext): Plugin {
866
+ const plugin = this.plugins.get(pluginName)
867
+ if (!plugin) {
868
+ const requiredBy = context?.requiredBy
869
+ throw new Diagnostics.Error({
870
+ code: Diagnostics.code.pluginNotFound,
871
+ severity: 'error',
872
+ message: requiredBy
873
+ ? `Plugin "${pluginName}" is required by "${requiredBy}" but not found. Make sure it is included in your Kubb config.`
874
+ : `Plugin "${pluginName}" is required but not found. Make sure it is included in your Kubb config.`,
875
+ help: requiredBy
876
+ ? `Add "${pluginName}" to the \`plugins\` array in kubb.config.ts (required by "${requiredBy}"), or remove the dependency on it.`
877
+ : `Add "${pluginName}" to the \`plugins\` array in kubb.config.ts, or remove the dependency on it.`,
878
+ location: { kind: 'config' },
879
+ })
880
+ }
881
+ return plugin
882
+ }
883
+ }
884
+
885
+ function inputToAdapterSource(config: Config): AdapterSource {
886
+ const input = config.input
887
+ if (!input) {
888
+ throw new Diagnostics.Error({
889
+ code: Diagnostics.code.inputRequired,
890
+ severity: 'error',
891
+ message: 'An adapter is configured without an input.',
892
+ help: 'Provide `input.path` (a file or URL) or `input.data` (an inline spec) in your Kubb config.',
893
+ location: { kind: 'config' },
894
+ })
895
+ }
896
+
897
+ if ('data' in input) {
898
+ return { type: 'data', data: input.data }
899
+ }
900
+
901
+ if (Url.canParse(input.path)) {
902
+ return { type: 'path', path: input.path }
903
+ }
904
+
905
+ const resolved = resolve(config.root, input.path)
906
+
907
+ return { type: 'path', path: resolved }
908
+ }