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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +17 -10
  2. package/README.md +25 -158
  3. package/dist/diagnostics-DiaUv_iK.d.ts +2904 -0
  4. package/dist/index.cjs +2523 -1071
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.ts +80 -273
  7. package/dist/index.js +2513 -1067
  8. package/dist/index.js.map +1 -1
  9. package/dist/memoryStorage-CUj1hrxa.cjs +823 -0
  10. package/dist/memoryStorage-CUj1hrxa.cjs.map +1 -0
  11. package/dist/memoryStorage-CWFzAz4o.js +714 -0
  12. package/dist/memoryStorage-CWFzAz4o.js.map +1 -0
  13. package/dist/mocks.cjs +83 -23
  14. package/dist/mocks.cjs.map +1 -1
  15. package/dist/mocks.d.ts +36 -10
  16. package/dist/mocks.js +85 -27
  17. package/dist/mocks.js.map +1 -1
  18. package/package.json +8 -28
  19. package/src/FileManager.ts +86 -64
  20. package/src/FileProcessor.ts +170 -44
  21. package/src/KubbDriver.ts +909 -0
  22. package/src/Transform.ts +105 -0
  23. package/src/constants.ts +111 -20
  24. package/src/createAdapter.ts +112 -17
  25. package/src/createKubb.ts +140 -517
  26. package/src/createRenderer.ts +43 -28
  27. package/src/createReporter.ts +134 -0
  28. package/src/createStorage.ts +36 -23
  29. package/src/defineGenerator.ts +140 -17
  30. package/src/defineParser.ts +30 -12
  31. package/src/definePlugin.ts +375 -21
  32. package/src/defineResolver.ts +402 -212
  33. package/src/diagnostics.ts +662 -0
  34. package/src/index.ts +8 -8
  35. package/src/mocks.ts +97 -26
  36. package/src/reporters/cliReporter.ts +89 -0
  37. package/src/reporters/fileReporter.ts +103 -0
  38. package/src/reporters/jsonReporter.ts +20 -0
  39. package/src/reporters/report.ts +85 -0
  40. package/src/storages/fsStorage.ts +23 -55
  41. package/src/types.ts +411 -887
  42. package/dist/PluginDriver-BkTRD2H2.js +0 -946
  43. package/dist/PluginDriver-BkTRD2H2.js.map +0 -1
  44. package/dist/PluginDriver-Cadu4ORh.cjs +0 -1037
  45. package/dist/PluginDriver-Cadu4ORh.cjs.map +0 -1
  46. package/dist/types-DVPKmzw_.d.ts +0 -2159
  47. package/src/Kubb.ts +0 -300
  48. package/src/PluginDriver.ts +0 -426
  49. package/src/defineLogger.ts +0 -19
  50. package/src/defineMiddleware.ts +0 -62
  51. package/src/devtools.ts +0 -59
  52. package/src/renderNode.ts +0 -35
  53. package/src/utils/diagnostics.ts +0 -18
  54. package/src/utils/isInputPath.ts +0 -10
  55. package/src/utils/packageJSON.ts +0 -99
  56. /package/dist/{chunk--u3MIqq1.js → chunk-C0LytTxp.js} +0 -0
package/src/mocks.ts CHANGED
@@ -1,16 +1,21 @@
1
- import { resolve } from 'node:path'
2
- import type { FileNode, OperationNode, SchemaNode, Visitor } from '@kubb/ast'
3
- import { transform } from '@kubb/ast'
1
+ import path, { resolve } from 'node:path'
2
+ import { camelCase } from '@internals/utils'
3
+ import type { FileNode, InputMeta, Macro, OperationNode, SchemaNode } from '@kubb/ast'
4
+ import { applyMacros } from '@kubb/ast'
5
+ import { expect } from 'vitest'
6
+ import type { Parser } from './defineParser.ts'
4
7
  import { FileManager } from './FileManager.ts'
5
- import { PluginDriver } from './PluginDriver.ts'
6
- import { applyHookResult } from './renderNode.ts'
7
- import type { Adapter, AdapterFactoryOptions, Config, Generator, GeneratorContext, NormalizedPlugin, PluginFactoryOptions } from './types.ts'
8
+ import { FileProcessor } from './FileProcessor.ts'
9
+ import type { KubbDriver } from './KubbDriver.ts'
10
+ import { memoryStorage } from './storages/memoryStorage.ts'
11
+ import type { Adapter, AdapterFactoryOptions, Config, Generator, GeneratorContext, NormalizedPlugin, PluginFactoryOptions, RendererFactory } from './types.ts'
8
12
 
9
13
  /**
10
-
11
14
  * Creates a minimal `PluginDriver` mock for unit tests.
12
15
  */
13
- export function createMockedPluginDriver(options: { name?: string; plugin?: NormalizedPlugin; config?: Config } = {}): PluginDriver {
16
+ export function createMockedPluginDriver(options: { name?: string; plugin?: NormalizedPlugin; config?: Config } = {}): KubbDriver {
17
+ const fileManager = new FileManager()
18
+
14
19
  return {
15
20
  config: options?.config ?? {
16
21
  root: '.',
@@ -22,29 +27,45 @@ export function createMockedPluginDriver(options: { name?: string; plugin?: Norm
22
27
  return options?.plugin
23
28
  },
24
29
  getResolver: (_pluginName: string) => options?.plugin?.resolver,
25
- fileManager: new FileManager(),
26
- } as unknown as PluginDriver
30
+ fileManager,
31
+ async dispatch({ result, renderer }: { result: unknown; renderer?: RendererFactory | null }): Promise<void> {
32
+ if (!result) return
33
+
34
+ if (Array.isArray(result)) {
35
+ fileManager.upsert(...(result as Array<FileNode>))
36
+ return
37
+ }
38
+
39
+ if (!renderer) return
40
+
41
+ using instance = renderer()
42
+ if (instance.stream) {
43
+ for (const file of instance.stream(result)) fileManager.upsert(file)
44
+ return
45
+ }
46
+
47
+ await instance.render(result)
48
+ fileManager.upsert(...instance.files)
49
+ },
50
+ } as unknown as KubbDriver
27
51
  }
28
52
 
29
53
  /**
30
54
  * Creates a minimal `Adapter` mock for unit tests.
31
- * `parse` returns an empty `InputNode` by default; override via `options.parse`.
55
+ * `parse` returns an empty `InputNode` by default. Override via `options.parse`.
32
56
  * `getImports` returns `[]` by default.
33
57
  */
34
58
  export function createMockedAdapter<TOptions extends AdapterFactoryOptions = AdapterFactoryOptions>(
35
59
  options: {
36
60
  name?: TOptions['name']
37
61
  resolvedOptions?: TOptions['resolvedOptions']
38
- inputNode?: Adapter<TOptions>['inputNode']
39
62
  parse?: Adapter<TOptions>['parse']
40
63
  getImports?: Adapter<TOptions>['getImports']
41
64
  } = {},
42
65
  ): Adapter<TOptions> {
43
- const inputNode = options.inputNode ?? null
44
66
  return {
45
67
  name: (options.name ?? 'oas') as TOptions['name'],
46
68
  options: (options.resolvedOptions ?? {}) as TOptions['resolvedOptions'],
47
- inputNode,
48
69
  parse: options.parse ?? (async () => ({ kind: 'Input' as const, schemas: [], operations: [] })),
49
70
  getImports: options.getImports ?? ((_node: SchemaNode, _resolve: (schemaName: string) => { name: string; path: string }) => []),
50
71
  } as Adapter<TOptions>
@@ -60,14 +81,14 @@ export function createMockedPlugin<TOptions extends PluginFactoryOptions = Plugi
60
81
  name: TOptions['name']
61
82
  options: TOptions['resolvedOptions']
62
83
  resolver?: TOptions['resolver']
63
- transformer?: Visitor
84
+ macros?: Array<Macro>
64
85
  dependencies?: Array<string>
65
86
  }): NormalizedPlugin<TOptions> {
66
87
  return {
67
88
  name: params.name,
68
89
  options: params.options,
69
90
  resolver: params.resolver,
70
- transformer: params.transformer,
91
+ macros: params.macros,
71
92
  dependencies: params.dependencies,
72
93
  hooks: {},
73
94
  } as unknown as NormalizedPlugin<TOptions>
@@ -76,7 +97,8 @@ export function createMockedPlugin<TOptions extends PluginFactoryOptions = Plugi
76
97
  type RenderGeneratorOptions<TOptions extends PluginFactoryOptions> = {
77
98
  config: Config
78
99
  adapter: Adapter
79
- driver: PluginDriver
100
+ meta?: InputMeta
101
+ driver: KubbDriver
80
102
  plugin: NormalizedPlugin<TOptions>
81
103
  options: TOptions['resolvedOptions']
82
104
  resolver: TOptions['resolver']
@@ -88,20 +110,18 @@ function createMockedPluginContext<TOptions extends PluginFactoryOptions>(opts:
88
110
  return {
89
111
  config: opts.config,
90
112
  root,
91
- getMode: (output: { path: string }) => PluginDriver.getMode(resolve(root, output.path)),
92
113
  adapter: opts.adapter,
93
114
  resolver: opts.resolver,
94
115
  plugin: opts.plugin,
95
116
  driver: opts.driver,
96
117
  getResolver: (name: string) => opts.driver.getResolver(name),
97
- inputNode: { kind: 'Input', schemas: [], operations: [] },
118
+ meta: opts.meta ?? { circularNames: [], enumNames: [] },
98
119
  addFile: async (...files: Array<FileNode>) => opts.driver.fileManager.add(...files),
99
120
  upsertFile: async (...files: Array<FileNode>) => opts.driver.fileManager.upsert(...files),
100
121
  hooks: opts.driver.hooks ?? ({} as never),
101
122
  warn: (msg: string) => console.warn(msg),
102
123
  error: (msg: string) => console.error(msg),
103
124
  info: (msg: string) => console.info(msg),
104
- openInStudio: async () => {},
105
125
  } as unknown as Omit<GeneratorContext<TOptions>, 'options'>
106
126
  }
107
127
 
@@ -121,12 +141,12 @@ export async function renderGeneratorSchema<TOptions extends PluginFactoryOption
121
141
  ): Promise<void> {
122
142
  if (!generator.schema) return
123
143
  const context = createMockedPluginContext(opts)
124
- const transformedNode = opts.plugin.transformer ? transform(node, opts.plugin.transformer) : node
144
+ const transformedNode = opts.plugin.macros?.length ? applyMacros(node, opts.plugin.macros) : node
125
145
  const result = await generator.schema(transformedNode, {
126
146
  ...context,
127
147
  options: opts.options,
128
148
  })
129
- await applyHookResult(result, opts.driver, generator.renderer ?? undefined)
149
+ await opts.driver.dispatch({ result, renderer: generator.renderer })
130
150
  }
131
151
 
132
152
  /**
@@ -145,12 +165,12 @@ export async function renderGeneratorOperation<TOptions extends PluginFactoryOpt
145
165
  ): Promise<void> {
146
166
  if (!generator.operation) return
147
167
  const context = createMockedPluginContext(opts)
148
- const transformedNode = opts.plugin.transformer ? transform(node, opts.plugin.transformer) : node
168
+ const transformedNode = opts.plugin.macros?.length ? applyMacros(node, opts.plugin.macros) : node
149
169
  const result = await generator.operation(transformedNode, {
150
170
  ...context,
151
171
  options: opts.options,
152
172
  })
153
- await applyHookResult(result, opts.driver, generator.renderer ?? undefined)
173
+ await opts.driver.dispatch({ result, renderer: generator.renderer })
154
174
  }
155
175
 
156
176
  /**
@@ -169,10 +189,61 @@ export async function renderGeneratorOperations<TOptions extends PluginFactoryOp
169
189
  ): Promise<void> {
170
190
  if (!generator.operations) return
171
191
  const context = createMockedPluginContext(opts)
172
- const transformedNodes = opts.plugin.transformer ? nodes.map((n) => transform(n, opts.plugin.transformer!)) : nodes
192
+ const transformedNodes = opts.plugin.macros?.length ? nodes.map((n) => applyMacros(n, opts.plugin.macros!)) : nodes
173
193
  const result = await generator.operations(transformedNodes, {
174
194
  ...context,
175
195
  options: opts.options,
176
196
  })
177
- await applyHookResult(result, opts.driver, generator.renderer ?? undefined)
197
+ await opts.driver.dispatch({ result, renderer: generator.renderer })
198
+ }
199
+
200
+ type MatchFilesOptions = {
201
+ /**
202
+ * Parsers indexed by file extension, used to render each `FileNode` to source.
203
+ * Without a matching parser the file's raw content is used.
204
+ */
205
+ parsers?: Map<FileNode['extname'], Parser>
206
+ /**
207
+ * Formatter applied to non-JSON output before snapshotting, e.g. prettier. When
208
+ * omitted the parsed source is snapshotted as-is.
209
+ */
210
+ format?: (source?: string) => string | Promise<string>
211
+ /**
212
+ * Subfolder under `__snapshots__`, camelCased. Useful to keep variant snapshots apart.
213
+ */
214
+ pre?: string
215
+ }
216
+
217
+ /**
218
+ * Renders the driver's collected `FileNode`s to source and asserts each against a file snapshot.
219
+ * Pair it with the `renderGenerator*` helpers to snapshot a generator's output.
220
+ *
221
+ * @example
222
+ * ```ts
223
+ * await renderGeneratorSchema(typeGenerator, node, { config, adapter, driver, plugin, options, resolver })
224
+ * await matchFiles(driver.fileManager.files, { parsers, format })
225
+ * ```
226
+ */
227
+ export async function matchFiles(files: Array<FileNode> | undefined, options: MatchFilesOptions = {}): Promise<Map<string, string> | undefined> {
228
+ if (!files?.length) return
229
+
230
+ const { parsers = new Map(), format, pre } = options
231
+ const fileProcessor = new FileProcessor({ storage: memoryStorage(), parsers })
232
+ const processed = new Map<string, string>()
233
+
234
+ for (const file of files) {
235
+ if (!file?.path || processed.has(file.path)) {
236
+ continue
237
+ }
238
+
239
+ const parsed = fileProcessor.parse(file)
240
+ const code = file.baseName.endsWith('.json') || !format ? parsed : await format(parsed)
241
+
242
+ processed.set(file.path, code)
243
+
244
+ const snapshotPath = path.join('__snapshots__', ...(pre ? [camelCase(pre)] : []), file.baseName)
245
+ await expect(code).toMatchFileSnapshot(snapshotPath)
246
+ }
247
+
248
+ return processed
178
249
  }
@@ -0,0 +1,89 @@
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 { createReporter, logLevel as logLevelMap } from '../createReporter.ts'
5
+ import { buildReport, type Report } from './report.ts'
6
+
7
+ /**
8
+ * Builds the vitest/jest-style summary for one {@link Report}: right-aligned dim labels with
9
+ * `N passed (total)` counts, and a per-plugin `Timings` section when `showTimings`.
10
+ */
11
+ function buildSummaryLines(report: Report, { showTimings }: { showTimings: boolean }): Array<string> {
12
+ const { status, plugins, counts, filesCreated, durationMs, output, timings } = report
13
+
14
+ const rows: Array<[label: string, value: string]> = []
15
+
16
+ rows.push([
17
+ 'Plugins',
18
+ status === 'success'
19
+ ? `${styleText('green', `${plugins.passed} passed`)} (${plugins.total})`
20
+ : `${styleText('green', `${plugins.passed} passed`)} | ${styleText('red', `${plugins.failed.length} failed`)} (${plugins.total})`,
21
+ ])
22
+
23
+ if (status === 'failed' && plugins.failed.length > 0) {
24
+ rows.push(['Failed', plugins.failed.map((name) => randomCliColor(name)).join(', ')])
25
+ }
26
+
27
+ if (counts.errors > 0 || counts.warnings > 0) {
28
+ const issues = [
29
+ counts.errors > 0 ? styleText('red', `${counts.errors} ${counts.errors === 1 ? 'error' : 'errors'}`) : undefined,
30
+ counts.warnings > 0 ? styleText('yellow', `${counts.warnings} ${counts.warnings === 1 ? 'warning' : 'warnings'}`) : undefined,
31
+ ]
32
+ .filter(Boolean)
33
+ .join(' | ')
34
+ rows.push(['Issues', issues])
35
+ }
36
+
37
+ rows.push(['Files', `${styleText('green', String(filesCreated))} generated`])
38
+ rows.push(['Duration', styleText('green', formatMs(durationMs))])
39
+ rows.push(['Output', output])
40
+
41
+ const labelWidth = Math.max(...rows.map(([label]) => label.length), timings.length > 0 ? 'Timings'.length : 0)
42
+ const lines = rows.map(([label, value]) => `${styleText('dim', label.padStart(labelWidth))} ${value}`)
43
+
44
+ if (showTimings && timings.length > 0) {
45
+ const nameWidth = Math.max(0, ...timings.map((timing) => timing.plugin.length))
46
+ const indent = ' '.repeat(labelWidth + 2)
47
+
48
+ lines.push(styleText('dim', 'Timings'.padStart(labelWidth)))
49
+ for (const timing of timings) {
50
+ const timeStr = formatMs(timing.durationMs)
51
+ const barLength = Math.min(Math.ceil(timing.durationMs / SUMMARY_TIME_SCALE_DIVISOR), SUMMARY_MAX_BAR_LENGTH)
52
+ const bar = styleText('dim', '█'.repeat(barLength))
53
+ lines.push(`${indent}${styleText('dim', '•')} ${timing.plugin.padEnd(nameWidth)} ${bar} ${timeStr}`)
54
+ }
55
+ }
56
+
57
+ return lines
58
+ }
59
+
60
+ /**
61
+ * Renders the summary as plain `console.log` lines so it works in every CLI (no clack/TTY
62
+ * dependency): a blank line, the config name colored by status, then the summary rows.
63
+ */
64
+ function renderSummary(lines: ReadonlyArray<string>, { title, status }: { title: string; status: 'success' | 'failed' }): void {
65
+ console.log('')
66
+ if (title) {
67
+ console.log(styleText(status === 'failed' ? 'red' : 'green', title))
68
+ }
69
+ for (const line of lines) {
70
+ console.log(line)
71
+ }
72
+ }
73
+
74
+ /**
75
+ * The default `cli` reporter. Renders the {@link Report} for each config as it finishes, independent
76
+ * of the live logger view. Suppressed at `silent`. The `verbose` level adds the per-plugin timings.
77
+ */
78
+ export const cliReporter = createReporter({
79
+ name: 'cli',
80
+ report(result, { logLevel }) {
81
+ if (logLevel <= logLevelMap.silent) {
82
+ return
83
+ }
84
+
85
+ const report = buildReport(result)
86
+ const lines = buildSummaryLines(report, { showTimings: logLevel >= logLevelMap.verbose })
87
+ renderSummary(lines, { title: report.name, status: report.status })
88
+ },
89
+ })
@@ -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
+ })
@@ -0,0 +1,85 @@
1
+ import { resolve } from 'node:path'
2
+ import { getElapsedMs } from '@internals/utils'
3
+ import type { GenerationResult } from '../createReporter.ts'
4
+ import { Diagnostics, type SerializedDiagnostic } from '../diagnostics.ts'
5
+
6
+ /**
7
+ * One plugin's elapsed time, derived from a `performance` diagnostic.
8
+ */
9
+ type ReportTiming = {
10
+ plugin: string
11
+ durationMs: number
12
+ }
13
+
14
+ /**
15
+ * The normalized result of generating one config, shared by every reporter. Each reporter renders
16
+ * the same {@link Report} in its own format (the `cli` summary, the `json` document, the `file`
17
+ * log), so they always agree on the numbers. Build it with {@link buildReport}.
18
+ */
19
+ export type Report = {
20
+ /**
21
+ * The config name, or an empty string when it is unnamed.
22
+ */
23
+ name: string
24
+ status: 'success' | 'failed'
25
+ plugins: {
26
+ passed: number
27
+ /**
28
+ * Names of the plugins that failed.
29
+ */
30
+ failed: Array<string>
31
+ total: number
32
+ }
33
+ counts: {
34
+ errors: number
35
+ warnings: number
36
+ infos: number
37
+ }
38
+ filesCreated: number
39
+ /**
40
+ * Wall-clock time spent generating this config, in milliseconds.
41
+ */
42
+ durationMs: number
43
+ /**
44
+ * Absolute output directory the files were written to.
45
+ */
46
+ output: string
47
+ /**
48
+ * Per-plugin durations, slowest first.
49
+ */
50
+ timings: Array<ReportTiming>
51
+ /**
52
+ * The build problems, serialized to their JSON-safe fields plus a `docsUrl`.
53
+ */
54
+ diagnostics: Array<SerializedDiagnostic>
55
+ }
56
+
57
+ /**
58
+ * Builds the normalized {@link Report} for one config from its {@link GenerationResult}. Splits the
59
+ * diagnostics into problems and per-plugin timings (slowest first) and derives the plugin and issue
60
+ * counts, so every reporter renders the same data.
61
+ */
62
+ export function buildReport(result: GenerationResult): Report {
63
+ const { config, diagnostics, filesCreated, status, hrStart } = result
64
+
65
+ const failed = Diagnostics.failedPlugins(diagnostics)
66
+ const total = config.plugins?.length ?? 0
67
+ const counts = Diagnostics.count(diagnostics)
68
+ const problems = diagnostics.filter(Diagnostics.isProblem)
69
+ const timings = diagnostics
70
+ .filter(Diagnostics.isPerformance)
71
+ .sort((a, b) => b.duration - a.duration)
72
+ .map((diagnostic) => ({ plugin: diagnostic.plugin, durationMs: diagnostic.duration }))
73
+
74
+ return {
75
+ name: config.name ?? '',
76
+ status,
77
+ plugins: { passed: total - failed.length, failed, total },
78
+ counts,
79
+ filesCreated,
80
+ durationMs: getElapsedMs(hrStart),
81
+ output: resolve(config.root, config.output.path),
82
+ timings,
83
+ diagnostics: problems.map((diagnostic) => Diagnostics.serialize(diagnostic)),
84
+ }
85
+ }
@@ -1,16 +1,8 @@
1
- import type { Dirent } from 'node:fs'
2
- import { access, readdir, readFile, rm } from 'node:fs/promises'
3
- import { join, resolve } from 'node:path'
4
- import { clean, write } from '@internals/utils'
1
+ import { access, glob, readFile, rm } from 'node:fs/promises'
2
+ import { join, relative, resolve } from 'node:path'
3
+ import { clean, runtime, toPosixPath, write } from '@internals/utils'
5
4
  import { createStorage } from '../createStorage.ts'
6
5
 
7
- /**
8
- * Detects the filesystem error used to indicate that a path does not exist.
9
- */
10
- function isMissingPathError(error: unknown): error is NodeJS.ErrnoException {
11
- return typeof error === 'object' && error !== null && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT'
12
- }
13
-
14
6
  /**
15
7
  * Built-in filesystem storage driver.
16
8
  *
@@ -18,11 +10,11 @@ function isMissingPathError(error: unknown): error is NodeJS.ErrnoException {
18
10
  * Keys are resolved against `process.cwd()`, so root-relative paths such as
19
11
  * `src/gen/api/getPets.ts` are written to the correct location without extra configuration.
20
12
  *
21
- * Internally uses the `write` utility from `@internals/utils`, which:
22
- * - trims leading/trailing whitespace before writing
23
- * - skips the write when file content is already identical (deduplication)
24
- * - creates missing parent directories automatically
25
- * - supports Bun's native file API when running under Bun
13
+ * Writes are deduplicated and directory-safe:
14
+ * - leading and trailing whitespace is trimmed before writing
15
+ * - the write is skipped when the file content is already identical
16
+ * - missing parent directories are created automatically
17
+ * - Bun's native file API is used when running under Bun
26
18
  *
27
19
  * @example
28
20
  * ```ts
@@ -42,27 +34,15 @@ export const fsStorage = createStorage(() => ({
42
34
  try {
43
35
  await access(resolve(key))
44
36
  return true
45
- } catch (error) {
46
- if (isMissingPathError(error)) {
47
- return false
48
- }
49
-
50
- throw new Error(`Failed to access storage item "${key}"`, {
51
- cause: error as Error,
52
- })
37
+ } catch (_error) {
38
+ return false
53
39
  }
54
40
  },
55
41
  async getItem(key: string) {
56
42
  try {
57
43
  return await readFile(resolve(key), 'utf8')
58
- } catch (error) {
59
- if (isMissingPathError(error)) {
60
- return null
61
- }
62
-
63
- throw new Error(`Failed to read storage item "${key}"`, {
64
- cause: error as Error,
65
- })
44
+ } catch (_error) {
45
+ return null
66
46
  }
67
47
  },
68
48
  async setItem(key: string, value: string) {
@@ -72,36 +52,24 @@ export const fsStorage = createStorage(() => ({
72
52
  await rm(resolve(key), { force: true })
73
53
  },
74
54
  async getKeys(base?: string) {
75
- const keys: Array<string> = []
76
55
  const resolvedBase = resolve(base ?? process.cwd())
77
56
 
78
- async function walk(dir: string, prefix: string): Promise<void> {
79
- let entries: Array<Dirent>
80
- try {
81
- entries = (await readdir(dir, {
82
- withFileTypes: true,
83
- })) as Array<Dirent>
84
- } catch (error) {
85
- if (isMissingPathError(error)) {
86
- return
87
- }
57
+ if (runtime.isBun) {
58
+ const bunGlob = new Bun.Glob('**/*')
59
+ return Array.fromAsync(bunGlob.scan({ cwd: resolvedBase, onlyFiles: true, dot: true }))
60
+ }
88
61
 
89
- throw new Error(`Failed to list storage keys under "${resolvedBase}"`, {
90
- cause: error as Error,
91
- })
92
- }
93
- for (const entry of entries) {
94
- const rel = prefix ? `${prefix}/${entry.name}` : entry.name
95
- if (entry.isDirectory()) {
96
- await walk(join(dir, entry.name), rel)
97
- } else {
98
- keys.push(rel)
62
+ const keys: Array<string> = []
63
+ try {
64
+ for await (const entry of glob('**/*', { cwd: resolvedBase, withFileTypes: true })) {
65
+ if (entry.isFile()) {
66
+ keys.push(toPosixPath(relative(resolvedBase, join(entry.parentPath, entry.name))))
99
67
  }
100
68
  }
69
+ } catch (_error) {
70
+ // base directory does not exist yet
101
71
  }
102
72
 
103
- await walk(resolvedBase, '')
104
-
105
73
  return keys
106
74
  },
107
75
  async clear(base?: string) {