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

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.
@@ -0,0 +1,591 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import type { AsyncEventEmitter } from '@internals/utils'
3
+ import { getErrorMessage } from '@internals/utils'
4
+ import { version } from '../package.json'
5
+ import { type DiagnosticCode, diagnosticCode } from './constants.ts'
6
+ import type { KubbHooks } from './createKubb.ts'
7
+
8
+ /**
9
+ * Docs major, derived from the package version so the link tracks the published major.
10
+ */
11
+ const docsMajor = version.split('.')[0] ?? '5'
12
+
13
+ /**
14
+ * How serious a diagnostic is. `error` fails the build, `warning` and `info`
15
+ * are reported but do not.
16
+ */
17
+ export type DiagnosticSeverity = 'error' | 'warning' | 'info'
18
+
19
+ /**
20
+ * A human-readable explanation of a diagnostic code: a short title, what triggers it, and how
21
+ * to resolve it. This is the single source of truth the kubb.dev `/diagnostics/<slug>` pages
22
+ * mirror, so every code stays documented in one place. Typed as a total record over
23
+ * {@link DiagnosticCode}, so adding a code without documenting it fails the build.
24
+ */
25
+ export type DiagnosticDoc = {
26
+ /**
27
+ * Short title shown as the docs heading.
28
+ */
29
+ title: string
30
+ /**
31
+ * What triggers the diagnostic.
32
+ */
33
+ cause: string
34
+ /**
35
+ * The action that resolves it.
36
+ */
37
+ fix: string
38
+ }
39
+
40
+ /**
41
+ * Points a diagnostic back into the source document. Inputs are parsed into an
42
+ * object model with no line/column, so locations carry a JSON pointer the adapter
43
+ * builds (the OAS adapter emits `#/components/schemas/Pet`). A `config` diagnostic
44
+ * points at the Kubb config itself and so has no pointer.
45
+ */
46
+ export type DiagnosticLocation =
47
+ | {
48
+ kind: 'schema'
49
+ /**
50
+ * RFC 6901 JSON pointer into the source document.
51
+ */
52
+ pointer: string
53
+ /**
54
+ * The original reference when the diagnostic stems from an unresolved one.
55
+ */
56
+ ref?: string
57
+ }
58
+ | {
59
+ kind: 'operation' | 'document'
60
+ /**
61
+ * RFC 6901 JSON pointer into the source document.
62
+ */
63
+ pointer: string
64
+ }
65
+ | {
66
+ kind: 'config'
67
+ }
68
+
69
+ /**
70
+ * What a diagnostic carries.
71
+ * - `problem` is a build issue shown to the user, and the only kind rendered as a problem.
72
+ * - `performance` records a plugin's elapsed time.
73
+ * - `update` is a version notice.
74
+ */
75
+ export type DiagnosticKind = 'problem' | 'performance' | 'update'
76
+
77
+ /**
78
+ * Codes that describe a build problem: every {@link DiagnosticCode} except the
79
+ * `performance` and `updateAvailable` codes, which ride on their own variants.
80
+ */
81
+ export type ProblemCode = Exclude<DiagnosticCode, typeof diagnosticCode.performance | typeof diagnosticCode.updateAvailable>
82
+
83
+ /**
84
+ * A build problem collected during a run, gathered into the result instead of
85
+ * aborting on the first failure. It carries a stable {@link ProblemCode}, a
86
+ * `severity`, a `message`, and optionally a `location` into the source document,
87
+ * a `help`, the `plugin` that produced it, and the `cause` it wraps.
88
+ */
89
+ export type ProblemDiagnostic = {
90
+ /**
91
+ * @default 'problem'
92
+ */
93
+ kind?: 'problem'
94
+ /**
95
+ * Stable identifier for the problem, from the {@link diagnosticCode} catalog.
96
+ */
97
+ code: ProblemCode
98
+ severity: DiagnosticSeverity
99
+ message: string
100
+ location?: DiagnosticLocation
101
+ /**
102
+ * A suggested fix, phrased as an action the user can take.
103
+ */
104
+ help?: string
105
+ /**
106
+ * Name of the plugin or subsystem that produced the diagnostic.
107
+ */
108
+ plugin?: string
109
+ /**
110
+ * The underlying error, when the diagnostic wraps a thrown one.
111
+ */
112
+ cause?: Error
113
+ }
114
+
115
+ /**
116
+ * A per-plugin performance record, built with {@link Diagnostics.performance}. It carries the
117
+ * `plugin` and its elapsed `duration`, and the `performance` kind keeps it out of the problem
118
+ * list. It feeds the per-plugin timing bars, and reporters sum these into the run total.
119
+ */
120
+ export type PerformanceDiagnostic = {
121
+ kind: 'performance'
122
+ code: typeof diagnosticCode.performance
123
+ severity: 'info'
124
+ message: string
125
+ /**
126
+ * The plugin this measurement belongs to.
127
+ */
128
+ plugin: string
129
+ /**
130
+ * Elapsed milliseconds.
131
+ */
132
+ duration: number
133
+ }
134
+
135
+ /**
136
+ * A notice that a newer Kubb version is available on npm, built with {@link Diagnostics.update}.
137
+ * It carries the running and latest versions and renders like any info diagnostic.
138
+ */
139
+ export type UpdateDiagnostic = {
140
+ kind: 'update'
141
+ code: typeof diagnosticCode.updateAvailable
142
+ severity: 'info'
143
+ message: string
144
+ /**
145
+ * The running Kubb version.
146
+ */
147
+ currentVersion: string
148
+ /**
149
+ * The newest version published on npm.
150
+ */
151
+ latestVersion: string
152
+ }
153
+
154
+ /**
155
+ * A structured record collected during a build, discriminated on `kind`: a
156
+ * {@link ProblemDiagnostic} for an issue, a {@link PerformanceDiagnostic} for a per-plugin
157
+ * timing, or an {@link UpdateDiagnostic} for a version notice.
158
+ */
159
+ export type Diagnostic = ProblemDiagnostic | PerformanceDiagnostic | UpdateDiagnostic
160
+
161
+ /**
162
+ * Maps each {@link DiagnosticCode} to the variant it selects, for {@link narrowDiagnostic}.
163
+ * Every {@link ProblemCode} selects a {@link ProblemDiagnostic}, and the two bookkeeping codes
164
+ * select their own variant.
165
+ */
166
+ export type DiagnosticByCode = Record<ProblemCode, ProblemDiagnostic> &
167
+ Record<typeof diagnosticCode.performance, PerformanceDiagnostic> &
168
+ Record<typeof diagnosticCode.updateAvailable, UpdateDiagnostic>
169
+
170
+ /**
171
+ * Narrows a {@link Diagnostic} to the variant for `code`, or `null` when it does not match.
172
+ *
173
+ * @example
174
+ * ```ts
175
+ * const update = narrowDiagnostic(diagnostic, diagnosticCode.updateAvailable)
176
+ * if (update) {
177
+ * console.log(update.latestVersion)
178
+ * }
179
+ * ```
180
+ */
181
+ export function narrowDiagnostic<C extends DiagnosticCode>(diagnostic: Diagnostic, code: C): DiagnosticByCode[C] | null {
182
+ return diagnostic.code === code ? (diagnostic as DiagnosticByCode[C]) : null
183
+ }
184
+
185
+ /**
186
+ * Builds a type guard that narrows a {@link Diagnostic} to the variant for `kind`. A diagnostic
187
+ * with no `kind` is treated as a `problem`.
188
+ */
189
+ function isKind<T extends Diagnostic>(kind: DiagnosticKind) {
190
+ return (diagnostic: Diagnostic): diagnostic is T => (diagnostic.kind ?? 'problem') === kind
191
+ }
192
+
193
+ /**
194
+ * Returns `true` when the diagnostic is a build {@link ProblemDiagnostic}.
195
+ *
196
+ * @example
197
+ * ```ts
198
+ * if (isProblemDiagnostic(diagnostic)) {
199
+ * console.log(diagnostic.location)
200
+ * }
201
+ * ```
202
+ */
203
+ export const isProblemDiagnostic = isKind<ProblemDiagnostic>('problem')
204
+
205
+ /**
206
+ * Returns `true` when the diagnostic is a per-plugin {@link PerformanceDiagnostic}.
207
+ *
208
+ * @example
209
+ * ```ts
210
+ * const timings = diagnostics.filter(isPerformanceDiagnostic)
211
+ * ```
212
+ */
213
+ export const isPerformanceDiagnostic = isKind<PerformanceDiagnostic>('performance')
214
+
215
+ /**
216
+ * Returns `true` when the diagnostic is a version-update {@link UpdateDiagnostic}.
217
+ *
218
+ * @example
219
+ * ```ts
220
+ * if (isUpdateDiagnostic(diagnostic)) {
221
+ * console.log(diagnostic.latestVersion)
222
+ * }
223
+ * ```
224
+ */
225
+ export const isUpdateDiagnostic = isKind<UpdateDiagnostic>('update')
226
+
227
+ /**
228
+ * A {@link Diagnostic} reduced to its JSON-safe fields plus a `docsUrl`, for
229
+ * machine-readable output (the `--reporter json` report, the MCP tools). Drops the
230
+ * non-serializable `cause` and the `timing`/`duration` bookkeeping.
231
+ */
232
+ export type SerializedDiagnostic = {
233
+ code: DiagnosticCode
234
+ severity: DiagnosticSeverity
235
+ message: string
236
+ location?: DiagnosticLocation
237
+ help?: string
238
+ plugin?: string
239
+ /**
240
+ * The kubb.dev docs link for the code, omitted for the unknown fallback.
241
+ */
242
+ docsUrl?: string
243
+ }
244
+
245
+ /**
246
+ * An `Error` that carries a {@link Diagnostic}, so structured problems can flow
247
+ * through the existing throw/catch paths while keeping their code and location.
248
+ *
249
+ * @example
250
+ * ```ts
251
+ * throw new DiagnosticError({ code: diagnosticCode.refNotFound, severity: 'error', message: `Could not find ${ref}`, location: { kind: 'schema', pointer: ref, ref } })
252
+ * ```
253
+ */
254
+ export class DiagnosticError extends Error {
255
+ diagnostic: ProblemDiagnostic
256
+
257
+ constructor(diagnostic: ProblemDiagnostic) {
258
+ super(diagnostic.message, { cause: diagnostic.cause })
259
+ this.name = 'DiagnosticError'
260
+ this.diagnostic = diagnostic
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Structural check for a {@link DiagnosticError}, including one thrown from a duplicated
266
+ * `@kubb/core` copy where `instanceof` fails. Matches on the `name` and a `diagnostic`
267
+ * that carries a `code`.
268
+ */
269
+ function isDiagnosticError(error: unknown): error is DiagnosticError {
270
+ if (error instanceof DiagnosticError) {
271
+ return true
272
+ }
273
+ return (
274
+ error instanceof Error &&
275
+ error.name === 'DiagnosticError' &&
276
+ 'diagnostic' in error &&
277
+ typeof (error as { diagnostic?: unknown }).diagnostic === 'object' &&
278
+ (error as { diagnostic?: Diagnostic }).diagnostic !== null &&
279
+ typeof (error as { diagnostic?: { code?: unknown } }).diagnostic?.code === 'string'
280
+ )
281
+ }
282
+
283
+ /**
284
+ * Explanation for every {@link diagnosticCode}. Use {@link Diagnostics.explain} to look one up
285
+ * and `Diagnostics.docsUrl` for the matching kubb.dev page.
286
+ */
287
+ export const diagnosticCatalog: Record<DiagnosticCode, DiagnosticDoc> = {
288
+ [diagnosticCode.unknown]: {
289
+ title: 'Unknown error',
290
+ cause: 'An error was thrown without a stable Kubb code, so it is reported as-is.',
291
+ fix: 'Read the underlying message and stack. If it comes from a plugin or adapter, check its configuration; otherwise report it as a possible Kubb bug.',
292
+ },
293
+ [diagnosticCode.inputNotFound]: {
294
+ title: 'Input not found',
295
+ cause: 'The file or URL set in `input.path` (or passed as `kubb generate PATH`) could not be read.',
296
+ fix: 'Check that the path or URL exists and is readable, then set it in `input.path` or pass it on the CLI.',
297
+ },
298
+ [diagnosticCode.inputRequired]: {
299
+ title: 'Input required',
300
+ cause: 'An adapter is configured but no `input` was provided.',
301
+ fix: 'Set `input.path` (a file or URL) or `input.data` (an inline spec) in your Kubb config.',
302
+ },
303
+ [diagnosticCode.refNotFound]: {
304
+ title: 'Reference not found',
305
+ cause: 'A `$ref` could not be resolved in the source document.',
306
+ fix: 'Add the missing definition (for example under `components.schemas`) or fix the `$ref`. Run `kubb validate` to check the spec.',
307
+ },
308
+ [diagnosticCode.invalidServerVariable]: {
309
+ title: 'Invalid server variable',
310
+ cause: 'A server variable value is not allowed by its `enum`.',
311
+ fix: 'Use one of the values listed in the server variable `enum`, or update the spec.',
312
+ },
313
+ [diagnosticCode.pluginNotFound]: {
314
+ title: 'Plugin not found',
315
+ cause: 'A plugin that another plugin depends on is missing from the config.',
316
+ fix: 'Add the required plugin to the `plugins` array in kubb.config.ts, or remove the dependency on it.',
317
+ },
318
+ [diagnosticCode.pluginFailed]: {
319
+ title: 'Plugin failed',
320
+ cause: 'A plugin threw while generating, or reported an error through `ctx.error`.',
321
+ fix: 'Read the underlying error and check the plugin options and the schema or operation it failed on.',
322
+ },
323
+ [diagnosticCode.pluginWarning]: {
324
+ title: 'Plugin warning',
325
+ cause: 'A plugin reported a non-fatal warning through `ctx.warn`.',
326
+ fix: 'Review the message. It does not fail the build; adjust the plugin options or input if the warning is unwanted.',
327
+ },
328
+ [diagnosticCode.pluginInfo]: {
329
+ title: 'Plugin info',
330
+ cause: 'A plugin reported an informational message through `ctx.info`.',
331
+ fix: 'Informational only. No action is required.',
332
+ },
333
+ [diagnosticCode.unsupportedFormat]: {
334
+ title: 'Unsupported format',
335
+ cause: 'A schema uses a `format` Kubb does not map to a specific type, so it falls back to the base type.',
336
+ fix: 'Use a format Kubb supports, or handle the custom format with a parser or plugin.',
337
+ },
338
+ [diagnosticCode.deprecated]: {
339
+ title: 'Deprecated',
340
+ cause: 'A referenced schema or operation is marked `deprecated`.',
341
+ fix: 'Migrate off the deprecated definition if the warning is unwanted.',
342
+ },
343
+ [diagnosticCode.adapterRequired]: {
344
+ title: 'Adapter required',
345
+ cause: 'An action needs an adapter (for example opening Kubb Studio) but none is configured.',
346
+ fix: 'Set `adapter` in kubb.config.ts, for example `adapterOas()`.',
347
+ },
348
+ [diagnosticCode.devtoolsInvalid]: {
349
+ title: 'Invalid devtools config',
350
+ cause: 'The `devtools` config is set to something other than an object.',
351
+ fix: 'Set `devtools` to an options object, or remove it to disable Kubb Studio.',
352
+ },
353
+ [diagnosticCode.pathTraversal]: {
354
+ title: 'Path traversal',
355
+ cause: 'A resolved output path escaped the output directory, which can stem from a path traversal in the spec or a misconfigured `group.name`.',
356
+ fix: 'Keep generated paths within the output directory. Review the `group.name` function and the names coming from the spec.',
357
+ },
358
+ [diagnosticCode.hookFailed]: {
359
+ title: 'Hook failed',
360
+ cause: 'A post-generate shell hook (`hooks.done`) exited with a non-zero status.',
361
+ fix: 'Check the command is installed and correct, and run it manually to see the error.',
362
+ },
363
+ [diagnosticCode.formatFailed]: {
364
+ title: 'Format failed',
365
+ cause: 'The formatter pass over the generated files failed.',
366
+ fix: 'Check the formatter (oxfmt, biome, or prettier) is installed and its config is valid, then run it manually on the output.',
367
+ },
368
+ [diagnosticCode.lintFailed]: {
369
+ title: 'Lint failed',
370
+ cause: 'The linter pass over the generated files failed.',
371
+ fix: 'Check the linter (oxlint, biome, or eslint) is installed and its config is valid, then run it manually on the output.',
372
+ },
373
+ [diagnosticCode.performance]: {
374
+ title: 'Performance',
375
+ cause: 'Not a failure. Records a plugin’s elapsed time, summed into the run total.',
376
+ fix: 'No action. This is an informational metric.',
377
+ },
378
+ [diagnosticCode.updateAvailable]: {
379
+ title: 'Update available',
380
+ cause: 'A newer Kubb version is published on npm than the one running.',
381
+ fix: 'Update the `@kubb/*` packages, for example `npm install -g @kubb/cli`, to get the latest fixes.',
382
+ },
383
+ }
384
+
385
+ /**
386
+ * Static helpers for working with {@link Diagnostic}s, plus the run-scoped sink
387
+ * that lets deep code report a diagnostic without threading a callback.
388
+ *
389
+ * The sink lives in a single `AsyncLocalStorage` in the `@kubb/core` bundle.
390
+ * `Diagnostics.scope` activates it for a run, so anything inside that run (the
391
+ * adapter parse, a lazily consumed stream, a generator) reports through
392
+ * `Diagnostics.report` and lands in the same run.
393
+ */
394
+ export class Diagnostics {
395
+ static #reporterStorage = new AsyncLocalStorage<(diagnostic: Diagnostic) => void>()
396
+
397
+ /**
398
+ * Runs `fn` with `sink` as the active diagnostic sink for the whole async
399
+ * subtree, so {@link Diagnostics.report} reaches it from anywhere inside.
400
+ */
401
+ static scope<T>(sink: (diagnostic: Diagnostic) => void, fn: () => T): T {
402
+ return Diagnostics.#reporterStorage.run(sink, fn)
403
+ }
404
+
405
+ /**
406
+ * Collects a diagnostic into the active build via the run-scoped sink, without throwing.
407
+ * Returns `true` when a run consumed it, `false` when called outside a {@link Diagnostics.scope}
408
+ * (so callers can fall back to throwing). Use a `warning`/`info` severity for non-fatal issues.
409
+ * For rendering a diagnostic live on the hook bus, use {@link Diagnostics.emit} instead.
410
+ */
411
+ static report(diagnostic: Diagnostic): boolean {
412
+ const sink = Diagnostics.#reporterStorage.getStore()
413
+ if (!sink) {
414
+ return false
415
+ }
416
+ sink(diagnostic)
417
+ return true
418
+ }
419
+
420
+ /**
421
+ * Emits a diagnostic on the run's `kubb:diagnostic` event so the loggers render it live.
422
+ * Use it instead of calling `hooks.emit('kubb:diagnostic', ...)` directly. To collect a
423
+ * diagnostic into the build result from deep in a run, use {@link Diagnostics.report} instead.
424
+ */
425
+ static async emit(hooks: AsyncEventEmitter<KubbHooks>, diagnostic: ProblemDiagnostic | UpdateDiagnostic): Promise<void> {
426
+ await hooks.emit('kubb:diagnostic', { diagnostic })
427
+ }
428
+
429
+ /**
430
+ * Coerces any thrown value into a {@link ProblemDiagnostic}. A {@link DiagnosticError}
431
+ * keeps its structured data, and anything else becomes a `KUBB_UNKNOWN` error.
432
+ */
433
+ static from(error: unknown): ProblemDiagnostic {
434
+ // The event emitter and BuildError wrap the original, so walk the cause chain to
435
+ // recover a DiagnosticError thrown deeper down. `root` tracks the deepest error so
436
+ // the unknown diagnostic reports the original message and stack, not the wrapper's.
437
+ const seen = new Set<unknown>()
438
+ let current: unknown = error
439
+ let root: Error | undefined
440
+ while (current instanceof Error && !seen.has(current)) {
441
+ // Match structurally, not just by `instanceof`: a `DiagnosticError` thrown from a
442
+ // duplicated `@kubb/core` copy (bundled into an adapter or plugin) is a different
443
+ // class, but still carries the same `diagnostic`, so its code must survive.
444
+ if (isDiagnosticError(current)) {
445
+ return current.diagnostic
446
+ }
447
+ seen.add(current)
448
+ root = current
449
+ current = current.cause
450
+ }
451
+
452
+ return {
453
+ code: diagnosticCode.unknown,
454
+ severity: 'error',
455
+ message: root ? root.message : getErrorMessage(error),
456
+ cause: root,
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Builds a per-plugin performance record. Reporters sum these into the run total.
462
+ */
463
+ static performance({ plugin, duration }: { plugin: string; duration: number }): PerformanceDiagnostic {
464
+ return {
465
+ kind: 'performance',
466
+ code: diagnosticCode.performance,
467
+ severity: 'info',
468
+ message: `${plugin} generated in ${Math.round(duration)}ms`,
469
+ plugin,
470
+ duration,
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Builds the version-update notice shown when a newer Kubb is published on npm.
476
+ */
477
+ static update({ currentVersion, latestVersion }: { currentVersion: string; latestVersion: string }): UpdateDiagnostic {
478
+ return {
479
+ kind: 'update',
480
+ code: diagnosticCode.updateAvailable,
481
+ severity: 'info',
482
+ message: `Update available: v${currentVersion} → v${latestVersion}. Run \`npm install -g @kubb/cli\` to update.`,
483
+ currentVersion,
484
+ latestVersion,
485
+ }
486
+ }
487
+
488
+ /**
489
+ * True when any diagnostic is an error, the severity that fails a build. Non-error
490
+ * diagnostics are ignored.
491
+ */
492
+ static hasError(diagnostics: ReadonlyArray<Diagnostic>): boolean {
493
+ return diagnostics.some((diagnostic) => diagnostic.severity === 'error')
494
+ }
495
+
496
+ /**
497
+ * Names of the plugins that failed, deduped, derived from the error diagnostics
498
+ * that carry a `plugin`.
499
+ */
500
+ static failedPlugins(diagnostics: ReadonlyArray<Diagnostic>): Array<string> {
501
+ const names = new Set<string>()
502
+ for (const diagnostic of diagnostics) {
503
+ if (diagnostic.severity === 'error' && diagnostic.plugin) {
504
+ names.add(diagnostic.plugin)
505
+ }
506
+ }
507
+ return [...names]
508
+ }
509
+
510
+ /**
511
+ * Counts `problem` diagnostics by severity for the run summary. `timing`
512
+ * diagnostics are ignored.
513
+ */
514
+ static count(diagnostics: ReadonlyArray<Diagnostic>): { errors: number; warnings: number; infos: number } {
515
+ let errors = 0
516
+ let warnings = 0
517
+ let infos = 0
518
+ for (const diagnostic of diagnostics) {
519
+ if (!isProblemDiagnostic(diagnostic)) {
520
+ continue
521
+ }
522
+ if (diagnostic.severity === 'error') {
523
+ errors += 1
524
+ } else if (diagnostic.severity === 'warning') {
525
+ warnings += 1
526
+ } else {
527
+ infos += 1
528
+ }
529
+ }
530
+ return { errors, warnings, infos }
531
+ }
532
+
533
+ /**
534
+ * Drops duplicate `problem` diagnostics that share a code, location pointer, and
535
+ * plugin, so the same issue reported across several passes is shown once. Non-problem
536
+ * diagnostics are always kept.
537
+ */
538
+ static dedupe(diagnostics: ReadonlyArray<Diagnostic>): Array<Diagnostic> {
539
+ const seen = new Set<string>()
540
+ const result: Array<Diagnostic> = []
541
+ for (const diagnostic of diagnostics) {
542
+ if (!isProblemDiagnostic(diagnostic)) {
543
+ result.push(diagnostic)
544
+ continue
545
+ }
546
+ const pointer = diagnostic.location && 'pointer' in diagnostic.location ? diagnostic.location.pointer : ''
547
+ const key = `${diagnostic.code} ${pointer} ${diagnostic.plugin ?? ''}`
548
+ if (seen.has(key)) {
549
+ continue
550
+ }
551
+ seen.add(key)
552
+ result.push(diagnostic)
553
+ }
554
+ return result
555
+ }
556
+
557
+ /**
558
+ * Builds the kubb.dev docs URL for a diagnostic code, e.g.
559
+ * `KUBB_REF_NOT_FOUND` → `https://kubb.dev/docs/5.x/reference/diagnostics/kubb-ref-not-found`.
560
+ */
561
+ static docsUrl(code: string): string {
562
+ const slug = code.toLowerCase().replaceAll('_', '-')
563
+ return `https://kubb.dev/docs/${docsMajor}.x/reference/diagnostics/${slug}`
564
+ }
565
+
566
+ /**
567
+ * The catalog entry for a code: its title, cause, and fix. Mirrors the kubb.dev
568
+ * `/diagnostics/<slug>` page.
569
+ */
570
+ static explain(code: DiagnosticCode): DiagnosticDoc {
571
+ return diagnosticCatalog[code]
572
+ }
573
+
574
+ /**
575
+ * Reduces a diagnostic to its JSON-safe fields plus a `docsUrl`, for machine-readable
576
+ * consumers. The `cause`, `kind`, and `duration` are dropped, and absent optional
577
+ * fields are omitted rather than set to `undefined`.
578
+ */
579
+ static serialize(diagnostic: Diagnostic): SerializedDiagnostic {
580
+ const problem = isProblemDiagnostic(diagnostic) ? diagnostic : undefined
581
+ return {
582
+ code: diagnostic.code,
583
+ severity: diagnostic.severity,
584
+ message: diagnostic.message,
585
+ ...(problem?.location ? { location: problem.location } : {}),
586
+ ...(problem?.help ? { help: problem.help } : {}),
587
+ ...(problem?.plugin ? { plugin: problem.plugin } : {}),
588
+ ...(diagnostic.code === diagnosticCode.unknown ? {} : { docsUrl: Diagnostics.docsUrl(diagnostic.code) }),
589
+ }
590
+ }
591
+ }
package/src/index.ts CHANGED
@@ -1,8 +1,18 @@
1
1
  export { AsyncEventEmitter, URLPath } from '@internals/utils'
2
2
  export * as ast from '@kubb/ast'
3
- export { logLevel } from './constants.ts'
3
+ export { diagnosticCode, logLevel } from './constants.ts'
4
4
  export { createAdapter } from './createAdapter.ts'
5
+ export {
6
+ diagnosticCatalog,
7
+ DiagnosticError,
8
+ Diagnostics,
9
+ isPerformanceDiagnostic,
10
+ isProblemDiagnostic,
11
+ isUpdateDiagnostic,
12
+ narrowDiagnostic,
13
+ } from './diagnostics.ts'
5
14
  export { createKubb } from './createKubb.ts'
15
+ export { createReporter } from './createReporter.ts'
6
16
  export { createRenderer } from './createRenderer.ts'
7
17
  export { createStorage } from './createStorage.ts'
8
18
  export { defineGenerator } from './defineGenerator.ts'
package/src/mocks.ts CHANGED
@@ -2,14 +2,16 @@ import { resolve } from 'node:path'
2
2
  import type { FileNode, InputMeta, OperationNode, SchemaNode, Visitor } from '@kubb/ast'
3
3
  import { transform } from '@kubb/ast'
4
4
  import { FileManager } from './FileManager.ts'
5
- import { applyHookResult, KubbDriver } from './KubbDriver.ts'
6
- import type { Adapter, AdapterFactoryOptions, Config, Generator, GeneratorContext, NormalizedPlugin, PluginFactoryOptions } from './types.ts'
5
+ import { KubbDriver } from './KubbDriver.ts'
6
+ import type { Adapter, AdapterFactoryOptions, Config, Generator, GeneratorContext, NormalizedPlugin, PluginFactoryOptions, RendererFactory } from './types.ts'
7
7
 
8
8
  /**
9
9
 
10
10
  * Creates a minimal `PluginDriver` mock for unit tests.
11
11
  */
12
12
  export function createMockedPluginDriver(options: { name?: string; plugin?: NormalizedPlugin; config?: Config } = {}): KubbDriver {
13
+ const fileManager = new FileManager()
14
+
13
15
  return {
14
16
  config: options?.config ?? {
15
17
  root: '.',
@@ -21,13 +23,32 @@ export function createMockedPluginDriver(options: { name?: string; plugin?: Norm
21
23
  return options?.plugin
22
24
  },
23
25
  getResolver: (_pluginName: string) => options?.plugin?.resolver,
24
- fileManager: new FileManager(),
26
+ fileManager,
27
+ async dispatch({ result, renderer }: { result: unknown; renderer?: RendererFactory | null }): Promise<void> {
28
+ if (!result) return
29
+
30
+ if (Array.isArray(result)) {
31
+ fileManager.upsert(...(result as Array<FileNode>))
32
+ return
33
+ }
34
+
35
+ if (!renderer) return
36
+
37
+ using instance = renderer()
38
+ if (instance.stream) {
39
+ for (const file of instance.stream(result)) fileManager.upsert(file)
40
+ return
41
+ }
42
+
43
+ await instance.render(result)
44
+ fileManager.upsert(...instance.files)
45
+ },
25
46
  } as unknown as KubbDriver
26
47
  }
27
48
 
28
49
  /**
29
50
  * Creates a minimal `Adapter` mock for unit tests.
30
- * `parse` returns an empty `InputNode` by default; override via `options.parse`.
51
+ * `parse` returns an empty `InputNode` by default. Override via `options.parse`.
31
52
  * `getImports` returns `[]` by default.
32
53
  */
33
54
  export function createMockedAdapter<TOptions extends AdapterFactoryOptions = AdapterFactoryOptions>(
@@ -123,7 +144,7 @@ export async function renderGeneratorSchema<TOptions extends PluginFactoryOption
123
144
  ...context,
124
145
  options: opts.options,
125
146
  })
126
- await applyHookResult({ result, driver: opts.driver, rendererFactory: generator.renderer })
147
+ await opts.driver.dispatch({ result, renderer: generator.renderer })
127
148
  }
128
149
 
129
150
  /**
@@ -147,7 +168,7 @@ export async function renderGeneratorOperation<TOptions extends PluginFactoryOpt
147
168
  ...context,
148
169
  options: opts.options,
149
170
  })
150
- await applyHookResult({ result, driver: opts.driver, rendererFactory: generator.renderer })
171
+ await opts.driver.dispatch({ result, renderer: generator.renderer })
151
172
  }
152
173
 
153
174
  /**
@@ -171,5 +192,5 @@ export async function renderGeneratorOperations<TOptions extends PluginFactoryOp
171
192
  ...context,
172
193
  options: opts.options,
173
194
  })
174
- await applyHookResult({ result, driver: opts.driver, rendererFactory: generator.renderer })
195
+ await opts.driver.dispatch({ result, renderer: generator.renderer })
175
196
  }