@kubb/core 5.0.0-beta.4 → 5.0.0-beta.40

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 (57) hide show
  1. package/README.md +15 -148
  2. package/dist/diagnostics-DhfW8YpT.d.ts +2963 -0
  3. package/dist/index.cjs +775 -1193
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.ts +165 -171
  6. package/dist/index.js +760 -1188
  7. package/dist/index.js.map +1 -1
  8. package/dist/memoryStorage-DTv1Kub1.js +2852 -0
  9. package/dist/memoryStorage-DTv1Kub1.js.map +1 -0
  10. package/dist/memoryStorage-Dkxnid2K.cjs +2985 -0
  11. package/dist/memoryStorage-Dkxnid2K.cjs.map +1 -0
  12. package/dist/mocks.cjs +80 -18
  13. package/dist/mocks.cjs.map +1 -1
  14. package/dist/mocks.d.ts +35 -8
  15. package/dist/mocks.js +81 -21
  16. package/dist/mocks.js.map +1 -1
  17. package/package.json +8 -19
  18. package/src/FileManager.ts +85 -63
  19. package/src/FileProcessor.ts +171 -43
  20. package/src/HookRegistry.ts +45 -0
  21. package/src/KubbDriver.ts +897 -0
  22. package/src/Telemetry.ts +278 -0
  23. package/src/Transform.ts +58 -0
  24. package/src/constants.ts +111 -19
  25. package/src/createAdapter.ts +113 -17
  26. package/src/createKubb.ts +921 -493
  27. package/src/createRenderer.ts +58 -27
  28. package/src/createReporter.ts +147 -0
  29. package/src/createStorage.ts +36 -23
  30. package/src/defineGenerator.ts +129 -17
  31. package/src/defineLogger.ts +78 -7
  32. package/src/defineMiddleware.ts +19 -17
  33. package/src/defineParser.ts +30 -13
  34. package/src/definePlugin.ts +320 -17
  35. package/src/defineResolver.ts +381 -179
  36. package/src/diagnostics.ts +660 -0
  37. package/src/index.ts +8 -6
  38. package/src/mocks.ts +92 -19
  39. package/src/reporters/cliReporter.ts +90 -0
  40. package/src/reporters/fileReporter.ts +103 -0
  41. package/src/reporters/jsonReporter.ts +20 -0
  42. package/src/reporters/report.ts +85 -0
  43. package/src/storages/fsStorage.ts +13 -37
  44. package/src/types.ts +59 -1297
  45. package/dist/PluginDriver-Ds-Us-e4.cjs +0 -1036
  46. package/dist/PluginDriver-Ds-Us-e4.cjs.map +0 -1
  47. package/dist/PluginDriver-Wi34Pegx.js +0 -945
  48. package/dist/PluginDriver-Wi34Pegx.js.map +0 -1
  49. package/dist/types-Cd0jhNmx.d.ts +0 -2153
  50. package/src/Kubb.ts +0 -300
  51. package/src/PluginDriver.ts +0 -424
  52. package/src/devtools.ts +0 -59
  53. package/src/renderNode.ts +0 -35
  54. package/src/utils/diagnostics.ts +0 -18
  55. package/src/utils/isInputPath.ts +0 -10
  56. package/src/utils/packageJSON.ts +0 -99
  57. /package/dist/{chunk--u3MIqq1.js → chunk-C0LytTxp.js} +0 -0
package/src/mocks.ts CHANGED
@@ -1,16 +1,22 @@
1
- import { resolve } from 'node:path'
2
- import type { FileNode, OperationNode, SchemaNode, Visitor } from '@kubb/ast'
1
+ import path, { resolve } from 'node:path'
2
+ import { camelCase } from '@internals/utils'
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'
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 { 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
14
 
11
15
  * Creates a minimal `PluginDriver` mock for unit tests.
12
16
  */
13
- export function createMockedPluginDriver(options: { name?: string; plugin?: NormalizedPlugin; config?: Config } = {}): PluginDriver {
17
+ export function createMockedPluginDriver(options: { name?: string; plugin?: NormalizedPlugin; config?: Config } = {}): KubbDriver {
18
+ const fileManager = new FileManager()
19
+
14
20
  return {
15
21
  config: options?.config ?? {
16
22
  root: '.',
@@ -22,29 +28,45 @@ export function createMockedPluginDriver(options: { name?: string; plugin?: Norm
22
28
  return options?.plugin
23
29
  },
24
30
  getResolver: (_pluginName: string) => options?.plugin?.resolver,
25
- fileManager: new FileManager(),
26
- } as unknown as PluginDriver
31
+ fileManager,
32
+ async dispatch({ result, renderer }: { result: unknown; renderer?: RendererFactory | null }): Promise<void> {
33
+ if (!result) return
34
+
35
+ if (Array.isArray(result)) {
36
+ fileManager.upsert(...(result as Array<FileNode>))
37
+ return
38
+ }
39
+
40
+ if (!renderer) return
41
+
42
+ using instance = renderer()
43
+ if (instance.stream) {
44
+ for (const file of instance.stream(result)) fileManager.upsert(file)
45
+ return
46
+ }
47
+
48
+ await instance.render(result)
49
+ fileManager.upsert(...instance.files)
50
+ },
51
+ } as unknown as KubbDriver
27
52
  }
28
53
 
29
54
  /**
30
55
  * Creates a minimal `Adapter` mock for unit tests.
31
- * `parse` returns an empty `InputNode` by default; override via `options.parse`.
56
+ * `parse` returns an empty `InputNode` by default. Override via `options.parse`.
32
57
  * `getImports` returns `[]` by default.
33
58
  */
34
59
  export function createMockedAdapter<TOptions extends AdapterFactoryOptions = AdapterFactoryOptions>(
35
60
  options: {
36
61
  name?: TOptions['name']
37
62
  resolvedOptions?: TOptions['resolvedOptions']
38
- inputNode?: Adapter<TOptions>['inputNode']
39
63
  parse?: Adapter<TOptions>['parse']
40
64
  getImports?: Adapter<TOptions>['getImports']
41
65
  } = {},
42
66
  ): Adapter<TOptions> {
43
- const inputNode = options.inputNode ?? null
44
67
  return {
45
68
  name: (options.name ?? 'oas') as TOptions['name'],
46
69
  options: (options.resolvedOptions ?? {}) as TOptions['resolvedOptions'],
47
- inputNode,
48
70
  parse: options.parse ?? (async () => ({ kind: 'Input' as const, schemas: [], operations: [] })),
49
71
  getImports: options.getImports ?? ((_node: SchemaNode, _resolve: (schemaName: string) => { name: string; path: string }) => []),
50
72
  } as Adapter<TOptions>
@@ -76,7 +98,8 @@ export function createMockedPlugin<TOptions extends PluginFactoryOptions = Plugi
76
98
  type RenderGeneratorOptions<TOptions extends PluginFactoryOptions> = {
77
99
  config: Config
78
100
  adapter: Adapter
79
- driver: PluginDriver
101
+ meta?: InputMeta
102
+ driver: KubbDriver
80
103
  plugin: NormalizedPlugin<TOptions>
81
104
  options: TOptions['resolvedOptions']
82
105
  resolver: TOptions['resolver']
@@ -88,20 +111,19 @@ function createMockedPluginContext<TOptions extends PluginFactoryOptions>(opts:
88
111
  return {
89
112
  config: opts.config,
90
113
  root,
91
- getMode: (output: { path: string }) => PluginDriver.getMode(resolve(root, output.path)),
114
+ getMode: (output: { path: string }) => KubbDriver.getMode(resolve(root, output.path)),
92
115
  adapter: opts.adapter,
93
116
  resolver: opts.resolver,
94
117
  plugin: opts.plugin,
95
118
  driver: opts.driver,
96
119
  getResolver: (name: string) => opts.driver.getResolver(name),
97
- inputNode: { kind: 'Input', schemas: [], operations: [] },
120
+ meta: opts.meta ?? { circularNames: [], enumNames: [] },
98
121
  addFile: async (...files: Array<FileNode>) => opts.driver.fileManager.add(...files),
99
122
  upsertFile: async (...files: Array<FileNode>) => opts.driver.fileManager.upsert(...files),
100
123
  hooks: opts.driver.hooks ?? ({} as never),
101
124
  warn: (msg: string) => console.warn(msg),
102
125
  error: (msg: string) => console.error(msg),
103
126
  info: (msg: string) => console.info(msg),
104
- openInStudio: async () => {},
105
127
  } as unknown as Omit<GeneratorContext<TOptions>, 'options'>
106
128
  }
107
129
 
@@ -126,7 +148,7 @@ export async function renderGeneratorSchema<TOptions extends PluginFactoryOption
126
148
  ...context,
127
149
  options: opts.options,
128
150
  })
129
- await applyHookResult(result, opts.driver, generator.renderer ?? undefined)
151
+ await opts.driver.dispatch({ result, renderer: generator.renderer })
130
152
  }
131
153
 
132
154
  /**
@@ -150,7 +172,7 @@ export async function renderGeneratorOperation<TOptions extends PluginFactoryOpt
150
172
  ...context,
151
173
  options: opts.options,
152
174
  })
153
- await applyHookResult(result, opts.driver, generator.renderer ?? undefined)
175
+ await opts.driver.dispatch({ result, renderer: generator.renderer })
154
176
  }
155
177
 
156
178
  /**
@@ -174,5 +196,56 @@ export async function renderGeneratorOperations<TOptions extends PluginFactoryOp
174
196
  ...context,
175
197
  options: opts.options,
176
198
  })
177
- await applyHookResult(result, opts.driver, generator.renderer ?? undefined)
199
+ await opts.driver.dispatch({ result, renderer: generator.renderer })
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
178
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
+ })
@@ -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
+ export 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
+ }
@@ -4,13 +4,6 @@ import { join, resolve } from 'node:path'
4
4
  import { clean, write } from '@internals/utils'
5
5
  import { createStorage } from '../createStorage.ts'
6
6
 
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
7
  /**
15
8
  * Built-in filesystem storage driver.
16
9
  *
@@ -42,27 +35,15 @@ export const fsStorage = createStorage(() => ({
42
35
  try {
43
36
  await access(resolve(key))
44
37
  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
- })
38
+ } catch (_error) {
39
+ return false
53
40
  }
54
41
  },
55
42
  async getItem(key: string) {
56
43
  try {
57
44
  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
- })
45
+ } catch (_error) {
46
+ return null
66
47
  }
67
48
  },
68
49
  async setItem(key: string, value: string) {
@@ -72,36 +53,31 @@ export const fsStorage = createStorage(() => ({
72
53
  await rm(resolve(key), { force: true })
73
54
  },
74
55
  async getKeys(base?: string) {
75
- const keys: Array<string> = []
76
56
  const resolvedBase = resolve(base ?? process.cwd())
77
57
 
78
- async function walk(dir: string, prefix: string): Promise<void> {
58
+ async function* walk(dir: string, prefix: string): AsyncGenerator<string, void, undefined> {
79
59
  let entries: Array<Dirent>
80
60
  try {
81
61
  entries = (await readdir(dir, {
82
62
  withFileTypes: true,
83
63
  })) as Array<Dirent>
84
- } catch (error) {
85
- if (isMissingPathError(error)) {
86
- return
87
- }
88
-
89
- throw new Error(`Failed to list storage keys under "${resolvedBase}"`, {
90
- cause: error as Error,
91
- })
64
+ } catch (_error) {
65
+ return
92
66
  }
93
67
  for (const entry of entries) {
94
68
  const rel = prefix ? `${prefix}/${entry.name}` : entry.name
95
69
  if (entry.isDirectory()) {
96
- await walk(join(dir, entry.name), rel)
70
+ yield* walk(join(dir, entry.name), rel)
97
71
  } else {
98
- keys.push(rel)
72
+ yield rel
99
73
  }
100
74
  }
101
75
  }
102
76
 
103
- await walk(resolvedBase, '')
104
-
77
+ const keys: Array<string> = []
78
+ for await (const key of walk(resolvedBase, '')) {
79
+ keys.push(key)
80
+ }
105
81
  return keys
106
82
  },
107
83
  async clear(base?: string) {