@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
@@ -1,40 +1,256 @@
1
1
  import path from 'node:path'
2
2
  import { camelCase, pascalCase } from '@internals/utils'
3
- import type { FileNode, InputNode, Node, OperationNode, SchemaNode } from '@kubb/ast'
3
+ import type { FileNode, InputMeta, Node, OperationNode, SchemaNode } from '@kubb/ast'
4
4
  import { createFile, isOperationNode, isSchemaNode } from '@kubb/ast'
5
- import { PluginDriver } from './PluginDriver.ts'
6
- import type {
7
- Config,
8
- PluginFactoryOptions,
9
- ResolveBannerContext,
10
- ResolveOptionsContext,
11
- Resolver,
12
- ResolverContext,
13
- ResolverFileParams,
14
- ResolverPathParams,
15
- } from './types.ts'
5
+ import { Diagnostics } from './diagnostics.ts'
6
+ import type { PluginFactoryOptions } from './definePlugin.ts'
7
+ import { getMode } from './definePlugin.ts'
8
+ import type { Config, Group, Output } from './types.ts'
9
+
10
+ /**
11
+ * Type/string pattern filter for include/exclude/override matching.
12
+ */
13
+ type PatternFilter = {
14
+ type: string
15
+ pattern: string | RegExp
16
+ }
17
+
18
+ /**
19
+ * Pattern filter with partial option overrides applied when the pattern matches.
20
+ */
21
+ type PatternOverride<TOptions> = PatternFilter & {
22
+ options: Omit<Partial<TOptions>, 'override'>
23
+ }
24
+
25
+ /**
26
+ * Context for resolving filtered options for a given operation or schema node.
27
+ *
28
+ * @internal
29
+ */
30
+ export type ResolveOptionsContext<TOptions> = {
31
+ options: TOptions
32
+ exclude?: Array<PatternFilter>
33
+ include?: Array<PatternFilter>
34
+ override?: Array<PatternOverride<TOptions>>
35
+ }
36
+
37
+ /**
38
+ * Base constraint for all plugin resolver objects.
39
+ *
40
+ * `default`, `resolveOptions`, `resolvePath`, `resolveFile`, `resolveBanner`, and `resolveFooter`
41
+ * are injected automatically by `defineResolver`. Extend this type to add custom resolution methods.
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * type MyResolver = Resolver & {
46
+ * resolveName(node: SchemaNode): string
47
+ * resolveTypedName(node: SchemaNode): string
48
+ * }
49
+ * ```
50
+ */
51
+ export type Resolver = {
52
+ name: string
53
+ pluginName: string
54
+ default(name: string, type?: 'file' | 'function' | 'type' | 'const'): string
55
+ resolveOptions<TOptions>(node: Node, context: ResolveOptionsContext<TOptions>): TOptions | null
56
+ resolvePath(params: ResolverPathParams, context: ResolverContext): string
57
+ resolveFile(params: ResolverFileParams, context: ResolverContext): FileNode
58
+ resolveBanner(meta: InputMeta | undefined, context: ResolveBannerContext): string | null
59
+ resolveFooter(meta: InputMeta | undefined, context: ResolveBannerContext): string | null
60
+ }
61
+
62
+ /**
63
+ * File-specific parameters for `Resolver.resolvePath`.
64
+ *
65
+ * Pass alongside a `ResolverContext` to identify which file to resolve.
66
+ * Provide `tag` for tag-based grouping or `path` for path-based grouping.
67
+ *
68
+ * @example
69
+ * ```ts
70
+ * resolver.resolvePath(
71
+ * { baseName: 'petTypes.ts', tag: 'pets' },
72
+ * { root: '/src', output: { path: 'types' }, group: { type: 'tag' } },
73
+ * )
74
+ * // → '/src/types/petsController/petTypes.ts'
75
+ * ```
76
+ */
77
+ export type ResolverPathParams = {
78
+ baseName: FileNode['baseName']
79
+ pathMode?: 'single' | 'split'
80
+ /**
81
+ * Tag value used when `group.type === 'tag'`.
82
+ */
83
+ tag?: string
84
+ /**
85
+ * Path value used when `group.type === 'path'`.
86
+ */
87
+ path?: string
88
+ }
89
+
90
+ /**
91
+ * Shared context passed as the second argument to `Resolver.resolvePath` and `Resolver.resolveFile`.
92
+ *
93
+ * Describes where on disk output is rooted, which output config is active, and the optional
94
+ * grouping strategy that controls subdirectory layout.
95
+ *
96
+ * @example
97
+ * ```ts
98
+ * const context: ResolverContext = {
99
+ * root: config.root,
100
+ * output,
101
+ * group,
102
+ * }
103
+ * ```
104
+ */
105
+ export type ResolverContext = {
106
+ root: string
107
+ output: Output
108
+ group?: Group
109
+ /**
110
+ * Plugin name used to populate `meta.pluginName` on the resolved file.
111
+ */
112
+ pluginName?: string
113
+ }
114
+
115
+ /**
116
+ * File-specific parameters for `Resolver.resolveFile`.
117
+ *
118
+ * Pass alongside a `ResolverContext` to fully describe the file to resolve.
119
+ * `tag` and `path` are used only when a matching `group` is present in the context.
120
+ *
121
+ * @example
122
+ * ```ts
123
+ * resolver.resolveFile(
124
+ * { name: 'listPets', extname: '.ts', tag: 'pets' },
125
+ * { root: '/src', output: { path: 'types' }, group: { type: 'tag' } },
126
+ * )
127
+ * // → { baseName: 'listPets.ts', path: '/src/types/petsController/listPets.ts', ... }
128
+ * ```
129
+ */
130
+ export type ResolverFileParams = {
131
+ name: string
132
+ extname: FileNode['extname']
133
+ /**
134
+ * Tag value used when `group.type === 'tag'`.
135
+ */
136
+ tag?: string
137
+ /**
138
+ * Path value used when `group.type === 'path'`.
139
+ */
140
+ path?: string
141
+ }
142
+
143
+ /**
144
+ * Per-file context describing the file a banner/footer is being resolved for.
145
+ *
146
+ * Supplied by the generator (or the barrel middleware) at resolve-time and merged
147
+ * into `BannerMeta` so a `banner`/`footer` function can branch on the file kind,
148
+ * e.g. omit a `'use server'` directive on re-export files.
149
+ */
150
+ export type ResolveBannerFile = {
151
+ /**
152
+ * Full output path of the file being generated.
153
+ */
154
+ path: string
155
+ /**
156
+ * File name only, e.g. `'stocks.ts'`.
157
+ */
158
+ baseName: string
159
+ /**
160
+ * `true` for `index.ts` re-export barrels.
161
+ */
162
+ isBarrel?: boolean
163
+ /**
164
+ * `true` for group `[dir]/[dir].ts` aggregation files.
165
+ */
166
+ isAggregation?: boolean
167
+ }
168
+
169
+ /**
170
+ * Document metadata extended with per-file context, passed to a `banner`/`footer` function.
171
+ *
172
+ * Carries everything in {@link InputMeta} plus the file the banner is rendered into, so a
173
+ * single function can decide per file (e.g. skip a directive on barrel/aggregation files).
174
+ *
175
+ * @example Skip a directive on re-export files
176
+ * `banner: (meta) => (meta.isBarrel || meta.isAggregation) ? '' : "'use server'"`
177
+ */
178
+ export type BannerMeta = InputMeta & {
179
+ /**
180
+ * Full output path of the file being generated.
181
+ */
182
+ filePath: string
183
+ /**
184
+ * File name only, e.g. `'stocks.ts'`.
185
+ */
186
+ baseName: string
187
+ /**
188
+ * `true` for `index.ts` re-export barrels.
189
+ */
190
+ isBarrel: boolean
191
+ /**
192
+ * `true` for group `[dir]/[dir].ts` aggregation files.
193
+ */
194
+ isAggregation: boolean
195
+ }
196
+
197
+ /**
198
+ * Context passed to `Resolver.resolveBanner` and `Resolver.resolveFooter`.
199
+ *
200
+ * `output` is optional, since not every plugin configures a banner/footer.
201
+ * `config` carries the global Kubb config, used to derive the default Kubb banner.
202
+ * `file` carries per-file context forwarded to a `banner`/`footer` function.
203
+ *
204
+ * @example
205
+ * ```ts
206
+ * resolver.resolveBanner(meta, { output: { banner: '// generated' }, config })
207
+ * // → '// generated'
208
+ * ```
209
+ */
210
+ export type ResolveBannerContext = {
211
+ output?: Pick<Output, 'banner' | 'footer'>
212
+ config: Config
213
+ file?: ResolveBannerFile
214
+ }
215
+
216
+ /**
217
+ * Merges document `meta` with per-file `file` context into the `BannerMeta` passed to a
218
+ * `banner`/`footer` function. Missing fields default to empty/`false` so the object shape
219
+ * is stable even when a caller (e.g. the barrel middleware) has no document metadata.
220
+ */
221
+ function buildBannerMeta({ meta, file }: { meta: InputMeta | undefined; file: ResolveBannerFile | undefined }): BannerMeta {
222
+ return {
223
+ title: meta?.title,
224
+ description: meta?.description,
225
+ version: meta?.version,
226
+ baseURL: meta?.baseURL,
227
+ circularNames: meta?.circularNames ?? [],
228
+ enumNames: meta?.enumNames ?? [],
229
+ filePath: file?.path ?? '',
230
+ baseName: file?.baseName ?? '',
231
+ isBarrel: file?.isBarrel ?? false,
232
+ isAggregation: file?.isAggregation ?? false,
233
+ }
234
+ }
16
235
 
17
236
  /**
18
237
  * Builder type for the plugin-specific resolver fields.
19
238
  *
20
239
  * `default`, `resolveOptions`, `resolvePath`, `resolveFile`, `resolveBanner`, and `resolveFooter`
21
- * are optional built-in fallbacks are injected when omitted.
240
+ * are optional, with built-in fallbacks injected when omitted.
22
241
  *
23
- * The builder receives `ctx` a reference to the fully assembled resolver so methods can
24
- * call sibling resolver methods without using `this`. Because `ctx` is captured by the closure
25
- * and the resolver is populated after the builder runs, `ctx` correctly reflects any overrides
26
- * that were applied by the builder itself.
242
+ * Methods in the returned object can call sibling resolver methods via `this`.
27
243
  */
28
- type ResolverBuilder<T extends PluginFactoryOptions> = (ctx: T['resolver']) => Omit<
244
+ type ResolverBuilder<T extends PluginFactoryOptions> = () => Omit<
29
245
  T['resolver'],
30
246
  'default' | 'resolveOptions' | 'resolvePath' | 'resolveFile' | 'resolveBanner' | 'resolveFooter' | 'name' | 'pluginName'
31
247
  > &
32
248
  Partial<Pick<T['resolver'], 'default' | 'resolveOptions' | 'resolvePath' | 'resolveFile' | 'resolveBanner' | 'resolveFooter'>> & {
33
249
  name: string
34
250
  pluginName: T['name']
35
- }
251
+ } & ThisType<T['resolver']>
36
252
 
37
- // String patterns are compiled lazily and cached the same filter is reused for every node.
253
+ // String patterns are compiled lazily and cached, so the same filter is reused for every node.
38
254
  const stringPatternCache = new Map<string, RegExp>()
39
255
 
40
256
  function testPattern(value: string, pattern: string | RegExp): boolean {
@@ -54,20 +270,12 @@ function testPattern(value: string, pattern: string | RegExp): boolean {
54
270
  * Checks if an operation matches a pattern for a given filter type (`tag`, `operationId`, `path`, `method`).
55
271
  */
56
272
  function matchesOperationPattern(node: OperationNode, type: string, pattern: string | RegExp): boolean {
57
- switch (type) {
58
- case 'tag':
59
- return node.tags.some((tag) => testPattern(tag, pattern))
60
- case 'operationId':
61
- return testPattern(node.operationId, pattern)
62
- case 'path':
63
- return testPattern(node.path, pattern)
64
- case 'method':
65
- return testPattern(node.method.toLowerCase(), pattern)
66
- case 'contentType':
67
- return node.requestBody?.content?.some((c) => testPattern(c.contentType, pattern)) ?? false
68
- default:
69
- return false
70
- }
273
+ if (type === 'tag') return node.tags.some((tag) => testPattern(tag, pattern))
274
+ if (type === 'operationId') return testPattern(node.operationId, pattern)
275
+ if (type === 'path') return node.path !== undefined && testPattern(node.path, pattern)
276
+ if (type === 'method') return node.method !== undefined && testPattern(node.method.toLowerCase(), pattern)
277
+ if (type === 'contentType') return node.requestBody?.content?.some((c) => testPattern(c.contentType, pattern)) ?? false
278
+ return false
71
279
  }
72
280
 
73
281
  /**
@@ -76,12 +284,8 @@ function matchesOperationPattern(node: OperationNode, type: string, pattern: str
76
284
  * Returns `null` when the filter type doesn't apply to schemas.
77
285
  */
78
286
  function matchesSchemaPattern(node: SchemaNode, type: string, pattern: string | RegExp): boolean | null {
79
- switch (type) {
80
- case 'schemaName':
81
- return node.name ? testPattern(node.name, pattern) : false
82
- default:
83
- return null
84
- }
287
+ if (type === 'schemaName') return node.name ? testPattern(node.name, pattern) : false
288
+ return null
85
289
  }
86
290
 
87
291
  /**
@@ -92,23 +296,13 @@ function matchesSchemaPattern(node: SchemaNode, type: string, pattern: string |
92
296
  * - `camelCase` for everything else.
93
297
  */
94
298
  function defaultResolver(name: string, type?: 'file' | 'function' | 'type' | 'const'): string {
95
- let resolvedName = camelCase(name)
96
-
97
- if (type === 'file' || type === 'function') {
98
- resolvedName = camelCase(name, {
99
- isFile: type === 'file',
100
- })
101
- }
102
-
103
- if (type === 'type') {
104
- resolvedName = pascalCase(name)
105
- }
106
-
107
- return resolvedName
299
+ if (type === 'file' || type === 'function') return camelCase(name, { isFile: type === 'file' })
300
+ if (type === 'type') return pascalCase(name)
301
+ return camelCase(name)
108
302
  }
109
303
 
110
304
  /**
111
- * Default option resolver applies include/exclude filters and merges matching override options.
305
+ * Default option resolver. Applies include/exclude filters and merges matching override options.
112
306
  *
113
307
  * Returns `null` when the node is filtered out by an `exclude` rule or not matched by any `include` rule.
114
308
  *
@@ -130,19 +324,18 @@ function defaultResolver(name: string, type?: 'file' | 'function' | 'type' | 'co
130
324
  * // → { enumType: 'enum' } when operationId matches
131
325
  * ```
132
326
  */
133
- export function defaultResolveOptions<TOptions>(
327
+ const resolveOptionsCache = new WeakMap<object, WeakMap<Node, { value: unknown }>>()
328
+
329
+ function computeOptions<TOptions>(
134
330
  node: Node,
135
- { options, exclude = [], include, override = [] }: ResolveOptionsContext<TOptions>,
331
+ options: TOptions,
332
+ exclude: Array<PatternFilter>,
333
+ include: Array<PatternFilter> | undefined,
334
+ override: Array<PatternOverride<TOptions>>,
136
335
  ): TOptions | null {
137
336
  if (isOperationNode(node)) {
138
- const isExcluded = exclude.some(({ type, pattern }) => matchesOperationPattern(node, type, pattern))
139
- if (isExcluded) {
140
- return null
141
- }
142
-
143
- if (include && !include.some(({ type, pattern }) => matchesOperationPattern(node, type, pattern))) {
144
- return null
145
- }
337
+ if (exclude.some(({ type, pattern }) => matchesOperationPattern(node, type, pattern))) return null
338
+ if (include && !include.some(({ type, pattern }) => matchesOperationPattern(node, type, pattern))) return null
146
339
 
147
340
  const overrideOptions = override.find(({ type, pattern }) => matchesOperationPattern(node, type, pattern))?.options
148
341
 
@@ -150,18 +343,13 @@ export function defaultResolveOptions<TOptions>(
150
343
  }
151
344
 
152
345
  if (isSchemaNode(node)) {
153
- if (exclude.some(({ type, pattern }) => matchesSchemaPattern(node, type, pattern) === true)) {
154
- return null
155
- }
156
-
346
+ if (exclude.some(({ type, pattern }) => matchesSchemaPattern(node, type, pattern) === true)) return null
157
347
  if (include) {
158
348
  const results = include.map(({ type, pattern }) => matchesSchemaPattern(node, type, pattern))
159
- const applicable = results.filter((r) => r !== null)
160
- if (applicable.length > 0 && !applicable.includes(true)) {
161
- return null
162
- }
163
- }
349
+ const applicable = results.filter((result) => result !== null)
164
350
 
351
+ if (applicable.length > 0 && !applicable.includes(true)) return null
352
+ }
165
353
  const overrideOptions = override.find(({ type, pattern }) => matchesSchemaPattern(node, type, pattern) === true)?.options
166
354
 
167
355
  return { ...options, ...overrideOptions }
@@ -170,6 +358,26 @@ export function defaultResolveOptions<TOptions>(
170
358
  return options
171
359
  }
172
360
 
361
+ export function defaultResolveOptions<TOptions>(
362
+ node: Node,
363
+ { options, exclude = [], include, override = [] }: ResolveOptionsContext<TOptions>,
364
+ ): TOptions | null {
365
+ const optionsKey = options as object
366
+ let byOptions = resolveOptionsCache.get(optionsKey)
367
+ if (!byOptions) {
368
+ byOptions = new WeakMap()
369
+ resolveOptionsCache.set(optionsKey, byOptions)
370
+ }
371
+ const cached = byOptions.get(node)
372
+ if (cached !== undefined) return cached.value as TOptions | null
373
+
374
+ const result = computeOptions(node, options, exclude, include, override)
375
+
376
+ byOptions.set(node, { value: result })
377
+
378
+ return result
379
+ }
380
+
173
381
  /**
174
382
  * Default path resolver used by `defineResolver`.
175
383
  *
@@ -215,31 +423,30 @@ export function defaultResolveOptions<TOptions>(
215
423
  * ```
216
424
  */
217
425
  export function defaultResolvePath({ baseName, pathMode, tag, path: groupPath }: ResolverPathParams, { root, output, group }: ResolverContext): string {
218
- const mode = pathMode ?? PluginDriver.getMode(path.resolve(root, output.path))
426
+ const mode = pathMode ?? getMode(path.resolve(root, output.path))
219
427
 
220
428
  if (mode === 'single') {
221
429
  return path.resolve(root, output.path)
222
430
  }
223
431
 
224
- let result: string
225
-
226
- if (group && (groupPath || tag)) {
227
- const groupValue = group.type === 'path' ? groupPath! : tag!
228
- const defaultName =
229
- group.type === 'tag'
230
- ? ({ group: g }: { group: string }) => `${camelCase(g)}Controller`
231
- : ({ group: g }: { group: string }) => {
232
- // Strip traversal components (empty, '.', '..') before taking the first meaningful segment.
233
- // When every segment is a traversal component (e.g. '../../') we fall back to '' so the
234
- // file is placed directly in the output root the boundary check below ensures safety.
235
- const segment = g.split('/').filter((s) => s !== '' && s !== '.' && s !== '..')[0]
236
- return segment ? camelCase(segment) : ''
237
- }
238
- const resolveName = group.name ?? defaultName
239
- result = path.resolve(root, output.path, resolveName({ group: groupValue }), baseName)
240
- } else {
241
- result = path.resolve(root, output.path, baseName)
242
- }
432
+ const result: string = (() => {
433
+ if (group && (groupPath || tag)) {
434
+ const groupValue = group.type === 'path' ? groupPath! : tag!
435
+ const defaultName =
436
+ group.type === 'tag'
437
+ ? ({ group: groupName }: { group: string }) => `${camelCase(groupName)}Controller`
438
+ : ({ group: groupName }: { group: string }) => {
439
+ // Strip traversal components (empty, '.', '..') before taking the first meaningful segment.
440
+ // When every segment is a traversal component (e.g. '../../') we fall back to '' so the
441
+ // file is placed directly in the output root, and the boundary check below ensures safety.
442
+ const segment = groupName.split('/').filter((part) => part !== '' && part !== '.' && part !== '..')[0]
443
+ return segment ? camelCase(segment) : ''
444
+ }
445
+ const resolveName = group.name ?? defaultName
446
+ return path.resolve(root, output.path, resolveName({ group: groupValue }), baseName)
447
+ }
448
+ return path.resolve(root, output.path, baseName)
449
+ })()
243
450
 
244
451
  // Ensure the resolved path stays within the configured output directory.
245
452
  // This prevents path traversal from malicious OpenAPI specs or custom group.name functions.
@@ -248,10 +455,13 @@ export function defaultResolvePath({ baseName, pathMode, tag, path: groupPath }:
248
455
  const outputDir = path.resolve(root, output.path)
249
456
  const outputDirWithSep = outputDir.endsWith(path.sep) ? outputDir : `${outputDir}${path.sep}`
250
457
  if (result !== outputDir && !result.startsWith(outputDirWithSep)) {
251
- throw new Error(
252
- `[Kubb] Resolved path "${result}" is outside the output directory "${outputDir}". ` +
253
- 'This may indicate a path traversal attempt in the OpenAPI specification or a misconfigured group.name function.',
254
- )
458
+ throw new Diagnostics.Error({
459
+ code: Diagnostics.code.pathTraversal,
460
+ severity: 'error',
461
+ message: `Resolved path "${result}" is outside the output directory "${outputDir}".`,
462
+ help: 'This can stem from a path traversal in the OpenAPI specification or a misconfigured `group.name` function. Keep generated paths within the output directory.',
463
+ location: { kind: 'config' },
464
+ })
255
465
  }
256
466
 
257
467
  return result
@@ -262,41 +472,41 @@ export function defaultResolvePath({ baseName, pathMode, tag, path: groupPath }:
262
472
  *
263
473
  * Resolves a `FileNode` by combining name resolution (`resolver.default`) with
264
474
  * path resolution (`resolver.resolvePath`). The resolved file always has empty
265
- * `sources`, `imports`, and `exports` arrays consumers populate those separately.
475
+ * `sources`, `imports`, and `exports` arrays, which consumers populate separately.
266
476
  *
267
477
  * In `single` mode the name is omitted and the file sits directly in the output directory.
268
478
  *
269
479
  * @example Resolve a schema file
270
480
  * ```ts
271
- * const file = defaultResolveFile(
481
+ * const file = defaultResolveFile.call(
482
+ * resolver,
272
483
  * { name: 'pet', extname: '.ts' },
273
484
  * { root: '/src', output: { path: 'types' } },
274
- * resolver,
275
485
  * )
276
486
  * // → { baseName: 'pet.ts', path: '/src/types/pet.ts', sources: [], ... }
277
487
  * ```
278
488
  *
279
489
  * @example Resolve an operation file with tag grouping
280
490
  * ```ts
281
- * const file = defaultResolveFile(
491
+ * const file = defaultResolveFile.call(
492
+ * resolver,
282
493
  * { name: 'listPets', extname: '.ts', tag: 'pets' },
283
494
  * { root: '/src', output: { path: 'types' }, group: { type: 'tag' } },
284
- * resolver,
285
495
  * )
286
496
  * // → { baseName: 'listPets.ts', path: '/src/types/petsController/listPets.ts', ... }
287
497
  * ```
288
498
  */
289
- export function defaultResolveFile({ name, extname, tag, path: groupPath }: ResolverFileParams, context: ResolverContext, ctx: Resolver): FileNode {
290
- const pathMode = PluginDriver.getMode(path.resolve(context.root, context.output.path))
291
- const resolvedName = pathMode === 'single' ? '' : ctx.default(name, 'file')
499
+ export function defaultResolveFile(this: Resolver, { name, extname, tag, path: groupPath }: ResolverFileParams, context: ResolverContext): FileNode {
500
+ const pathMode = getMode(path.resolve(context.root, context.output.path))
501
+ const resolvedName = pathMode === 'single' ? '' : this.default(name, 'file')
292
502
  const baseName = `${resolvedName}${extname}` as FileNode['baseName']
293
- const filePath = ctx.resolvePath({ baseName, pathMode, tag, path: groupPath }, context)
503
+ const filePath = this.resolvePath({ baseName, pathMode, tag, path: groupPath }, context)
294
504
 
295
505
  return createFile({
296
506
  path: filePath,
297
507
  baseName: path.basename(filePath) as `${string}.${string}`,
298
508
  meta: {
299
- pluginName: ctx.pluginName,
509
+ pluginName: this.pluginName,
300
510
  },
301
511
  sources: [],
302
512
  imports: [],
@@ -319,17 +529,16 @@ export function buildDefaultBanner({
319
529
  config: Config
320
530
  }): string {
321
531
  try {
322
- let source = ''
323
- if (Array.isArray(config.input)) {
324
- const first = config.input[0]
325
- if (first && 'path' in first) {
326
- source = path.basename(first.path)
532
+ const source = (() => {
533
+ if (Array.isArray(config.input)) {
534
+ const first = config.input[0]
535
+ if (first && 'path' in first) return path.basename(first.path)
536
+ return ''
327
537
  }
328
- } else if (config.input && 'path' in config.input) {
329
- source = path.basename(config.input.path)
330
- } else if (config.input && 'data' in config.input) {
331
- source = 'text content'
332
- }
538
+ if (config.input && 'path' in config.input) return path.basename(config.input.path)
539
+ if (config.input && 'data' in config.input) return 'text content'
540
+ return ''
541
+ })()
333
542
 
334
543
  let banner = '/**\n* Generated by Kubb (https://kubb.dev/).\n* Do not edit manually.\n'
335
544
 
@@ -363,14 +572,13 @@ export function buildDefaultBanner({
363
572
  }
364
573
 
365
574
  /**
366
- * Default banner resolver returns the banner string for a generated file.
575
+ * Default banner resolver. Returns the banner string for a generated file.
367
576
  *
368
577
  * A user-supplied `output.banner` overrides the default Kubb "Generated by Kubb" notice.
369
578
  * When no `output.banner` is set, the Kubb notice is used (including `title` and `version`
370
- * from the OAS spec when a `node` is provided).
579
+ * from the document metadata when `meta` is provided).
371
580
  *
372
- * - When `output.banner` is a function and `node` is provided, returns `output.banner(node)`.
373
- * - When `output.banner` is a function and `node` is absent, falls back to the Kubb notice.
581
+ * - When `output.banner` is a function, calls it with the file's `BannerMeta` and returns the result.
374
582
  * - When `output.banner` is a string, returns it directly.
375
583
  * - When `config.output.defaultBanner` is `false`, returns `undefined`.
376
584
  * - Otherwise returns the Kubb "Generated by Kubb" notice.
@@ -381,27 +589,33 @@ export function buildDefaultBanner({
381
589
  * // → '// my banner'
382
590
  * ```
383
591
  *
384
- * @example Function banner with node
592
+ * @example Function banner with metadata
385
593
  * ```ts
386
- * defaultResolveBanner(inputNode, { output: { banner: (node) => `// v${node.version}` }, config })
594
+ * defaultResolveBanner(meta, { output: { banner: (m) => `// v${m.version}` }, config })
387
595
  * // → '// v3.0.0'
388
596
  * ```
389
597
  *
390
- * @example No user banner Kubb notice with OAS metadata
598
+ * @example Function banner skips re-export files
391
599
  * ```ts
392
- * defaultResolveBanner(inputNode, { config })
600
+ * defaultResolveBanner(meta, { output: { banner: (m) => (m.isBarrel ? '' : "'use server'") }, config, file: { path, baseName, isBarrel: true } })
601
+ * // → ''
602
+ * ```
603
+ *
604
+ * @example No user banner, Kubb notice with OAS metadata
605
+ * ```ts
606
+ * defaultResolveBanner(meta, { config })
393
607
  * // → '/** Generated by Kubb ... Title: Pet Store ... *\/'
394
608
  * ```
395
609
  *
396
610
  * @example Disabled default banner
397
611
  * ```ts
398
612
  * defaultResolveBanner(undefined, { config: { output: { defaultBanner: false }, ...config } })
399
- * // → undefined
613
+ * // → null
400
614
  * ```
401
615
  */
402
- export function defaultResolveBanner(node: InputNode | undefined, { output, config }: ResolveBannerContext): string | undefined {
616
+ export function defaultResolveBanner(meta: InputMeta | undefined, { output, config, file }: ResolveBannerContext): string | null {
403
617
  if (typeof output?.banner === 'function') {
404
- return output.banner(node)
618
+ return output.banner(buildBannerMeta({ meta, file }))
405
619
  }
406
620
 
407
621
  if (typeof output?.banner === 'string') {
@@ -409,21 +623,20 @@ export function defaultResolveBanner(node: InputNode | undefined, { output, conf
409
623
  }
410
624
 
411
625
  if (config.output.defaultBanner === false) {
412
- return undefined
626
+ return null
413
627
  }
414
628
 
415
629
  return buildDefaultBanner({
416
- title: node?.meta?.title,
417
- version: node?.meta?.version,
630
+ title: meta?.title,
631
+ version: meta?.version,
418
632
  config,
419
633
  })
420
634
  }
421
635
 
422
636
  /**
423
- * Default footer resolver returns the footer string for a generated file.
637
+ * Default footer resolver. Returns the footer string for a generated file.
424
638
  *
425
- * - When `output.footer` is a function and `node` is provided, calls it with the node.
426
- * - When `output.footer` is a function and `node` is absent, returns `undefined`.
639
+ * - When `output.footer` is a function, calls it with the file's `BannerMeta` and returns the result.
427
640
  * - When `output.footer` is a string, returns it directly.
428
641
  * - Otherwise returns `undefined`.
429
642
  *
@@ -433,89 +646,78 @@ export function defaultResolveBanner(node: InputNode | undefined, { output, conf
433
646
  * // → '// end of file'
434
647
  * ```
435
648
  *
436
- * @example Function footer with node
649
+ * @example Function footer with metadata
437
650
  * ```ts
438
- * defaultResolveFooter(inputNode, { output: { footer: (node) => `// ${node.title}` }, config })
651
+ * defaultResolveFooter(meta, { output: { footer: (m) => `// ${m.title}` }, config })
439
652
  * // → '// Pet Store'
440
653
  * ```
441
654
  */
442
- export function defaultResolveFooter(node: InputNode | undefined, { output }: ResolveBannerContext): string | undefined {
655
+ export function defaultResolveFooter(meta: InputMeta | undefined, { output, file }: ResolveBannerContext): string | null {
443
656
  if (typeof output?.footer === 'function') {
444
- return node ? output.footer(node) : undefined
657
+ return output.footer(buildBannerMeta({ meta, file }))
445
658
  }
446
659
  if (typeof output?.footer === 'string') {
447
660
  return output.footer
448
661
  }
449
- return undefined
662
+ return null
450
663
  }
451
664
 
452
665
  /**
453
- * Defines a resolver for a plugin, injecting built-in defaults for name casing,
454
- * include/exclude/override filtering, path resolution, and file construction.
666
+ * Defines a plugin resolver. The resolver is the object that decides what
667
+ * every generated symbol and file path is called. Built-in defaults handle
668
+ * name casing, include/exclude/override filtering, output path computation,
669
+ * and file construction. Supply your own to override any of them:
455
670
  *
456
- * All four defaults can be overridden by providing them in the builder function:
457
- * - `default` name casing strategy (camelCase / PascalCase)
458
- * - `resolveOptions` include/exclude/override filtering
459
- * - `resolvePath` output path computation
460
- * - `resolveFile` full `FileNode` construction
671
+ * - `default` sets the name casing strategy (camelCase or PascalCase).
672
+ * - `resolveOptions` does include/exclude/override filtering.
673
+ * - `resolvePath` computes the output path.
674
+ * - `resolveFile` builds the full `FileNode`.
675
+ * - `resolveBanner` and `resolveFooter` produce the top and bottom of file text.
461
676
  *
462
- * The builder receives `ctx` a reference to the assembled resolver so methods can
463
- * call sibling resolver methods using `ctx` instead of `this`.
677
+ * Methods in the returned object can call sibling resolver methods via `this`,
678
+ * which keeps custom rules small (`this.default(name, 'type')` to delegate).
464
679
  *
465
680
  * @example Basic resolver with naming helpers
466
681
  * ```ts
467
- * export const resolver = defineResolver<PluginTs>((ctx) => ({
682
+ * export const resolverTs = defineResolver<PluginTs>(() => ({
468
683
  * name: 'default',
469
- * resolveName(node) {
470
- * return ctx.default(node.name, 'function')
684
+ * resolveName(name) {
685
+ * return this.default(name, 'function')
471
686
  * },
472
- * resolveTypedName(node) {
473
- * return ctx.default(node.name, 'type')
687
+ * resolveTypeName(name) {
688
+ * return this.default(name, 'type')
474
689
  * },
475
690
  * }))
476
691
  * ```
477
692
  *
478
- * @example Override resolvePath for a custom output structure
693
+ * @example Custom output path
479
694
  * ```ts
480
- * export const resolver = defineResolver<PluginTs>((_ctx) => ({
695
+ * import path from 'node:path'
696
+ *
697
+ * export const resolverTs = defineResolver<PluginTs>(() => ({
481
698
  * name: 'custom',
482
699
  * resolvePath({ baseName }, { root, output }) {
483
700
  * return path.resolve(root, output.path, 'generated', baseName)
484
701
  * },
485
702
  * }))
486
703
  * ```
487
- *
488
- * @example Use ctx.default inside a helper
489
- * ```ts
490
- * export const resolver = defineResolver<PluginTs>((ctx) => ({
491
- * name: 'default',
492
- * resolveParamName(node, param) {
493
- * return ctx.default(`${node.operationId} ${param.in} ${param.name}`, 'type')
494
- * },
495
- * }))
496
- * ```
497
704
  */
498
705
  export function defineResolver<T extends PluginFactoryOptions>(build: ResolverBuilder<T>): T['resolver'] {
499
- // Create the resolver shell first. When `build(resolver)` executes below, `resolver` is
500
- // still empty, but methods returned by the builder capture it by reference. By the time
501
- // those methods are actually called, `Object.assign` will have already populated all
502
- // properties (including any overrides from the builder itself).
503
- const resolver = {} as T['resolver']
706
+ // `resolver` is kept so the default `resolveFile` wrapper can reference the fully assembled
707
+ // object via `.call(resolver, ...)` at call-time, after the result is assigned below.
708
+ let resolver: T['resolver']
504
709
 
505
- Object.assign(resolver, {
710
+ const result = {
506
711
  default: defaultResolver,
507
712
  resolveOptions: defaultResolveOptions,
508
713
  resolvePath: defaultResolvePath,
509
- // Wire the default resolveFile implementation with a wrapper that passes resolver as ctx.
510
- // Unlike other defaults which can be assigned directly, defaultResolveFile requires the
511
- // resolver as its third parameter.
512
- resolveFile: (params: ResolverFileParams, context: ResolverContext) => defaultResolveFile(params, context, resolver as Resolver),
714
+ resolveFile: (params: ResolverFileParams, context: ResolverContext) => defaultResolveFile.call(resolver as Resolver, params, context),
513
715
  resolveBanner: defaultResolveBanner,
514
716
  resolveFooter: defaultResolveFooter,
515
- // Builder overrides are applied last. Any method in the builder can call
516
- // ctx.xxx() and will see the fully merged resolver (including its own overrides).
517
- ...build(resolver),
518
- })
717
+ ...build(),
718
+ } as T['resolver']
719
+
720
+ resolver = result
519
721
 
520
722
  return resolver
521
723
  }