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

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.
@@ -1,4 +1,5 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import { styleText } from 'node:util'
2
3
  import type { AsyncEventEmitter } from '@internals/utils'
3
4
  import { getErrorMessage } from '@internals/utils'
4
5
  import { version } from '../package.json'
@@ -159,7 +160,7 @@ export type UpdateDiagnostic = {
159
160
  export type Diagnostic = ProblemDiagnostic | PerformanceDiagnostic | UpdateDiagnostic
160
161
 
161
162
  /**
162
- * Maps each {@link DiagnosticCode} to the variant it selects, for {@link narrowDiagnostic}.
163
+ * Maps each {@link DiagnosticCode} to the variant it selects, for {@link narrow}.
163
164
  * Every {@link ProblemCode} selects a {@link ProblemDiagnostic}, and the two bookkeeping codes
164
165
  * select their own variant.
165
166
  */
@@ -172,13 +173,13 @@ export type DiagnosticByCode = Record<ProblemCode, ProblemDiagnostic> &
172
173
  *
173
174
  * @example
174
175
  * ```ts
175
- * const update = narrowDiagnostic(diagnostic, diagnosticCode.updateAvailable)
176
+ * const update = narrow(diagnostic, diagnosticCode.updateAvailable)
176
177
  * if (update) {
177
178
  * console.log(update.latestVersion)
178
179
  * }
179
180
  * ```
180
181
  */
181
- export function narrowDiagnostic<C extends DiagnosticCode>(diagnostic: Diagnostic, code: C): DiagnosticByCode[C] | null {
182
+ function narrow<C extends DiagnosticCode>(diagnostic: Diagnostic, code: C): DiagnosticByCode[C] | null {
182
183
  return diagnostic.code === code ? (diagnostic as DiagnosticByCode[C]) : null
183
184
  }
184
185
 
@@ -195,34 +196,44 @@ function isKind<T extends Diagnostic>(kind: DiagnosticKind) {
195
196
  *
196
197
  * @example
197
198
  * ```ts
198
- * if (isProblemDiagnostic(diagnostic)) {
199
+ * if (isProblem(diagnostic)) {
199
200
  * console.log(diagnostic.location)
200
201
  * }
201
202
  * ```
202
203
  */
203
- export const isProblemDiagnostic = isKind<ProblemDiagnostic>('problem')
204
+ const isProblem = isKind<ProblemDiagnostic>('problem')
204
205
 
205
206
  /**
206
207
  * Returns `true` when the diagnostic is a per-plugin {@link PerformanceDiagnostic}.
207
208
  *
208
209
  * @example
209
210
  * ```ts
210
- * const timings = diagnostics.filter(isPerformanceDiagnostic)
211
+ * const timings = diagnostics.filter(isPerformance)
211
212
  * ```
212
213
  */
213
- export const isPerformanceDiagnostic = isKind<PerformanceDiagnostic>('performance')
214
+ const isPerformance = isKind<PerformanceDiagnostic>('performance')
214
215
 
215
216
  /**
216
217
  * Returns `true` when the diagnostic is a version-update {@link UpdateDiagnostic}.
217
218
  *
218
219
  * @example
219
220
  * ```ts
220
- * if (isUpdateDiagnostic(diagnostic)) {
221
+ * if (isUpdate(diagnostic)) {
221
222
  * console.log(diagnostic.latestVersion)
222
223
  * }
223
224
  * ```
224
225
  */
225
- export const isUpdateDiagnostic = isKind<UpdateDiagnostic>('update')
226
+ const isUpdate = isKind<UpdateDiagnostic>('update')
227
+
228
+ /**
229
+ * Glyph and accent color per severity, matching the miette/oxlint convention
230
+ * (`×` error, `⚠` warning, `ℹ` advice).
231
+ */
232
+ const severityStyle: Record<DiagnosticSeverity, { glyph: string; color: 'red' | 'yellow' | 'blue' }> = {
233
+ error: { glyph: '×', color: 'red' },
234
+ warning: { glyph: '⚠', color: 'yellow' },
235
+ info: { glyph: 'ℹ', color: 'blue' },
236
+ }
226
237
 
227
238
  /**
228
239
  * A {@link Diagnostic} reduced to its JSON-safe fields plus a `docsUrl`, for
@@ -242,49 +253,11 @@ export type SerializedDiagnostic = {
242
253
  docsUrl?: string
243
254
  }
244
255
 
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
256
  /**
284
257
  * Explanation for every {@link diagnosticCode}. Use {@link Diagnostics.explain} to look one up
285
258
  * and `Diagnostics.docsUrl` for the matching kubb.dev page.
286
259
  */
287
- export const diagnosticCatalog: Record<DiagnosticCode, DiagnosticDoc> = {
260
+ const diagnosticCatalog: Record<DiagnosticCode, DiagnosticDoc> = {
288
261
  [diagnosticCode.unknown]: {
289
262
  title: 'Unknown error',
290
263
  cause: 'An error was thrown without a stable Kubb code, so it is reported as-is.',
@@ -342,14 +315,9 @@ export const diagnosticCatalog: Record<DiagnosticCode, DiagnosticDoc> = {
342
315
  },
343
316
  [diagnosticCode.adapterRequired]: {
344
317
  title: 'Adapter required',
345
- cause: 'An action needs an adapter (for example opening Kubb Studio) but none is configured.',
318
+ cause: 'An action needs an adapter but none is configured.',
346
319
  fix: 'Set `adapter` in kubb.config.ts, for example `adapterOas()`.',
347
320
  },
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
321
  [diagnosticCode.pathTraversal]: {
354
322
  title: 'Path traversal',
355
323
  cause: 'A resolved output path escaped the output directory, which can stem from a path traversal in the spec or a misconfigured `group.name`.',
@@ -394,6 +362,69 @@ export const diagnosticCatalog: Record<DiagnosticCode, DiagnosticDoc> = {
394
362
  export class Diagnostics {
395
363
  static #reporterStorage = new AsyncLocalStorage<(diagnostic: Diagnostic) => void>()
396
364
 
365
+ /**
366
+ * The diagnostic code catalog, exposed as `Diagnostics.code` (e.g. `Diagnostics.code.refNotFound`).
367
+ */
368
+ static code = diagnosticCode
369
+
370
+ /**
371
+ * Type guard for a build {@link ProblemDiagnostic}.
372
+ */
373
+ static isProblem = isProblem
374
+
375
+ /**
376
+ * Type guard for a version-update {@link UpdateDiagnostic}.
377
+ */
378
+ static isUpdate = isUpdate
379
+
380
+ /**
381
+ * Type guard for a per-plugin {@link PerformanceDiagnostic}.
382
+ */
383
+ static isPerformance = isPerformance
384
+
385
+ /**
386
+ * Narrows a {@link Diagnostic} to the variant for `code`, or `null` when it does not match.
387
+ */
388
+ static narrow = narrow
389
+
390
+ /**
391
+ * An `Error` that carries a {@link Diagnostic}, so structured problems can flow
392
+ * through the existing throw/catch paths while keeping their code and location.
393
+ *
394
+ * @example
395
+ * ```ts
396
+ * throw new Diagnostics.Error({ code: diagnosticCode.refNotFound, severity: 'error', message: `Could not find ${ref}`, location: { kind: 'schema', pointer: ref, ref } })
397
+ * ```
398
+ */
399
+ static Error = class DiagnosticError extends Error {
400
+ diagnostic: ProblemDiagnostic
401
+
402
+ constructor(diagnostic: ProblemDiagnostic) {
403
+ super(diagnostic.message, { cause: diagnostic.cause })
404
+ this.name = 'DiagnosticError'
405
+ this.diagnostic = diagnostic
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Structural check for a {@link Diagnostics.Error}, including one thrown from a duplicated
411
+ * `@kubb/core` copy where `instanceof` fails. Matches on the `name` and a `diagnostic`
412
+ * that carries a `code`.
413
+ */
414
+ static isError(error: unknown): error is InstanceType<typeof Diagnostics.Error> {
415
+ if (error instanceof Diagnostics.Error) {
416
+ return true
417
+ }
418
+ return (
419
+ error instanceof Error &&
420
+ error.name === 'DiagnosticError' &&
421
+ 'diagnostic' in error &&
422
+ typeof (error as { diagnostic?: unknown }).diagnostic === 'object' &&
423
+ (error as { diagnostic?: Diagnostic }).diagnostic !== null &&
424
+ typeof (error as { diagnostic?: { code?: unknown } }).diagnostic?.code === 'string'
425
+ )
426
+ }
427
+
397
428
  /**
398
429
  * Runs `fn` with `sink` as the active diagnostic sink for the whole async
399
430
  * subtree, so {@link Diagnostics.report} reaches it from anywhere inside.
@@ -427,21 +458,21 @@ export class Diagnostics {
427
458
  }
428
459
 
429
460
  /**
430
- * Coerces any thrown value into a {@link ProblemDiagnostic}. A {@link DiagnosticError}
461
+ * Coerces any thrown value into a {@link ProblemDiagnostic}. A {@link Diagnostics.Error}
431
462
  * keeps its structured data, and anything else becomes a `KUBB_UNKNOWN` error.
432
463
  */
433
464
  static from(error: unknown): ProblemDiagnostic {
434
465
  // 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
466
+ // recover a Diagnostics.Error thrown deeper down. `root` tracks the deepest error so
436
467
  // the unknown diagnostic reports the original message and stack, not the wrapper's.
437
468
  const seen = new Set<unknown>()
438
469
  let current: unknown = error
439
470
  let root: Error | undefined
440
471
  while (current instanceof Error && !seen.has(current)) {
441
- // Match structurally, not just by `instanceof`: a `DiagnosticError` thrown from a
472
+ // Match structurally, not just by `instanceof`: a `Diagnostics.Error` thrown from a
442
473
  // duplicated `@kubb/core` copy (bundled into an adapter or plugin) is a different
443
474
  // class, but still carries the same `diagnostic`, so its code must survive.
444
- if (isDiagnosticError(current)) {
475
+ if (Diagnostics.isError(current)) {
445
476
  return current.diagnostic
446
477
  }
447
478
  seen.add(current)
@@ -516,7 +547,7 @@ export class Diagnostics {
516
547
  let warnings = 0
517
548
  let infos = 0
518
549
  for (const diagnostic of diagnostics) {
519
- if (!isProblemDiagnostic(diagnostic)) {
550
+ if (!isProblem(diagnostic)) {
520
551
  continue
521
552
  }
522
553
  if (diagnostic.severity === 'error') {
@@ -539,7 +570,7 @@ export class Diagnostics {
539
570
  const seen = new Set<string>()
540
571
  const result: Array<Diagnostic> = []
541
572
  for (const diagnostic of diagnostics) {
542
- if (!isProblemDiagnostic(diagnostic)) {
573
+ if (!isProblem(diagnostic)) {
543
574
  result.push(diagnostic)
544
575
  continue
545
576
  }
@@ -577,7 +608,7 @@ export class Diagnostics {
577
608
  * fields are omitted rather than set to `undefined`.
578
609
  */
579
610
  static serialize(diagnostic: Diagnostic): SerializedDiagnostic {
580
- const problem = isProblemDiagnostic(diagnostic) ? diagnostic : undefined
611
+ const problem = isProblem(diagnostic) ? diagnostic : undefined
581
612
  return {
582
613
  code: diagnostic.code,
583
614
  severity: diagnostic.severity,
@@ -588,4 +619,42 @@ export class Diagnostics {
588
619
  ...(diagnostic.code === diagnosticCode.unknown ? {} : { docsUrl: Diagnostics.docsUrl(diagnostic.code) }),
589
620
  }
590
621
  }
622
+
623
+ /**
624
+ * Renders a {@link Diagnostic} for terminal output as its parts: the colored severity `symbol`
625
+ * (the gutter glyph), the `plugin(CODE): message` `headline`, and the `details` lines (optional
626
+ * `at <pointer>`, `help:`, and `docs:`).
627
+ *
628
+ * Hosts compose these to fit their gutter: a clack logger passes `symbol` as its own gutter and
629
+ * `[headline, ...details]` as the message, while plain text outputs use {@link Diagnostics.formatLines}.
630
+ */
631
+ static format(diagnostic: Diagnostic): { symbol: string; headline: string; details: Array<string> } {
632
+ const { code, severity, message } = diagnostic
633
+ const { glyph, color } = severityStyle[severity]
634
+ const problem = isProblem(diagnostic) ? diagnostic : undefined
635
+
636
+ const rule = styleText(color, styleText('bold', problem?.plugin ? `${problem.plugin}(${code})` : code))
637
+ const details: Array<string> = []
638
+
639
+ if (problem?.location && 'pointer' in problem.location) {
640
+ details.push(` ${styleText('dim', 'at')} ${styleText('cyan', problem.location.pointer)}`)
641
+ }
642
+ if (problem?.help) {
643
+ details.push(` ${styleText('cyan', 'help:')} ${problem.help}`)
644
+ }
645
+ if (code !== diagnosticCode.unknown) {
646
+ details.push(` ${styleText('dim', 'docs:')} ${styleText('cyan', Diagnostics.docsUrl(code))}`)
647
+ }
648
+
649
+ return { symbol: styleText(color, styleText('bold', glyph)), headline: `${rule}: ${message}`, details }
650
+ }
651
+
652
+ /**
653
+ * The self-contained block form of {@link Diagnostics.format}: `${symbol} ${headline}` followed by
654
+ * the detail lines. Used where there is no gutter to own the symbol (plain and file output).
655
+ */
656
+ static formatLines(diagnostic: Diagnostic): Array<string> {
657
+ const { symbol, headline, details } = Diagnostics.format(diagnostic)
658
+ return [`${symbol} ${headline}`, ...details]
659
+ }
591
660
  }
package/src/index.ts CHANGED
@@ -1,30 +1,22 @@
1
- export { AsyncEventEmitter, URLPath } from '@internals/utils'
1
+ export { AsyncEventEmitter } from '@internals/utils'
2
2
  export * as ast from '@kubb/ast'
3
- export { diagnosticCode, logLevel } from './constants.ts'
4
3
  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'
4
+ export { Diagnostics } from './diagnostics.ts'
14
5
  export { createKubb } from './createKubb.ts'
15
- export { createReporter } from './createReporter.ts'
6
+ export { createReporter, selectReporters } from './createReporter.ts'
7
+ export { cliReporter } from './reporters/cliReporter.ts'
8
+ export { fileReporter } from './reporters/fileReporter.ts'
9
+ export { jsonReporter } from './reporters/jsonReporter.ts'
10
+ export { Telemetry } from './Telemetry.ts'
16
11
  export { createRenderer } from './createRenderer.ts'
17
12
  export { createStorage } from './createStorage.ts'
18
13
  export { defineGenerator } from './defineGenerator.ts'
19
- export { defineLogger } from './defineLogger.ts'
14
+ export { defineLogger, logLevel } from './defineLogger.ts'
20
15
  export { defineMiddleware } from './defineMiddleware.ts'
21
16
  export { defineParser } from './defineParser.ts'
22
17
  export { definePlugin } from './definePlugin.ts'
23
18
  export { defineResolver } from './defineResolver.ts'
24
- export { FileManager } from './FileManager.ts'
25
- export { FileProcessor } from './FileProcessor.ts'
26
19
  export { KubbDriver } from './KubbDriver.ts'
27
20
  export { fsStorage } from './storages/fsStorage.ts'
28
21
  export { memoryStorage } from './storages/memoryStorage.ts'
29
22
  export * from './types.ts'
30
- export { isInputPath } from './createKubb.ts'
package/src/mocks.ts CHANGED
@@ -1,8 +1,13 @@
1
- import { resolve } from 'node:path'
1
+ import path, { resolve } from 'node:path'
2
+ import { camelCase } from '@internals/utils'
2
3
  import type { FileNode, InputMeta, OperationNode, SchemaNode, Visitor } from '@kubb/ast'
3
4
  import { transform } from '@kubb/ast'
5
+ import { expect } from 'vitest'
6
+ import type { Parser } from './defineParser.ts'
4
7
  import { FileManager } from './FileManager.ts'
8
+ import { FileProcessor } from './FileProcessor.ts'
5
9
  import { KubbDriver } from './KubbDriver.ts'
10
+ import { memoryStorage } from './storages/memoryStorage.ts'
6
11
  import type { Adapter, AdapterFactoryOptions, Config, Generator, GeneratorContext, NormalizedPlugin, PluginFactoryOptions, RendererFactory } from './types.ts'
7
12
 
8
13
  /**
@@ -119,7 +124,6 @@ function createMockedPluginContext<TOptions extends PluginFactoryOptions>(opts:
119
124
  warn: (msg: string) => console.warn(msg),
120
125
  error: (msg: string) => console.error(msg),
121
126
  info: (msg: string) => console.info(msg),
122
- openInStudio: async () => {},
123
127
  } as unknown as Omit<GeneratorContext<TOptions>, 'options'>
124
128
  }
125
129
 
@@ -194,3 +198,54 @@ export async function renderGeneratorOperations<TOptions extends PluginFactoryOp
194
198
  })
195
199
  await opts.driver.dispatch({ result, renderer: generator.renderer })
196
200
  }
201
+
202
+ type MatchFilesOptions = {
203
+ /**
204
+ * Parsers indexed by file extension, used to render each `FileNode` to source.
205
+ * Without a matching parser the file's raw content is used.
206
+ */
207
+ parsers?: Map<FileNode['extname'], Parser>
208
+ /**
209
+ * Formatter applied to non-JSON output before snapshotting, e.g. prettier. When
210
+ * omitted the parsed source is snapshotted as-is.
211
+ */
212
+ format?: (source?: string) => string | Promise<string>
213
+ /**
214
+ * Subfolder under `__snapshots__`, camelCased. Useful to keep variant snapshots apart.
215
+ */
216
+ pre?: string
217
+ }
218
+
219
+ /**
220
+ * Renders the driver's collected `FileNode`s to source and asserts each against a file snapshot.
221
+ * Pair it with the `renderGenerator*` helpers to snapshot a generator's output.
222
+ *
223
+ * @example
224
+ * ```ts
225
+ * await renderGeneratorSchema(typeGenerator, node, { config, adapter, driver, plugin, options, resolver })
226
+ * await matchFiles(driver.fileManager.files, { parsers, format })
227
+ * ```
228
+ */
229
+ export async function matchFiles(files: Array<FileNode> | undefined, options: MatchFilesOptions = {}): Promise<Map<string, string> | undefined> {
230
+ if (!files?.length) return
231
+
232
+ const { parsers = new Map(), format, pre } = options
233
+ const fileProcessor = new FileProcessor({ storage: memoryStorage(), parsers })
234
+ const processed = new Map<string, string>()
235
+
236
+ for (const file of files) {
237
+ if (!file?.path || processed.has(file.path)) {
238
+ continue
239
+ }
240
+
241
+ const parsed = fileProcessor.parse(file)
242
+ const code = file.baseName.endsWith('.json') || !format ? parsed : await format(parsed)
243
+
244
+ processed.set(file.path, code)
245
+
246
+ const snapshotPath = path.join('__snapshots__', ...(pre ? [camelCase(pre)] : []), file.baseName)
247
+ await expect(code).toMatchFileSnapshot(snapshotPath)
248
+ }
249
+
250
+ return processed
251
+ }
@@ -0,0 +1,90 @@
1
+ import { styleText } from 'node:util'
2
+ import { formatMs, randomCliColor } from '@internals/utils'
3
+ import { SUMMARY_MAX_BAR_LENGTH, SUMMARY_TIME_SCALE_DIVISOR } from '../constants.ts'
4
+ import { logLevel as logLevelMap } from '../defineLogger.ts'
5
+ import { createReporter } from '../createReporter.ts'
6
+ import { buildReport, type Report } from './report.ts'
7
+
8
+ /**
9
+ * Builds the vitest/jest-style summary for one {@link Report}: right-aligned dim labels with
10
+ * `N passed (total)` counts, and a per-plugin `Timings` section when `showTimings`.
11
+ */
12
+ function buildSummaryLines(report: Report, { showTimings }: { showTimings: boolean }): Array<string> {
13
+ const { status, plugins, counts, filesCreated, durationMs, output, timings } = report
14
+
15
+ const rows: Array<[label: string, value: string]> = []
16
+
17
+ rows.push([
18
+ 'Plugins',
19
+ status === 'success'
20
+ ? `${styleText('green', `${plugins.passed} passed`)} (${plugins.total})`
21
+ : `${styleText('green', `${plugins.passed} passed`)} | ${styleText('red', `${plugins.failed.length} failed`)} (${plugins.total})`,
22
+ ])
23
+
24
+ if (status === 'failed' && plugins.failed.length > 0) {
25
+ rows.push(['Failed', plugins.failed.map((name) => randomCliColor(name)).join(', ')])
26
+ }
27
+
28
+ if (counts.errors > 0 || counts.warnings > 0) {
29
+ const issues = [
30
+ counts.errors > 0 ? styleText('red', `${counts.errors} ${counts.errors === 1 ? 'error' : 'errors'}`) : undefined,
31
+ counts.warnings > 0 ? styleText('yellow', `${counts.warnings} ${counts.warnings === 1 ? 'warning' : 'warnings'}`) : undefined,
32
+ ]
33
+ .filter(Boolean)
34
+ .join(' | ')
35
+ rows.push(['Issues', issues])
36
+ }
37
+
38
+ rows.push(['Files', `${styleText('green', String(filesCreated))} generated`])
39
+ rows.push(['Duration', styleText('green', formatMs(durationMs))])
40
+ rows.push(['Output', output])
41
+
42
+ const labelWidth = Math.max(...rows.map(([label]) => label.length), timings.length > 0 ? 'Timings'.length : 0)
43
+ const lines = rows.map(([label, value]) => `${styleText('dim', label.padStart(labelWidth))} ${value}`)
44
+
45
+ if (showTimings && timings.length > 0) {
46
+ const nameWidth = Math.max(0, ...timings.map((timing) => timing.plugin.length))
47
+ const indent = ' '.repeat(labelWidth + 2)
48
+
49
+ lines.push(styleText('dim', 'Timings'.padStart(labelWidth)))
50
+ for (const timing of timings) {
51
+ const timeStr = formatMs(timing.durationMs)
52
+ const barLength = Math.min(Math.ceil(timing.durationMs / SUMMARY_TIME_SCALE_DIVISOR), SUMMARY_MAX_BAR_LENGTH)
53
+ const bar = styleText('dim', '█'.repeat(barLength))
54
+ lines.push(`${indent}${styleText('dim', '•')} ${timing.plugin.padEnd(nameWidth)} ${bar} ${timeStr}`)
55
+ }
56
+ }
57
+
58
+ return lines
59
+ }
60
+
61
+ /**
62
+ * Renders the summary as plain `console.log` lines so it works in every CLI (no clack/TTY
63
+ * dependency): a blank line, the config name colored by status, then the summary rows.
64
+ */
65
+ function renderSummary(lines: ReadonlyArray<string>, { title, status }: { title: string; status: 'success' | 'failed' }): void {
66
+ console.log('')
67
+ if (title) {
68
+ console.log(styleText(status === 'failed' ? 'red' : 'green', title))
69
+ }
70
+ for (const line of lines) {
71
+ console.log(line)
72
+ }
73
+ }
74
+
75
+ /**
76
+ * The default `cli` reporter. Renders the {@link Report} for each config as it finishes, independent
77
+ * of the live logger view. Suppressed at `silent`; the `verbose` level adds the per-plugin timings.
78
+ */
79
+ export const cliReporter = createReporter({
80
+ name: 'cli',
81
+ report(result, { logLevel }) {
82
+ if (logLevel <= logLevelMap.silent) {
83
+ return
84
+ }
85
+
86
+ const report = buildReport(result)
87
+ const lines = buildSummaryLines(report, { showTimings: logLevel >= logLevelMap.verbose })
88
+ renderSummary(lines, { title: report.name, status: report.status })
89
+ },
90
+ })
@@ -0,0 +1,103 @@
1
+ import { relative, resolve } from 'node:path'
2
+ import process from 'node:process'
3
+ import { stripVTControlCharacters } from 'node:util'
4
+ import { formatMs, write } from '@internals/utils'
5
+ import { createReporter } from '../createReporter.ts'
6
+ import { type Diagnostic, Diagnostics } from '../diagnostics.ts'
7
+ import { buildReport, type Report } from './report.ts'
8
+
9
+ /**
10
+ * Builds the `## Summary` section: the same counts the cli and json reporters expose, as a list of
11
+ * `label value` rows with the labels padded to a common width.
12
+ */
13
+ function buildSummarySection(report: Report): Array<string> {
14
+ const { status, plugins, counts, filesCreated, durationMs, output } = report
15
+
16
+ const rows: Array<[label: string, value: string]> = [
17
+ ['Status', status],
18
+ [
19
+ 'Plugins',
20
+ status === 'success' ? `${plugins.passed} passed (${plugins.total})` : `${plugins.passed} passed | ${plugins.failed.length} failed (${plugins.total})`,
21
+ ],
22
+ ]
23
+
24
+ if (plugins.failed.length > 0) {
25
+ rows.push(['Failed', plugins.failed.join(', ')])
26
+ }
27
+
28
+ rows.push(['Issues', `${counts.errors} errors | ${counts.warnings} warnings | ${counts.infos} infos`])
29
+ rows.push(['Files', `${filesCreated} generated`])
30
+ rows.push(['Duration', formatMs(durationMs)])
31
+ rows.push(['Output', output])
32
+
33
+ const labelWidth = Math.max(...rows.map(([label]) => label.length))
34
+ const lines = rows.map(([label, value]) => ` ${label.padEnd(labelWidth)} ${value}`)
35
+
36
+ return ['## Summary', '', ...lines]
37
+ }
38
+
39
+ /**
40
+ * Builds the `## Problems` section: each problem rendered in the miette block format, blocks
41
+ * separated by a blank line. Returns an empty array when there are no problems, so the caller
42
+ * can drop the heading.
43
+ */
44
+ function buildProblemSection(diagnostics: ReadonlyArray<Diagnostic>): Array<string> {
45
+ const problems = diagnostics.filter(Diagnostics.isProblem)
46
+ if (problems.length === 0) {
47
+ return []
48
+ }
49
+
50
+ const blocks = problems.map((diagnostic) => Diagnostics.formatLines(diagnostic).join('\n'))
51
+ return ['## Problems', '', blocks.join('\n\n')]
52
+ }
53
+
54
+ /**
55
+ * Builds the `## Timings` section from a {@link Report}: one `plugin duration` row per record,
56
+ * slowest first with the plugin names left-aligned and the durations right-aligned. Returns an
57
+ * empty array when there are no timings.
58
+ */
59
+ function buildTimingSection(report: Report): Array<string> {
60
+ const { timings } = report
61
+ if (timings.length === 0) {
62
+ return []
63
+ }
64
+
65
+ const nameWidth = Math.max(...timings.map((timing) => timing.plugin.length))
66
+ const durations = timings.map((timing) => formatMs(timing.durationMs))
67
+ const durationWidth = Math.max(...durations.map((duration) => duration.length))
68
+ const rows = timings.map((timing, index) => ` ${timing.plugin.padEnd(nameWidth)} ${durations[index]!.padStart(durationWidth)}`)
69
+
70
+ return ['## Timings', '', ...rows]
71
+ }
72
+
73
+ /**
74
+ * The `file` reporter. Writes a config's {@link Report} to `.kubb/kubb-<name>-<timestamp>.log` as a
75
+ * plain-text document: a `# <name> — <timestamp>` header, a `## Summary` with the same counts the
76
+ * cli and json reporters expose, a `## Problems` section in the miette block format, and a
77
+ * `## Timings` section. Selected with `--reporter file` (or `reporters: ['file']`), replacing the
78
+ * old `--debug` flag.
79
+ *
80
+ * @note Unlike the streaming logger it replaced, it captures the collected diagnostics once a
81
+ * config finishes, not the live `kubb:info`/`kubb:plugin` event stream. Color is stripped so the
82
+ * file stays plain text even when the run is attached to a TTY.
83
+ */
84
+ export const fileReporter = createReporter({
85
+ name: 'file',
86
+ async report(result) {
87
+ const { diagnostics, config } = result
88
+ if (diagnostics.length === 0) {
89
+ return
90
+ }
91
+
92
+ const report = buildReport(result)
93
+ const header = config.name ? `# ${config.name} — ${new Date().toISOString()}` : `# ${new Date().toISOString()}`
94
+ const sections = [buildSummarySection(report), buildProblemSection(diagnostics), buildTimingSection(report)].filter((section) => section.length > 0)
95
+ const content = stripVTControlCharacters([header, ...sections.map((section) => section.join('\n'))].join('\n\n'))
96
+
97
+ const baseName = `${['kubb', config.name, Date.now()].filter(Boolean).join('-')}.log`
98
+ const pathName = resolve(process.cwd(), '.kubb', baseName)
99
+
100
+ await write(pathName, `${content}\n`)
101
+ console.error(`Debug log written to ${relative(process.cwd(), pathName)}`)
102
+ },
103
+ })
@@ -0,0 +1,20 @@
1
+ import process from 'node:process'
2
+ import { createReporter } from '../createReporter.ts'
3
+ import { buildReport } from './report.ts'
4
+
5
+ /**
6
+ * The `json` reporter. `report` returns one config's {@link Report}, which {@link createReporter}
7
+ * buffers, and `drain` writes them as a single pretty-printed JSON array on `kubb:lifecycle:end`.
8
+ * Buffering keeps a multi-config run one valid JSON document on stdout instead of concatenated
9
+ * objects that would break `jq .`. The terminal reporter is suppressed while `json` is active so
10
+ * stdout stays valid JSON.
11
+ */
12
+ export const jsonReporter = createReporter({
13
+ name: 'json',
14
+ report(result) {
15
+ return buildReport(result)
16
+ },
17
+ drain(_context, reports) {
18
+ process.stdout.write(`${JSON.stringify(reports, null, 2)}\n`)
19
+ },
20
+ })