@kubb/core 5.0.0-beta.36 → 5.0.0-beta.37

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/src/KubbDriver.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { resolve } from 'node:path'
2
- import { arrayToAsyncIterable, type AsyncEventEmitter, forBatches, formatMs, getElapsedMs, isPromise, memoize, URLPath } from '@internals/utils'
3
- import { collectUsedSchemaNames, createFile, createStreamInput, transform } from '@kubb/ast'
2
+ import { arrayToAsyncIterable, type AsyncEventEmitter, forBatches, getElapsedMs, isPromise, memoize, URLPath } from '@internals/utils'
3
+ import { collectUsedSchemaNames, createFile, createStreamInput } from '@kubb/ast'
4
4
  import type { FileNode, InputMeta, InputNode, InputStreamNode, OperationNode, SchemaNode } from '@kubb/ast'
5
- import { DEFAULT_STUDIO_URL, SCHEMA_PARALLEL, STREAM_FLUSH_EVERY } from './constants.ts'
5
+ import { DEFAULT_STUDIO_URL, diagnosticCode, OPERATION_FILTER_TYPES, SCHEMA_PARALLEL } from './constants.ts'
6
+ import { type Diagnostic, DiagnosticError, Diagnostics, type ProblemDiagnostic } from './diagnostics.ts'
7
+ import type { RendererFactory } from './createRenderer.ts'
6
8
  import type { Storage } from './createStorage.ts'
7
9
  import type { Generator } from './defineGenerator.ts'
8
10
  import type { Parser } from './defineParser.ts'
@@ -12,7 +14,8 @@ import { defineResolver } from './defineResolver.ts'
12
14
  import { openInStudio as openInStudioFn } from './devtools.ts'
13
15
  import { FileManager } from './FileManager.ts'
14
16
  import { FileProcessor } from './FileProcessor.ts'
15
- import type { Renderer, RendererFactory } from './createRenderer.ts'
17
+ import { type HookListener, HookRegistry } from './HookRegistry.ts'
18
+ import { Transform } from './Transform.ts'
16
19
 
17
20
  import type {
18
21
  Adapter,
@@ -36,8 +39,6 @@ function enforceOrder(enforce: 'pre' | 'post' | undefined): number {
36
39
  return enforce === 'pre' ? -1 : enforce === 'post' ? 1 : 0
37
40
  }
38
41
 
39
- const OPERATION_FILTER_TYPES = new Set(['tag', 'operationId', 'path', 'method', 'contentType'])
40
-
41
42
  export class KubbDriver {
42
43
  readonly config: Config
43
44
  readonly options: Options
@@ -57,7 +58,7 @@ export class KubbDriver {
57
58
 
58
59
  /**
59
60
  * The streaming `InputStreamNode` produced by the adapter.
60
- * Always set after adapter setup parse-only adapters are wrapped automatically.
61
+ * Always set after adapter setup, parse-only adapters are wrapped automatically.
61
62
  */
62
63
  inputNode: InputStreamNode | null = null
63
64
  adapter: Adapter | null = null
@@ -65,7 +66,7 @@ export class KubbDriver {
65
66
  * Studio session state, kept together so `dispose()` can reset it atomically.
66
67
  *
67
68
  * - `source` holds the raw adapter source so `adapter.parse()` can be called lazily.
68
- * Intentionally outlives the build; cleared by `dispose()`.
69
+ * Intentionally outlives the build, cleared by `dispose()`.
69
70
  * - `isOpen` prevents opening the studio more than once per build.
70
71
  * - `inputNode` caches the parse promise so `adapter.parse()` is called at most once
71
72
  * per studio session, even when `openInStudio()` is called multiple times.
@@ -76,21 +77,12 @@ export class KubbDriver {
76
77
  inputNode: null,
77
78
  }
78
79
 
79
- // Register middleware hooks after all plugin hooks are registered.
80
- // Because AsyncEventEmitter calls listeners in registration order,
81
- // middleware hooks for any event fire after all plugin hooks for that event.
82
- // Handlers are tracked so they can be removed after each build (disposeMiddleware),
83
- // preventing accumulation when multiple configs share the same hooks instance.
84
- #middlewareListeners: Array<[keyof KubbHooks & string, (...args: Array<never>) => void | Promise<void>]> = []
85
-
86
80
  /**
87
81
  * Central file store for all generated files.
88
82
  * Plugins should use `this.addFile()` / `this.upsertFile()` (via their context) to
89
- * add files; this property gives direct read/write access when needed.
83
+ * add files. This property gives direct read/write access when needed.
90
84
  */
91
85
  readonly fileManager = new FileManager()
92
- readonly #fileProcessor = new FileProcessor()
93
-
94
86
  readonly plugins = new Map<string, NormalizedPlugin>()
95
87
 
96
88
  /**
@@ -100,12 +92,25 @@ export class KubbDriver {
100
92
  readonly #eventGeneratorPlugins = new Set<string>()
101
93
  readonly #resolvers = new Map<string, Resolver>()
102
94
  readonly #defaultResolvers = new Map<string, Resolver>()
103
- readonly #hookListeners = new Map<keyof KubbHooks, Set<(...args: Array<never>) => void | Promise<void>>>()
95
+
96
+ /**
97
+ * Tracks every listener the driver added (plugin, middleware, generator) so `dispose()` can
98
+ * remove them in one pass. Middleware registers after plugins, so it fires last via `Set`
99
+ * insertion order. External `hooks.on(...)` listeners are not tracked.
100
+ */
101
+ readonly #registry: HookRegistry<KubbHooks>
102
+
103
+ /**
104
+ * Transform registry. Plugins populate it during `kubb:plugin:setup` via `setTransformer`,
105
+ * and `#runGenerators` reads it once per `(plugin, node)` pair through `applyTo`.
106
+ */
107
+ readonly #transforms = new Transform()
104
108
 
105
109
  constructor(config: Config, options: Options) {
106
110
  this.config = config
107
111
  this.options = options
108
112
  this.adapter = config.adapter ?? null
113
+ this.#registry = new HookRegistry({ emitter: options.hooks })
109
114
  }
110
115
 
111
116
  async setup() {
@@ -135,7 +140,7 @@ export class KubbDriver {
135
140
  }
136
141
  }
137
142
  if (this.config.adapter) {
138
- await this.#registerAdapter(this.config.adapter)
143
+ this.#studio.source = inputToAdapterSource(this.config)
139
144
  }
140
145
  }
141
146
 
@@ -163,32 +168,25 @@ export class KubbDriver {
163
168
  return normalized
164
169
  }
165
170
 
166
- async #registerAdapter(adapter: Adapter) {
167
- const source = inputToAdapterSource(this.config)
168
- this.#studio.source = source
171
+ /**
172
+ * Parses the adapter source into `this.inputNode`. Idempotent, so repeated calls from
173
+ * `run` or the studio path do not re-parse. Adapters with `stream()` are used directly.
174
+ * Adapters with only `parse()` are wrapped via `createStreamInput` so the dispatch loop
175
+ * stays stream-only.
176
+ */
177
+ async #parseInput(): Promise<void> {
178
+ if (this.inputNode || !this.adapter || !this.#studio.source) return
179
+
180
+ const adapter = this.adapter
181
+ const source = this.#studio.source
169
182
 
170
183
  if (adapter.stream) {
171
184
  this.inputNode = await adapter.stream(source)
172
-
173
- await this.hooks.emit('kubb:debug', {
174
- date: new Date(),
175
- logs: [`✓ Adapter '${adapter.name}' producing input stream`],
176
- })
177
- } else {
178
- // Adapter does not implement stream() — eagerly parse and wrap in a
179
- // reusable AsyncIterable so the rest of the pipeline stays stream-only.
180
- const inputNode = await adapter.parse(source)
181
- this.inputNode = createStreamInput(arrayToAsyncIterable(inputNode.schemas), arrayToAsyncIterable(inputNode.operations), inputNode.meta)
182
-
183
- await this.hooks.emit('kubb:debug', {
184
- date: new Date(),
185
- logs: [
186
- `✓ Adapter '${adapter.name}' resolved InputNode (wrapped as stream)`,
187
- ` • Schemas: ${inputNode.schemas.length}`,
188
- ` • Operations: ${inputNode.operations.length}`,
189
- ],
190
- })
185
+ return
191
186
  }
187
+
188
+ const parsed = await adapter.parse(source)
189
+ this.inputNode = createStreamInput(arrayToAsyncIterable(parsed.schemas), arrayToAsyncIterable(parsed.operations), parsed.meta)
192
190
  }
193
191
 
194
192
  #registerMiddleware<K extends keyof KubbHooks & string>(event: K, middlewareHooks: Middleware['hooks']) {
@@ -198,22 +196,16 @@ export class KubbDriver {
198
196
  return
199
197
  }
200
198
 
201
- this.hooks.on(event, handler)
202
- this.#middlewareListeners.push([event, handler as (...args: Array<never>) => void | Promise<void>])
199
+ this.#registry.register({ event, handler, source: 'middleware' })
203
200
  }
204
201
 
205
202
  /**
206
203
  * Registers a hook-style plugin's lifecycle handlers on the shared `AsyncEventEmitter`.
207
204
  *
208
- * For `kubb:plugin:setup`, the registered listener wraps the globally emitted context with a
209
- * plugin-specific one so that `addGenerator`, `setResolver`, `setTransformer`, and
210
- * `setRenderer` all target the correct `normalizedPlugin` entry in the plugins map.
211
- *
212
- * All other hooks are iterated and registered directly as pass-through listeners.
213
- * Any event key present in the global `KubbHooks` interface can be subscribed to.
214
- *
215
- * External tooling can subscribe to any of these events via `hooks.on(...)` to observe
216
- * the plugin lifecycle without modifying plugin behavior.
205
+ * The `kubb:plugin:setup` listener wraps the global context in a plugin-specific one so
206
+ * `addGenerator`, `setResolver`, and `setTransformer` target the right `normalizedPlugin`.
207
+ * Every other `KubbHooks` event registers as a pass-through listener that external tooling
208
+ * can observe via `hooks.on(...)`.
217
209
  *
218
210
  * @internal
219
211
  */
@@ -237,10 +229,7 @@ export class KubbDriver {
237
229
  this.setPluginResolver(plugin.name, resolver)
238
230
  },
239
231
  setTransformer: (visitor) => {
240
- plugin.transformer = visitor
241
- },
242
- setRenderer: (renderer) => {
243
- plugin.renderer = renderer
232
+ this.#transforms.register(plugin.name, visitor)
244
233
  },
245
234
  setOptions: (opts) => {
246
235
  plugin.options = { ...plugin.options, ...opts }
@@ -252,16 +241,20 @@ export class KubbDriver {
252
241
  return hooks['kubb:plugin:setup']!(pluginCtx)
253
242
  }
254
243
 
255
- this.hooks.on('kubb:plugin:setup', setupHandler)
256
- this.#trackHookListener('kubb:plugin:setup', setupHandler as (...args: Array<never>) => void | Promise<void>)
244
+ this.#registry.register({ event: 'kubb:plugin:setup', handler: setupHandler, source: 'plugin' })
257
245
  }
258
246
 
259
247
  // All other hooks are registered as direct pass-through listeners on the shared emitter.
260
- for (const [event, handler] of Object.entries(hooks) as Array<[keyof KubbHooks, ((...args: Array<never>) => void | Promise<void>) | undefined]>) {
261
- if (event === 'kubb:plugin:setup' || !handler) continue
262
-
263
- this.hooks.on(event, handler as never)
264
- this.#trackHookListener(event, handler as (...args: Array<never>) => void | Promise<void>)
248
+ for (const event of Object.keys(hooks) as Array<keyof KubbHooks & string>) {
249
+ if (event === 'kubb:plugin:setup') continue
250
+ const handler = hooks[event]
251
+ if (!handler) continue
252
+
253
+ this.#registry.register({
254
+ event,
255
+ handler: handler as HookListener<KubbHooks[typeof event], unknown>,
256
+ source: 'plugin',
257
+ })
265
258
  }
266
259
  }
267
260
 
@@ -280,7 +273,6 @@ export class KubbDriver {
280
273
  addGenerator: noop,
281
274
  setResolver: noop,
282
275
  setTransformer: noop,
283
- setRenderer: noop,
284
276
  setOptions: noop,
285
277
  injectFile: noop,
286
278
  updateConfig: noop,
@@ -295,49 +287,42 @@ export class KubbDriver {
295
287
  * respectively. Each listener is scoped to the owning plugin via a `ctx.plugin.name` check
296
288
  * so that generators from different plugins do not cross-fire.
297
289
  *
298
- * The renderer resolution chain is: `generator.renderer plugin.renderer config.renderer`.
299
- * Set `generator.renderer = null` to explicitly opt out of rendering even when the plugin
300
- * declares a renderer.
290
+ * The renderer comes from `generator.renderer`. Set `generator.renderer = null` (or leave it
291
+ * unset) to opt out of rendering.
301
292
  *
302
293
  * Call this method inside `addGenerator()` (in `kubb:plugin:setup`) to wire up a generator.
303
294
  */
304
- registerGenerator(pluginName: string, gen: Generator): void {
305
- const resolveRenderer = () => {
306
- const plugin = this.plugins.get(pluginName)
307
- return gen.renderer === null ? undefined : (gen.renderer ?? plugin?.renderer ?? this.config.renderer)
308
- }
309
-
310
- if (gen.schema) {
295
+ registerGenerator(pluginName: string, generator: Generator): void {
296
+ if (generator.schema) {
311
297
  const schemaHandler = async (node: SchemaNode, ctx: GeneratorContext) => {
312
298
  if (ctx.plugin.name !== pluginName) return
313
- const result = await gen.schema!(node, ctx)
314
- await applyHookResult({ result, driver: this, rendererFactory: resolveRenderer() })
299
+ const result = await generator.schema!(node, ctx)
300
+
301
+ await this.dispatch({ result, renderer: generator.renderer })
315
302
  }
316
303
 
317
- this.hooks.on('kubb:generate:schema', schemaHandler)
318
- this.#trackHookListener('kubb:generate:schema', schemaHandler as (...args: Array<never>) => void | Promise<void>)
304
+ this.#registry.register({ event: 'kubb:generate:schema', handler: schemaHandler, source: 'driver' })
319
305
  }
320
306
 
321
- if (gen.operation) {
307
+ if (generator.operation) {
322
308
  const operationHandler = async (node: OperationNode, ctx: GeneratorContext) => {
323
309
  if (ctx.plugin.name !== pluginName) return
324
- const result = await gen.operation!(node, ctx)
325
- await applyHookResult({ result, driver: this, rendererFactory: resolveRenderer() })
310
+
311
+ const result = await generator.operation!(node, ctx)
312
+ await this.dispatch({ result, renderer: generator.renderer })
326
313
  }
327
314
 
328
- this.hooks.on('kubb:generate:operation', operationHandler)
329
- this.#trackHookListener('kubb:generate:operation', operationHandler as (...args: Array<never>) => void | Promise<void>)
315
+ this.#registry.register({ event: 'kubb:generate:operation', handler: operationHandler, source: 'driver' })
330
316
  }
331
317
 
332
- if (gen.operations) {
318
+ if (generator.operations) {
333
319
  const operationsHandler = async (nodes: Array<OperationNode>, ctx: GeneratorContext) => {
334
320
  if (ctx.plugin.name !== pluginName) return
335
- const result = await gen.operations!(nodes, ctx)
336
- await applyHookResult({ result, driver: this, rendererFactory: resolveRenderer() })
321
+ const result = await generator.operations!(nodes, ctx)
322
+ await this.dispatch({ result, renderer: generator.renderer })
337
323
  }
338
324
 
339
- this.hooks.on('kubb:generate:operations', operationsHandler)
340
- this.#trackHookListener('kubb:generate:operations', operationsHandler as (...args: Array<never>) => void | Promise<void>)
325
+ this.#registry.register({ event: 'kubb:generate:operations', handler: operationsHandler, source: 'driver' })
341
326
  }
342
327
 
343
328
  this.#eventGeneratorPlugins.add(pluginName)
@@ -355,19 +340,14 @@ export class KubbDriver {
355
340
  }
356
341
 
357
342
  /**
358
- * Runs the full plugin pipeline. Returns timings/failures collected so far even
359
- * when an outer hook throws the orchestrator preserves partial state by capturing
360
- * the error into `error` instead of propagating.
343
+ * Runs the full plugin pipeline. Returns the diagnostics collected so far even
344
+ * when an outer hook throws, since the orchestrator preserves partial state by capturing
345
+ * the failure as a {@link Diagnostic} instead of propagating. Each plugin also
346
+ * contributes a `timing` diagnostic for the run summary.
361
347
  */
362
- async run({ storage }: { storage: Storage }): Promise<{
363
- failedPlugins: Set<{ plugin: Plugin; error: Error }>
364
- pluginTimings: Map<string, number>
365
- error?: Error
366
- }> {
367
- const hooks = this.hooks
368
- const config = this.config
369
- const failedPlugins = new Set<{ plugin: Plugin; error: Error }>()
370
- const pluginTimings = new Map<string, number>()
348
+ async run({ storage }: { storage: Storage }): Promise<{ diagnostics: Array<Diagnostic> }> {
349
+ const { hooks, config } = this
350
+ const diagnostics: Array<Diagnostic> = []
371
351
  const parsersMap = new Map<FileNode['extname'], Parser>()
372
352
 
373
353
  for (const parser of config.parsers) {
@@ -376,116 +356,111 @@ export class KubbDriver {
376
356
  }
377
357
  }
378
358
 
379
- const pendingFiles = new Map<string, FileNode>()
380
- this.fileManager.setOnUpsert((file) => {
381
- pendingFiles.set(file.path, file)
359
+ const processor = new FileProcessor({ parsers: parsersMap, storage, extension: config.output.extension })
360
+ // Bridge processor lifecycle to the user-facing kubb hooks so existing listeners on
361
+ // kubb:files:processing:* keep firing.
362
+ processor.hooks.on('start', async (files) => {
363
+ await hooks.emit('kubb:files:processing:start', { files })
382
364
  })
365
+ const updateBuffer: Array<{ file: FileNode; source?: string; processed: number; total: number; percentage: number }> = []
366
+ processor.hooks.on('update', (item) => {
367
+ updateBuffer.push(item)
368
+ })
369
+ processor.hooks.on('end', async (files) => {
370
+ await hooks.emit('kubb:files:processing:update', {
371
+ files: updateBuffer.map((item) => ({ ...item, config })),
372
+ })
373
+ updateBuffer.length = 0
374
+ await hooks.emit('kubb:files:processing:end', { files })
375
+ })
376
+ const onFileUpsert = (file: FileNode): void => {
377
+ processor.enqueue(file)
378
+ }
379
+ this.fileManager.hooks.on('upsert', onFileUpsert)
383
380
 
384
- try {
385
- const flushPending = async (): Promise<void> => {
386
- if (pendingFiles.size === 0) return
387
- const files = [...pendingFiles.values()]
388
- pendingFiles.clear()
389
-
390
- await hooks.emit('kubb:debug', { date: new Date(), logs: [`Writing ${files.length} files...`] })
391
- await hooks.emit('kubb:files:processing:start', { files })
392
-
393
- const items = [...this.#fileProcessor.stream(files, { parsers: parsersMap, extension: config.output.extension })]
394
-
395
- await hooks.emit('kubb:files:processing:update', {
396
- files: items.map(({ file, source, processed, total, percentage }) => ({ file, source, processed, total, percentage, config })),
397
- })
398
-
399
- const queue: Array<Promise<void>> = []
400
- for (const { file, source } of items) {
401
- if (source) {
402
- queue.push(storage.setItem(file.path, source))
403
- if (queue.length >= STREAM_FLUSH_EVERY) await Promise.all(queue.splice(0))
381
+ // Make `diagnostics` the active sink so deep code (adapter parse, lazily consumed
382
+ // streams, generators) can report into this run via `Diagnostics.report`.
383
+ return Diagnostics.scope(
384
+ (diagnostic) => diagnostics.push(diagnostic),
385
+ async () => {
386
+ try {
387
+ // Parse the adapter source into the streaming `InputNode`.
388
+ await this.#parseInput()
389
+ // Emit `kubb:plugin:setup` so plugins can register transformers via `setTransformer`.
390
+ // Each call writes into `this.#transforms`, which `#runGenerators` later reads through
391
+ // `transforms.applyTo`.
392
+ await this.emitSetupHooks()
393
+
394
+ if (this.adapter && this.inputNode) {
395
+ await hooks.emit(
396
+ 'kubb:build:start',
397
+ Object.assign({ config, adapter: this.adapter, meta: this.inputNode.meta, getPlugin: this.getPlugin.bind(this) }, this.#filesPayload()),
398
+ )
404
399
  }
405
- }
406
- await Promise.all(queue)
407
400
 
408
- await hooks.emit('kubb:files:processing:end', { files })
409
- await hooks.emit('kubb:debug', { date: new Date(), logs: [`✓ File write process completed for ${files.length} files`] })
410
- }
401
+ const generatorPlugins: Array<{ plugin: NormalizedPlugin; context: Omit<GeneratorContext, 'options'>; hrStart: ReturnType<typeof process.hrtime> }> =
402
+ []
411
403
 
412
- await this.emitSetupHooks()
404
+ for (const plugin of this.plugins.values()) {
405
+ const context = this.getContext(plugin)
406
+ const hrStart = process.hrtime()
413
407
 
414
- if (this.adapter && this.inputNode) {
415
- await hooks.emit(
416
- 'kubb:build:start',
417
- Object.assign({ config, adapter: this.adapter, meta: this.inputNode.meta, getPlugin: this.getPlugin.bind(this) }, this.#filesPayload()),
418
- )
419
- }
408
+ try {
409
+ await hooks.emit('kubb:plugin:start', { plugin })
410
+ } catch (caughtError) {
411
+ const error = caughtError as Error
412
+ const duration = getElapsedMs(hrStart)
420
413
 
421
- const generatorPlugins: Array<{ plugin: NormalizedPlugin; context: Omit<GeneratorContext, 'options'>; hrStart: ReturnType<typeof process.hrtime> }> = []
414
+ await this.#emitPluginEnd({ plugin, duration, success: false, error })
422
415
 
423
- for (const plugin of this.plugins.values()) {
424
- const context = this.getContext(plugin)
425
- const hrStart = process.hrtime()
416
+ diagnostics.push({ ...Diagnostics.from(error), plugin: plugin.name }, Diagnostics.performance({ plugin: plugin.name, duration }))
426
417
 
427
- try {
428
- await hooks.emit('kubb:plugin:start', { plugin })
429
- await hooks.emit('kubb:debug', { date: new Date(), logs: ['Starting plugin...', ` • Plugin Name: ${plugin.name}`] })
430
- } catch (caughtError) {
431
- const error = caughtError as Error
432
- const duration = getElapsedMs(hrStart)
433
- pluginTimings.set(plugin.name, duration)
434
- await this.#emitPluginEnd({ plugin, duration, success: false, error })
435
- failedPlugins.add({ plugin, error })
436
- continue
437
- }
418
+ continue
419
+ }
438
420
 
439
- if (plugin.generators?.length || this.hasEventGenerators(plugin.name)) {
440
- generatorPlugins.push({ plugin, context, hrStart })
441
- continue
442
- }
421
+ if (this.hasEventGenerators(plugin.name)) {
422
+ generatorPlugins.push({ plugin, context, hrStart })
443
423
 
444
- const duration = getElapsedMs(hrStart)
445
- pluginTimings.set(plugin.name, duration)
446
- await this.#emitPluginEnd({ plugin, duration, success: true })
447
- await hooks.emit('kubb:debug', { date: new Date(), logs: [`✓ Plugin started successfully (${formatMs(duration)})`] })
448
- }
424
+ continue
425
+ }
449
426
 
450
- if (generatorPlugins.length > 0) {
451
- if (this.inputNode) {
452
- const { timings, failed } = await this.#runGenerators(generatorPlugins, flushPending)
453
- // Drain any files written after the last batch's flush.
454
- await flushPending()
455
- for (const [name, duration] of timings) pluginTimings.set(name, duration)
456
- for (const entry of failed) failedPlugins.add(entry)
457
- } else {
458
- // No adapter input: generator-plugins have nothing to dispatch, but still
459
- // need their `kubb:plugin:end` so middleware (e.g. barrel) completes.
460
- for (const { plugin, hrStart } of generatorPlugins) {
461
427
  const duration = getElapsedMs(hrStart)
462
- pluginTimings.set(plugin.name, duration)
428
+ diagnostics.push(Diagnostics.performance({ plugin: plugin.name, duration }))
429
+
463
430
  await this.#emitPluginEnd({ plugin, duration, success: true })
464
431
  }
465
- }
466
- }
467
432
 
468
- await hooks.emit('kubb:plugins:end', Object.assign({ config }, this.#filesPayload()))
433
+ // Stream every node through the transform registry and into each plugin's generators.
434
+ // Handles the empty-entries and missing-`inputNode` cases by closing out each entry's
435
+ // `kubb:plugin:end` directly.
436
+ diagnostics.push(...(await this.#runGenerators(generatorPlugins, () => processor.flush())))
437
+ // Wait for the last in-flight batch and write anything still pending.
438
+ await processor.drain()
469
439
 
470
- await flushPending()
440
+ await hooks.emit('kubb:plugins:end', Object.assign({ config }, this.#filesPayload()))
471
441
 
472
- const files = this.fileManager.files
442
+ // Plugins-end listeners (barrel middleware etc.) may have queued more files.
443
+ await processor.drain()
473
444
 
474
- await hooks.emit('kubb:build:end', { files, config, outputDir: resolve(config.root, config.output.path) })
445
+ await hooks.emit('kubb:build:end', { files: this.fileManager.files, config, outputDir: resolve(config.root, config.output.path) })
475
446
 
476
- return { failedPlugins, pluginTimings }
477
- } catch (caughtError) {
478
- return { failedPlugins, pluginTimings, error: caughtError as Error }
479
- } finally {
480
- this.fileManager.setOnUpsert(null)
481
- }
447
+ return { diagnostics: Diagnostics.dedupe(diagnostics) }
448
+ } catch (caughtError) {
449
+ diagnostics.push(Diagnostics.from(caughtError))
450
+ return { diagnostics: Diagnostics.dedupe(diagnostics) }
451
+ } finally {
452
+ this.fileManager.hooks.off('upsert', onFileUpsert)
453
+ }
454
+ },
455
+ )
482
456
  }
483
457
 
484
458
  // Returns a fresh object with a lazy `files` getter and a bound `upsertFile`.
485
- // Caller must use `Object.assign(extra, this.#filesPayload())`, not object spread
486
- // spread would eagerly invoke the getter and freeze a stale snapshot into the payload.
459
+ // Caller must use `Object.assign(extra, this.#filesPayload())`, not object spread.
460
+ // Spread would eagerly invoke the getter and freeze a stale snapshot into the payload.
487
461
  #filesPayload(): { readonly files: Array<FileNode>; upsertFile: (...files: Array<FileNode>) => Array<FileNode> } {
488
462
  const driver = this
463
+
489
464
  return {
490
465
  get files() {
491
466
  return driver.fileManager.files
@@ -501,12 +476,37 @@ export class KubbDriver {
501
476
  )
502
477
  }
503
478
 
479
+ /**
480
+ * Streams schemas and operations through every plugin's generators. Each node is run
481
+ * through the plugin's transformer (from `this.#transforms`) before the generator sees it,
482
+ * so plugins stay isolated and the hot path stays per-node. Schemas run before operations
483
+ * because the two passes share `flushPending` and the FileProcessor's event emitter.
484
+ * A failing plugin contributes an error diagnostic so the rest of the build continues.
485
+ * Every plugin also contributes a `timing` diagnostic.
486
+ *
487
+ * When `entries` is empty or `this.inputNode` is `null`, every entry still gets a
488
+ * `kubb:plugin:end` so middleware listeners (the barrel writer and friends) complete.
489
+ */
504
490
  async #runGenerators(
505
491
  entries: Array<{ plugin: NormalizedPlugin; context: Omit<GeneratorContext, 'options'>; hrStart: ReturnType<typeof process.hrtime> }>,
506
492
  flushPending: () => Promise<void>,
507
- ): Promise<{ timings: Map<string, number>; failed: Set<{ plugin: Plugin; error: Error }> }> {
508
- const timings = new Map<string, number>()
509
- const failed = new Set<{ plugin: Plugin; error: Error }>()
493
+ ): Promise<Array<Diagnostic>> {
494
+ const diagnostics: Array<Diagnostic> = []
495
+
496
+ if (entries.length === 0) return diagnostics
497
+
498
+ if (!this.inputNode) {
499
+ for (const { plugin, hrStart } of entries) {
500
+ const duration = getElapsedMs(hrStart)
501
+ diagnostics.push(Diagnostics.performance({ plugin: plugin.name, duration }))
502
+ await this.#emitPluginEnd({ plugin, duration, success: true })
503
+ }
504
+ return diagnostics
505
+ }
506
+
507
+ const transforms = this.#transforms
508
+ const { schemas, operations } = this.inputNode
509
+
510
510
  type PluginState = {
511
511
  plugin: NormalizedPlugin
512
512
  generatorContext: Omit<GeneratorContext, 'options'>
@@ -518,8 +518,6 @@ export class KubbDriver {
518
518
  allowedSchemaNames: Set<string> | null
519
519
  }
520
520
 
521
- const driver = this
522
- const { schemas, operations } = this.inputNode!
523
521
  const states: Array<PluginState> = entries.map(({ plugin, context, hrStart }) => {
524
522
  const { exclude, include, override } = plugin.options
525
523
  const hasExclude = Array.isArray(exclude) && exclude.length > 0
@@ -541,9 +539,9 @@ export class KubbDriver {
541
539
  const emitsOperationHook = this.hooks.listenerCount('kubb:generate:operation') > 0
542
540
 
543
541
  // Pre-scan: plugins with operation-based includes (but no schemaName include) need
544
- // the reachable schema set. This requires the full schema graph in memory at once
545
- // transitive reachability can't be derived from a single node. `allSchemas` is
546
- // released as soon as the pre-scan returns; the main passes get fresh iterators.
542
+ // the reachable schema set. This requires the full schema graph in memory at once,
543
+ // since transitive reachability can't be derived from a single node. `allSchemas` is
544
+ // released as soon as the pre-scan returns, so the main passes get fresh iterators.
547
545
  const pruningStates = states.filter(({ plugin }) => {
548
546
  const { include } = plugin.options
549
547
  return (include?.some(({ type }) => OPERATION_FILTER_TYPES.has(type)) ?? false) && !(include?.some(({ type }) => type === 'schemaName') ?? false)
@@ -553,7 +551,7 @@ export class KubbDriver {
553
551
  const allSchemas: Array<SchemaNode> = []
554
552
  for await (const schema of schemas) allSchemas.push(schema)
555
553
 
556
- const includedOpsByState = new Map<PluginState, Array<OperationNode>>(pruningStates.map((s) => [s, []]))
554
+ const includedOpsByState = new Map<PluginState, Array<OperationNode>>(pruningStates.map((state) => [state, []]))
557
555
  for await (const operation of operations) {
558
556
  for (const state of pruningStates) {
559
557
  const { exclude, include, override } = state.plugin.options
@@ -568,12 +566,27 @@ export class KubbDriver {
568
566
  }
569
567
  }
570
568
 
571
- const resolveRendererFor = (gen: Generator, state: PluginState): RendererFactory | undefined =>
572
- gen.renderer === null ? undefined : (gen.renderer ?? state.plugin.renderer ?? state.generatorContext.config.renderer)
569
+ // Apply the plugin's transformer, then resolve options (skipping the resolver when
570
+ // optionsAreStatic). Returns null when include/exclude/override rules out the node.
571
+ // The per-node dispatch and the collected-operations tail both go through this so
572
+ // they agree on what a plugin sees.
573
+ const resolveForPlugin = <TNode extends SchemaNode | OperationNode>(
574
+ state: PluginState,
575
+ node: TNode,
576
+ ): { transformedNode: TNode; options: NormalizedPlugin['options'] } | null => {
577
+ const { plugin, generatorContext } = state
578
+ const transformedNode = transforms.applyTo(plugin.name, node)
579
+ if (state.optionsAreStatic) return { transformedNode, options: plugin.options }
580
+
581
+ const { exclude, include, override } = plugin.options
582
+ const options = generatorContext.resolver.resolveOptions(transformedNode, { options: plugin.options, exclude, include, override })
583
+ if (options === null) return null
584
+ return { transformedNode, options }
585
+ }
573
586
 
574
- // Schema and operation passes share this body. They differ only in which
575
- // generator method runs, which hook is emitted, and the schema-only
576
- // `allowedSchemaNames` prune (operations don't carry that constraint).
587
+ // Schema and operation passes share this body. They differ only in which generator
588
+ // method runs, which hook is emitted, and the schema-only `allowedSchemaNames` prune
589
+ // (operations don't carry that constraint).
577
590
  const dispatchNode = async <TNode extends SchemaNode | OperationNode>(
578
591
  state: PluginState,
579
592
  node: TNode,
@@ -585,9 +598,10 @@ export class KubbDriver {
585
598
  ): Promise<void> => {
586
599
  if (state.failed) return
587
600
  try {
588
- const { plugin, generatorContext, generators } = state
589
- const transformedNode: TNode = plugin.transformer ? (transform(node, plugin.transformer) as TNode) : node
601
+ const resolved = resolveForPlugin(state, node)
602
+ if (!resolved) return
590
603
 
604
+ const { transformedNode, options } = resolved
591
605
  if (
592
606
  dispatch.checkAllowedNames &&
593
607
  state.allowedSchemaNames !== null &&
@@ -598,19 +612,13 @@ export class KubbDriver {
598
612
  return
599
613
  }
600
614
 
601
- const { exclude, include, override } = plugin.options
602
- const options = state.optionsAreStatic
603
- ? plugin.options
604
- : generatorContext.resolver.resolveOptions(transformedNode, { options: plugin.options, exclude, include, override })
605
- if (options === null) return
606
-
607
- const ctx = { ...generatorContext, options }
608
- for (const gen of generators) {
609
- const generate = gen[dispatch.method] as ((node: TNode, ctx: GeneratorContext) => unknown) | undefined
610
- if (!generate) continue
611
- const raw = generate(transformedNode, ctx)
615
+ const ctx = { ...state.generatorContext, options }
616
+ for (const gen of state.generators) {
617
+ const run = gen[dispatch.method] as ((node: TNode, ctx: GeneratorContext) => unknown) | undefined
618
+ if (!run) continue
619
+ const raw = run(transformedNode, ctx)
612
620
  const result = isPromise(raw) ? await raw : raw
613
- const applied = applyHookResult({ result, driver, rendererFactory: resolveRendererFor(gen, state) })
621
+ const applied = this.dispatch({ result, renderer: gen.renderer })
614
622
  if (isPromise(applied)) await applied
615
623
  }
616
624
  if (dispatch.emit) await dispatch.emit(transformedNode, ctx)
@@ -631,16 +639,16 @@ export class KubbDriver {
631
639
  emit: emitsOperationHook ? (node: OperationNode, ctx: GeneratorContext) => this.hooks.emit('kubb:generate:operation', node, ctx) : null,
632
640
  } as const
633
641
 
634
- // Skip building the aggregated operations array when nothing consumes it.
635
- // Saves an N-sized allocation that lives until the build ends, on the common
636
- // path where plugins only define per-node `gen.operation`.
637
- const needsCollectedOperations = this.hooks.listenerCount('kubb:generate:operations') > 0 || states.some((s) => s.generators.some((g) => !!g.operations))
642
+ // Skip building the aggregated operations array when nothing consumes it. Saves an
643
+ // N-sized allocation on the common path where plugins only define per-node `gen.operation`.
644
+ const needsCollectedOperations =
645
+ this.hooks.listenerCount('kubb:generate:operations') > 0 || states.some((state) => state.generators.some((gen) => !!gen.operations))
638
646
  const collectedOperations: Array<OperationNode> | undefined = needsCollectedOperations ? [] : undefined
639
647
 
640
648
  // Run schemas before operations: the two passes share `flushPending` and the
641
649
  // FileProcessor's event emitter, so running them concurrently would interleave
642
650
  // `kubb:files:processing:start|end` events and race on the shared dirty list.
643
- await forBatches(schemas, (nodes) => Promise.all(nodes.flatMap((n) => states.map((state) => dispatchNode(state, n, schemaDispatch)))), {
651
+ await forBatches(schemas, (nodes) => Promise.all(nodes.flatMap((node) => states.map((state) => dispatchNode(state, node, schemaDispatch)))), {
644
652
  concurrency: SCHEMA_PARALLEL,
645
653
  flush: flushPending,
646
654
  })
@@ -649,7 +657,7 @@ export class KubbDriver {
649
657
  operations,
650
658
  (nodes) => {
651
659
  if (needsCollectedOperations) collectedOperations?.push(...nodes)
652
- return Promise.all(nodes.flatMap((n) => states.map((state) => dispatchNode(state, n, operationDispatch))))
660
+ return Promise.all(nodes.flatMap((node) => states.map((state) => dispatchNode(state, node, operationDispatch))))
653
661
  },
654
662
  { concurrency: SCHEMA_PARALLEL, flush: flushPending },
655
663
  )
@@ -659,23 +667,18 @@ export class KubbDriver {
659
667
  try {
660
668
  const { plugin, generatorContext, generators } = state
661
669
  const ctx = { ...generatorContext, options: plugin.options }
662
- // Filter to operations this plugin would have dispatched to gen.operation():
663
- // excludes/includes/overrides that resolve to null in dispatchOperation must also
664
- // be hidden from the batched gen.operations() hook, otherwise grouped/barrel
665
- // generators emit references to operation files that the per-op hook intentionally skipped.
670
+ // Match what the per-node dispatch passes to gen.operation(): the transformed node,
671
+ // already filtered by excludes/includes/overrides.
666
672
  const ops = collectedOperations ?? []
667
- const pluginOperations = state.optionsAreStatic
668
- ? ops
669
- : ops.filter((node) => {
670
- const transformed = plugin.transformer ? transform(node, plugin.transformer) : node
671
- const { exclude, include, override } = plugin.options
672
-
673
- return generatorContext.resolver.resolveOptions(transformed, { options: plugin.options, exclude, include, override }) !== null
674
- })
673
+ const pluginOperations = ops.reduce<Array<OperationNode>>((acc, node) => {
674
+ const resolved = resolveForPlugin(state, node)
675
+ if (resolved) acc.push(resolved.transformedNode)
676
+ return acc
677
+ }, [])
675
678
  for (const gen of generators) {
676
679
  if (!gen.operations) continue
677
680
  const result = await gen.operations(pluginOperations, ctx)
678
- await applyHookResult({ result, driver, rendererFactory: resolveRendererFor(gen, state) })
681
+ await this.dispatch({ result, renderer: gen.renderer })
679
682
  }
680
683
  await this.hooks.emit('kubb:generate:operations', pluginOperations, ctx)
681
684
  } catch (caughtError) {
@@ -685,36 +688,70 @@ export class KubbDriver {
685
688
  }
686
689
 
687
690
  const duration = getElapsedMs(state.hrStart)
688
- timings.set(state.plugin.name, duration)
689
691
  await this.#emitPluginEnd({ plugin: state.plugin, duration, success: !state.failed, error: state.failed && state.error ? state.error : undefined })
690
692
 
691
- if (state.failed && state.error) failed.add({ plugin: state.plugin, error: state.error })
692
-
693
- await this.hooks.emit('kubb:debug', {
694
- date: new Date(),
695
- logs: [state.failed ? '✗ Plugin start failed' : `✓ Plugin started successfully (${formatMs(duration)})`],
696
- })
693
+ if (state.failed && state.error) {
694
+ diagnostics.push({ ...Diagnostics.from(state.error), plugin: state.plugin.name })
695
+ }
696
+ diagnostics.push(Diagnostics.performance({ plugin: state.plugin.name, duration }))
697
697
  }
698
698
 
699
- return { timings, failed }
699
+ return diagnostics
700
700
  }
701
701
 
702
702
  /**
703
- * Unregisters all plugin lifecycle listeners from the shared event emitter.
704
- * Called at the end of a build to prevent listener leaks across repeated builds.
703
+ * Stores whatever a generator method or `kubb:generate:*` hook returned.
705
704
  *
706
- * @internal
705
+ * - An `Array<FileNode>` goes straight into `fileManager` via `upsert`.
706
+ * - A renderer element runs through `renderer` (the renderer factory, e.g. JSX) and the
707
+ * produced files go to `fileManager.upsert`.
708
+ * - A falsy result is treated as a no-op. The generator wrote files itself via
709
+ * `ctx.upsertFile`.
710
+ *
711
+ * Pass `renderer` when the result may be a renderer element. Generators that only return
712
+ * `Array<FileNode>` do not need one.
707
713
  */
708
- dispose(): void {
709
- for (const [event, handlers] of this.#hookListeners) {
710
- for (const handler of handlers) {
711
- this.hooks.off(event, handler as never)
714
+ async dispatch<TElement = unknown>({
715
+ result,
716
+ renderer,
717
+ }: {
718
+ result: TElement | Array<FileNode> | undefined | null
719
+ renderer?: RendererFactory<TElement> | null
720
+ }): Promise<void> {
721
+ if (!result) return
722
+
723
+ if (Array.isArray(result)) {
724
+ this.fileManager.upsert(...(result as Array<FileNode>))
725
+ return
726
+ }
727
+
728
+ if (!renderer) {
729
+ return
730
+ }
731
+
732
+ using instance = renderer()
733
+ if (instance.stream) {
734
+ for (const file of instance.stream(result)) {
735
+ this.fileManager.upsert(file)
712
736
  }
737
+ return
713
738
  }
714
739
 
715
- this.#hookListeners.clear()
740
+ await instance.render(result)
741
+ this.fileManager.upsert(...instance.files)
742
+ }
743
+
744
+ /**
745
+ * Removes every listener the driver added. Listeners attached directly to `hooks` from outside
746
+ * the driver survive. Called at the end of a build to prevent leaks across repeated builds.
747
+ *
748
+ * @internal
749
+ */
750
+ dispose(): void {
751
+ this.#registry.dispose()
716
752
  this.#eventGeneratorPlugins.clear()
717
- // Release resolver closures — the driver is rebuilt for each build() call
753
+ this.#transforms.dispose()
754
+ // Release resolver closures. The driver is rebuilt for each build() call
718
755
  // so there is no value in retaining these maps after disposal.
719
756
  this.#resolvers.clear()
720
757
  this.#defaultResolvers.clear()
@@ -722,28 +759,14 @@ export class KubbDriver {
722
759
  // memory is reclaimed between builds. The returned `BuildOutput.files`
723
760
  // array still references any FileNodes the caller needs to inspect.
724
761
  this.fileManager.dispose()
725
- this.#fileProcessor.dispose()
726
762
  this.inputNode = null
727
763
  this.#studio = { source: null, isOpen: false, inputNode: null }
728
-
729
- for (const [event, handler] of this.#middlewareListeners) {
730
- this.hooks.off(event, handler as never)
731
- }
732
764
  }
733
765
 
734
766
  [Symbol.dispose](): void {
735
767
  this.dispose()
736
768
  }
737
769
 
738
- #trackHookListener(event: keyof KubbHooks, handler: (...args: Array<never>) => void | Promise<void>): void {
739
- let handlers = this.#hookListeners.get(event)
740
- if (!handlers) {
741
- handlers = new Set()
742
- this.#hookListeners.set(event, handlers)
743
- }
744
- handlers.add(handler)
745
- }
746
-
747
770
  #getDefaultResolver = memoize(
748
771
  this.#defaultResolvers,
749
772
  (pluginName: string): Resolver => defineResolver<PluginFactoryOptions>(() => ({ name: 'default', pluginName })),
@@ -779,6 +802,19 @@ export class KubbDriver {
779
802
  getContext<TOptions extends PluginFactoryOptions>(plugin: NormalizedPlugin<TOptions>): Omit<GeneratorContext<TOptions>, 'options'> {
780
803
  const driver = this
781
804
 
805
+ const report = (diagnostic: Omit<ProblemDiagnostic, 'plugin'>): void => {
806
+ Diagnostics.report({ ...diagnostic, plugin: plugin.name })
807
+ if (diagnostic.severity === 'error') {
808
+ driver.hooks.emit('kubb:error', { error: diagnostic.cause ?? new Error(diagnostic.message) })
809
+ return
810
+ }
811
+ if (diagnostic.severity === 'warning') {
812
+ driver.hooks.emit('kubb:warn', { message: diagnostic.message })
813
+ return
814
+ }
815
+ driver.hooks.emit('kubb:info', { message: diagnostic.message })
816
+ }
817
+
782
818
  return {
783
819
  config: driver.config,
784
820
  get root(): string {
@@ -811,16 +847,17 @@ export class KubbDriver {
811
847
  return driver.getResolver(plugin.name)
812
848
  },
813
849
  get transformer() {
814
- return plugin.transformer
850
+ return driver.#transforms.get(plugin.name)
815
851
  },
816
852
  warn(message: string) {
817
- driver.hooks.emit('kubb:warn', { message })
853
+ report({ code: diagnosticCode.pluginWarning, severity: 'warning', message })
818
854
  },
819
855
  error(error: string | Error) {
820
- driver.hooks.emit('kubb:error', { error: typeof error === 'string' ? new Error(error) : error })
856
+ const cause = typeof error === 'string' ? undefined : error
857
+ report({ code: diagnosticCode.pluginFailed, severity: 'error', message: typeof error === 'string' ? error : error.message, cause })
821
858
  },
822
859
  info(message: string) {
823
- driver.hooks.emit('kubb:info', { message })
860
+ report({ code: diagnosticCode.pluginInfo, severity: 'info', message })
824
861
  },
825
862
  async openInStudio(options?: DevtoolsOptions) {
826
863
  if (!driver.config.devtools || driver.#studio.isOpen) {
@@ -828,11 +865,23 @@ export class KubbDriver {
828
865
  }
829
866
 
830
867
  if (typeof driver.config.devtools !== 'object') {
831
- throw new Error('Devtools must be an object')
868
+ throw new DiagnosticError({
869
+ code: diagnosticCode.devtoolsInvalid,
870
+ severity: 'error',
871
+ message: 'The `devtools` config must be an object.',
872
+ help: 'Set `devtools` to an options object, or remove it to disable Kubb Studio.',
873
+ location: { kind: 'config' },
874
+ })
832
875
  }
833
876
 
834
877
  if (!driver.adapter || !driver.#studio.source) {
835
- throw new Error('adapter is not defined, make sure you have set the parser in kubb.config.ts')
878
+ throw new DiagnosticError({
879
+ code: diagnosticCode.adapterRequired,
880
+ severity: 'error',
881
+ message: 'An adapter is required to open Kubb Studio, but none is configured.',
882
+ help: 'Set `adapter` in kubb.config.ts (for example `adapterOas()`).',
883
+ location: { kind: 'config' },
884
+ })
836
885
  }
837
886
 
838
887
  driver.#studio.isOpen = true
@@ -860,63 +909,28 @@ export class KubbDriver {
860
909
  requirePlugin(pluginName: string): Plugin {
861
910
  const plugin = this.plugins.get(pluginName)
862
911
  if (!plugin) {
863
- throw new Error(`[kubb] Plugin "${pluginName}" is required but not found. Make sure it is included in your Kubb config.`)
912
+ throw new DiagnosticError({
913
+ code: diagnosticCode.pluginNotFound,
914
+ severity: 'error',
915
+ message: `Plugin "${pluginName}" is required but not found. Make sure it is included in your Kubb config.`,
916
+ help: `Add "${pluginName}" to the \`plugins\` array in kubb.config.ts, or remove the dependency on it.`,
917
+ location: { kind: 'config' },
918
+ })
864
919
  }
865
920
  return plugin
866
921
  }
867
922
  }
868
923
 
869
- /**
870
- * Handles the return value of a plugin AST hook or generator method.
871
- *
872
- * - Renderer output → rendered via the provided `rendererFactory` (e.g. JSX), files stored in `driver.fileManager`
873
- * - `Array<FileNode>` → added directly into `driver.fileManager`
874
- * - `void` / `null` / `undefined` → no-op (plugin handled it via `this.upsertFile`)
875
- *
876
- * Pass a `rendererFactory` (e.g. `jsxRenderer` from `@kubb/renderer-jsx`) when the result
877
- * may be a renderer element. Generators that only return `Array<FileNode>` do not need one.
878
- */
879
- export function applyHookResult<TElement = unknown>({
880
- result,
881
- driver,
882
- rendererFactory,
883
- }: {
884
- result: TElement | Array<FileNode> | undefined | null
885
- driver: KubbDriver
886
- rendererFactory?: RendererFactory<TElement> | null
887
- }): void | Promise<void> {
888
- if (!result) return
889
-
890
- if (Array.isArray(result)) {
891
- driver.fileManager.upsert(...(result as Array<FileNode>))
892
- return
893
- }
894
-
895
- if (!rendererFactory) {
896
- return
897
- }
898
-
899
- const renderer = rendererFactory()
900
- if (renderer.stream) {
901
- using r = renderer
902
- for (const file of r.stream!(result)) {
903
- driver.fileManager.upsert(file)
904
- }
905
- return
906
- }
907
- return applyAsyncRender({ renderer, result, driver })
908
- }
909
-
910
- async function applyAsyncRender<TElement>({ renderer, result, driver }: { renderer: Renderer<TElement>; result: TElement; driver: KubbDriver }): Promise<void> {
911
- using r = renderer
912
- await r.render(result)
913
- driver.fileManager.upsert(...r.files)
914
- }
915
-
916
924
  function inputToAdapterSource(config: Config): AdapterSource {
917
925
  const input = config.input
918
926
  if (!input) {
919
- throw new Error('[kubb] input is required when using an adapter. Provide input.path or input.data in your config.')
927
+ throw new DiagnosticError({
928
+ code: diagnosticCode.inputRequired,
929
+ severity: 'error',
930
+ message: 'An adapter is configured without an input.',
931
+ help: 'Provide `input.path` (a file or URL) or `input.data` (an inline spec) in your Kubb config.',
932
+ location: { kind: 'config' },
933
+ })
920
934
  }
921
935
 
922
936
  if ('data' in input) {