@kubb/core 5.0.0-beta.98 → 5.0.0

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.
package/dist/index.js CHANGED
@@ -1,11 +1,13 @@
1
- import "./rolldown-runtime-C0LytTxp.js";
2
- import { a as isPathInside, c as write, d as toError, f as camelCase, i as clean, l as BuildError, n as FileManager, o as toFilePath, r as Hookable, s as toPosixPath, t as _usingCtx, u as getErrorMessage } from "./usingCtx-BNggxUEL.js";
1
+ import { t as __name } from "./rolldown-runtime-C0LytTxp.js";
2
+ import { a as OPERATION_FILTER_TYPES, c as clean, d as toPosixPath, f as write, g as camelCase, h as toError, i as Hookable, l as isPathInside, m as getErrorMessage, n as createNodeCache, o as diagnosticCode, p as BuildError, r as FileManager, s as inParallel, t as _usingCtx, u as toFilePath } from "./usingCtx-njZUKKsY.js";
3
3
  import { AsyncLocalStorage } from "node:async_hooks";
4
4
  import { stripVTControlCharacters, styleText } from "node:util";
5
- import { hash } from "node:crypto";
5
+ import { createHash, hash } from "node:crypto";
6
6
  import { access, glob, readFile, rm } from "node:fs/promises";
7
7
  import path, { join, relative, resolve } from "node:path";
8
- import { ast, collect, collectUsedSchemaNames, composeMacros, narrowSchema, operationDef, resolveRefName, schemaDef, transform } from "@kubb/ast";
8
+ import { ast, collectImportedRefNames, collectUsedSchemaNames, composeMacros, operationDef, schemaDef, transform } from "@kubb/ast";
9
+ import { existsSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
9
11
  import process$1 from "node:process";
10
12
  //#region src/createAdapter.ts
11
13
  /**
@@ -145,163 +147,15 @@ const randomColors = [
145
147
  function randomCliColor(text) {
146
148
  if (!text) return "";
147
149
  const index = hash("sha256", text, "buffer").readUInt32BE(0) % randomColors.length;
148
- return styleText(randomColors[index] ?? "white", text);
150
+ const color = randomColors[index] ?? "white";
151
+ return styleText(color, text);
149
152
  }
150
153
  //#endregion
151
- //#region ../../internals/utils/src/promise.ts
152
- /**
153
- * Wraps `factory` with a keyed cache backed by the provided store.
154
- *
155
- * Pass a `WeakMap` for object keys (results are GC-eligible when the key is
156
- * collected) or a `Map` for primitive keys. For multi-argument functions,
157
- * nest two `memoize` calls — the outer keyed by the first argument, the
158
- * inner (created once per outer miss) keyed by the second.
159
- *
160
- * Because the cache is owned by the caller, it can be shared, inspected, or
161
- * cleared independently of the memoized function.
162
- *
163
- * @example Single WeakMap key
164
- * ```ts
165
- * const cache = new WeakMap<SchemaNode, Set<string>>()
166
- * const getRefs = memoize(cache, (node) => collectRefs(node))
167
- * ```
168
- *
169
- * @example Single Map key (primitive)
170
- * ```ts
171
- * const cache = new Map<string, Resolver>()
172
- * const getResolver = memoize(cache, (name) => buildResolver(name))
173
- * ```
174
- *
175
- * @example Two-level (object + primitive)
176
- * ```ts
177
- * const outer = new WeakMap<Params[], Map<string, Params[]>>()
178
- * const fn = memoize(outer, (params) => memoize(new Map(), (key) => transform(params, key)))
179
- * fn(params)('camelcase')
180
- * ```
181
- */
182
- function memoize(store, factory) {
183
- return (key) => {
184
- if (store.has(key)) return store.get(key);
185
- const value = factory(key);
186
- store.set(key, value);
187
- return value;
188
- };
189
- }
190
- //#endregion
191
- //#region package.json
192
- var version = "5.0.0-beta.98";
193
- //#endregion
194
- //#region src/constants.ts
195
- /**
196
- * Plugin `include` filter types that select operations directly. When one of these is set
197
- * without a `schemaName` include, the generate phase pre-scans operations to compute the set
198
- * of schemas they reach, so unreachable schemas can be pruned for that plugin.
199
- */
200
- const OPERATION_FILTER_TYPES = /* @__PURE__ */ new Set([
201
- "tag",
202
- "operationId",
203
- "path",
204
- "method",
205
- "contentType"
206
- ]);
207
- /**
208
- * Stable codes Kubb attaches to a `Diagnostic`. Each maps to a known failure mode
209
- * and stays stable so it can be referenced in tooling and (later) docs. Reference
210
- * these instead of inlining the string at a throw site.
211
- */
212
- const diagnosticCode = {
213
- /**
214
- * Fallback for an unstructured error with no specific code.
215
- */
216
- unknown: "KUBB_UNKNOWN",
217
- /**
218
- * The file or URL set as `input` could not be read.
219
- */
220
- inputNotFound: "KUBB_INPUT_NOT_FOUND",
221
- /**
222
- * An adapter was configured without an `input`.
223
- */
224
- inputRequired: "KUBB_INPUT_REQUIRED",
225
- /**
226
- * A `$ref` (or equivalent reference) could not be resolved in the source document.
227
- */
228
- refNotFound: "KUBB_REF_NOT_FOUND",
229
- /**
230
- * A server variable value is not allowed by its `enum`.
231
- */
232
- invalidServerVariable: "KUBB_INVALID_SERVER_VARIABLE",
233
- /**
234
- * A required plugin is missing from the config.
235
- */
236
- pluginNotFound: "KUBB_PLUGIN_NOT_FOUND",
237
- /**
238
- * A plugin threw while generating.
239
- */
240
- pluginFailed: "KUBB_PLUGIN_FAILED",
241
- /**
242
- * A plugin reported a non-fatal warning through `ctx.warn`.
243
- */
244
- pluginWarning: "KUBB_PLUGIN_WARNING",
245
- /**
246
- * A plugin reported an informational message through `ctx.info`.
247
- */
248
- pluginInfo: "KUBB_PLUGIN_INFO",
249
- /**
250
- * A schema uses a `format` Kubb does not map to a specific type. Reserved for
251
- * adapters to emit as a `warning`.
252
- */
253
- unsupportedFormat: "KUBB_UNSUPPORTED_FORMAT",
254
- /**
255
- * A referenced schema or operation is marked `deprecated`. Reserved for adapters
256
- * to emit as an `info`.
257
- */
258
- deprecated: "KUBB_DEPRECATED",
259
- /**
260
- * An adapter is required but the config has none. The build cannot read the input
261
- * without one.
262
- */
263
- adapterRequired: "KUBB_ADAPTER_REQUIRED",
264
- /**
265
- * A resolved output path escapes the output directory, which can stem from a path
266
- * traversal in the spec or a misconfigured `group.name`.
267
- */
268
- pathTraversal: "KUBB_PATH_TRAVERSAL",
269
- /**
270
- * `output.clean` is enabled but `output.path` resolves to the project root or a parent of it,
271
- * so cleaning would delete kubb.config and every source file.
272
- */
273
- cleanRoot: "KUBB_CLEAN_ROOT",
274
- /**
275
- * A plugin's options are invalid, for example `output.mode: 'file'` paired with a `group` option.
276
- */
277
- invalidPluginOptions: "KUBB_INVALID_PLUGIN_OPTIONS",
278
- /**
279
- * A post-generate command (`output.postGenerate`) exited with a failure.
280
- */
281
- postGenerateFailed: "KUBB_POST_GENERATE_FAILED",
282
- /**
283
- * The formatter pass over the generated files failed.
284
- */
285
- formatFailed: "KUBB_FORMAT_FAILED",
286
- /**
287
- * The linter pass over the generated files failed.
288
- */
289
- lintFailed: "KUBB_LINT_FAILED",
290
- /**
291
- * Not a failure. Carries a plugin's elapsed time, summed into the run total.
292
- */
293
- performance: "KUBB_PERFORMANCE",
294
- /**
295
- * Not a failure. A newer Kubb version is available on npm.
296
- */
297
- updateAvailable: "KUBB_UPDATE_AVAILABLE"
298
- };
299
- //#endregion
300
154
  //#region src/Diagnostics.ts
301
155
  /**
302
156
  * Docs major version, derived from the package version so the link tracks the published major.
303
157
  */
304
- const docsMajor = version.split(".")[0] ?? "5";
158
+ const docsMajor = "5.0.0".split(".")[0] ?? "5";
305
159
  /**
306
160
  * Builds a type guard that narrows a {@link Diagnostic} to the variant for `kind`. A diagnostic
307
161
  * with no `kind` is treated as a `problem`.
@@ -361,14 +215,34 @@ const diagnosticCatalog = {
361
215
  },
362
216
  [diagnosticCode.inputNotFound]: {
363
217
  title: "Input not found",
364
- cause: "The file or URL set as `input` (or passed as `kubb generate PATH`) could not be read.",
365
- fix: "Check that the path or URL exists and is readable, then set it as `input` or pass it on the CLI."
218
+ cause: "The file set as `input` (or passed as `kubb generate PATH`) could not be read. A URL reports `KUBB_INPUT_REQUEST_FAILED` or `KUBB_INPUT_UNREACHABLE` instead.",
219
+ fix: "Check that the path exists and is readable, then set it as `input` or pass it on the CLI."
220
+ },
221
+ [diagnosticCode.inputRequestFailed]: {
222
+ title: "Input request failed",
223
+ cause: "A URL set as `input` (or reached through a `$ref`) answered with a 4xx or 5xx status instead of the document.",
224
+ fix: "Open the URL to see what the server returns. A 401 or 403 needs credentials Kubb does not send, so download the document and point `input` at the local file. A 404 means the path is wrong, and a 5xx means the server itself failed."
225
+ },
226
+ [diagnosticCode.inputUnreachable]: {
227
+ title: "Input unreachable",
228
+ cause: "A URL set as `input` (or reached through a `$ref`) never answered, so the request failed before a status came back. A refused connection, an unknown host, an expired certificate, and a timeout all land here.",
229
+ fix: "Check that the host is running and reachable from this machine. For a local server, start it and confirm the port matches the one in `input`."
366
230
  },
367
231
  [diagnosticCode.inputRequired]: {
368
232
  title: "Input required",
369
233
  cause: "An adapter is configured but no `input` was provided.",
370
234
  fix: "Set `input` to a file path, a URL, an inline spec (JSON/YAML string), or a parsed object in your Kubb config."
371
235
  },
236
+ [diagnosticCode.legacyInput]: {
237
+ title: "Legacy input shape",
238
+ cause: "`input` is a `{ path }` or `{ data }` wrapper, which v4 used to point at a document and v5 reads as the document itself.",
239
+ fix: "Unwrap it: `input: { path: \"./petStore.yaml\" }` becomes `input: \"./petStore.yaml\"`, and `input: { data: spec }` becomes `input: spec`."
240
+ },
241
+ [diagnosticCode.invalidDocument]: {
242
+ title: "Invalid document",
243
+ cause: "The parsed `input` has no `openapi` or `swagger` version field, so it is not an OpenAPI or Swagger document.",
244
+ fix: "Point `input` at a document that declares `openapi` or `swagger`, and check that a passed object is the spec itself rather than a wrapper around it."
245
+ },
372
246
  [diagnosticCode.refNotFound]: {
373
247
  title: "Reference not found",
374
248
  cause: "A `$ref` could not be resolved in the source document.",
@@ -709,14 +583,18 @@ var Diagnostics = class Diagnostics {
709
583
  * Merges the `output.mode` default into the output config and validates the combination.
710
584
  * Throws `KUBB_INVALID_PLUGIN_OPTIONS` when `mode: 'file'` is paired with a `group` option,
711
585
  * since a single-file output has nothing to group.
586
+ *
587
+ * An omitted `mode` follows the shape of `path`: an extension means a single file, anything
588
+ * else is a directory. Plugin defaults such as `path: 'types'` name a directory, so they
589
+ * generate without the caller spelling out `mode`.
712
590
  */
713
591
  function normalizeOutput({ output, group, pluginName }) {
714
- const mode = output.mode ?? "directory";
592
+ const mode = output.mode ?? (path.extname(output.path) ? "file" : "directory");
715
593
  if (mode === "file" && group) throw new Diagnostics.Error({
716
594
  code: diagnosticCode.invalidPluginOptions,
717
595
  severity: "error",
718
- message: `Plugin "${pluginName}" sets \`output.mode: 'file'\` but also configures a \`group\` option.`,
719
- help: "A single-file output has nothing to group. Remove the `group` option, or use `output.mode: 'directory'` to organize files into subdirectories.",
596
+ message: `Plugin "${pluginName}" resolves \`output.mode\` to 'file' but also configures a \`group\` option.`,
597
+ help: "A single-file output has nothing to group. Remove the `group` option, give `output.path` an extensionless directory name, or set `output.mode: 'directory'` explicitly.",
720
598
  location: { kind: "config" },
721
599
  plugin: pluginName
722
600
  });
@@ -767,6 +645,23 @@ function getInputKind(input) {
767
645
  return "file";
768
646
  }
769
647
  /**
648
+ * The v4 `input` wrapper keys. v4 typed `input` as `{ path }` or `{ data }`; v5 takes the value
649
+ * directly, so the wrapper now matches the "already-parsed document" branch and silently yields
650
+ * an empty build.
651
+ */
652
+ const legacyInputKeys = ["path", "data"];
653
+ /**
654
+ * Detects the v4 `{ path }` / `{ data }` wrapper so it fails loudly instead of being read as a
655
+ * document. A real spec always carries more than these keys, so an object whose keys are drawn
656
+ * only from them is the old shape rather than a document that happens to have a `path` property.
657
+ */
658
+ function isLegacyInput(input) {
659
+ if (Array.isArray(input)) return input.some(isLegacyInput);
660
+ if (typeof input !== "object" || input === null) return false;
661
+ const keys = Object.keys(input);
662
+ return keys.length > 0 && keys.every((key) => legacyInputKeys.includes(key));
663
+ }
664
+ /**
770
665
  * Normalizes `config.input` into an `AdapterSource` the adapter can parse.
771
666
  *
772
667
  * A parsed object and inline content become `{ type: 'data' }`; a URL is kept verbatim and a
@@ -774,6 +669,13 @@ function getInputKind(input) {
774
669
  */
775
670
  function inputToAdapterSource(config) {
776
671
  const input = config.input;
672
+ if (input && isLegacyInput(input)) throw new Diagnostics.Error({
673
+ code: Diagnostics.code.legacyInput,
674
+ severity: "error",
675
+ message: "The `input` option uses the v4 `{ path }` / `{ data }` wrapper.",
676
+ help: "Unwrap it: `input: { path: \"./petStore.yaml\" }` becomes `input: \"./petStore.yaml\"`, and `input: { data: spec }` becomes `input: spec`.",
677
+ location: { kind: "config" }
678
+ });
777
679
  if (!input) throw new Diagnostics.Error({
778
680
  code: Diagnostics.code.inputRequired,
779
681
  severity: "error",
@@ -885,28 +787,24 @@ var Resolver = class Resolver {
885
787
  * schemas (`targetName`) import the emitted name. Names and paths go through the top-level
886
788
  * `name` and `file`, so import entries follow the plugin's conventions, and a per-call
887
789
  * `name` override wins over both.
790
+ *
791
+ * The subtree scan runs through `collectImportedRefNames`, which memoizes by node identity, so a
792
+ * schema shared across the ts, zod, and faker plugins is walked once and every plugin's resolver
793
+ * reads the same ref set instead of re-scanning it per plugin.
888
794
  */
889
795
  imports(options) {
890
796
  const { node, root, output, group, extname = ".ts", name } = options;
891
797
  const resolveName = name ?? ((schemaName) => this.name(schemaName));
892
- const seen = /* @__PURE__ */ new Set();
893
- return collect(node, { schema: (schemaNode) => {
894
- const schemaRef = narrowSchema(schemaNode, "ref");
895
- if (!schemaRef?.ref) return null;
896
- const schemaName = resolveRefName(schemaRef);
897
- if (!schemaName || seen.has(schemaName)) return null;
898
- seen.add(schemaName);
899
- return ast.factory.createImport({
900
- name: [resolveName(schemaName)],
901
- path: this.file({
902
- name: schemaName,
903
- extname,
904
- root,
905
- output,
906
- group
907
- }).path
908
- });
909
- } });
798
+ return collectImportedRefNames(node).map((schemaName) => ast.factory.createImport({
799
+ name: [resolveName(schemaName)],
800
+ path: this.file({
801
+ name: schemaName,
802
+ extname,
803
+ root,
804
+ output,
805
+ group
806
+ }).path
807
+ }));
910
808
  }
911
809
  /**
912
810
  * Folds each `override` over `base`, left to right, and returns a new resolver with helpers
@@ -1027,12 +925,12 @@ var Resolver = class Resolver {
1027
925
  return segment ? camelCase(segment) : "";
1028
926
  }
1029
927
  /**
1030
- * `mode: 'file'` resolves directly to `output.path`. `mode: 'directory'` (default) resolves
928
+ * `mode: 'file'` (default) resolves directly to `output.path`. `mode: 'directory'` resolves
1031
929
  * to `output.path/{baseName}`, or into a subdirectory when `group` and a `tag`/`path` value
1032
930
  * are provided.
1033
931
  */
1034
932
  #resolvePath({ baseName, tag, path: groupPath, root, output, group }) {
1035
- if (output.mode === "file") return path.resolve(root, output.path);
933
+ if (output.mode !== "directory") return path.resolve(root, output.path);
1036
934
  const outputDir = path.resolve(root, output.path);
1037
935
  const result = group && (groupPath || tag) ? path.resolve(outputDir, Resolver.#resolveGroupDir(group, group.type === "path" ? groupPath : tag), baseName) : path.resolve(outputDir, baseName);
1038
936
  const outputDirWithSep = outputDir.endsWith(path.sep) ? outputDir : `${outputDir}${path.sep}`;
@@ -1286,6 +1184,38 @@ const ENFORCE_ORDER = {
1286
1184
  post: 1
1287
1185
  };
1288
1186
  const enforceWeight = (plugin) => plugin.enforce ? ENFORCE_ORDER[plugin.enforce] : 0;
1187
+ /**
1188
+ * The options bag a `NormalizedPlugin` starts with before a plugin refines it: a directory output
1189
+ * at the plugin root and empty filter lists.
1190
+ */
1191
+ function defaultPluginOptions() {
1192
+ return {
1193
+ output: {
1194
+ path: ".",
1195
+ mode: "directory"
1196
+ },
1197
+ exclude: [],
1198
+ override: []
1199
+ };
1200
+ }
1201
+ /**
1202
+ * Fills in the `output`, `exclude`, and `override` a `NormalizedPlugin` needs from a plugin's raw
1203
+ * options, running `output` through `normalizeOutput`. Idempotent, so the driver can apply it after
1204
+ * `setOptions` has already run without disturbing an already-normalized bag.
1205
+ */
1206
+ function normalizePluginOptions(rawOptions, pluginName) {
1207
+ const options = {
1208
+ ...defaultPluginOptions(),
1209
+ ...rawOptions ?? {}
1210
+ };
1211
+ const group = "group" in options ? options.group : void 0;
1212
+ options.output = normalizeOutput({
1213
+ output: options.output,
1214
+ group,
1215
+ pluginName
1216
+ });
1217
+ return options;
1218
+ }
1289
1219
  var KubbDriver = class {
1290
1220
  config;
1291
1221
  options;
@@ -1307,13 +1237,6 @@ var KubbDriver = class {
1307
1237
  fileManager = new FileManager();
1308
1238
  plugins = /* @__PURE__ */ new Map();
1309
1239
  /**
1310
- * Tracks which plugins have generators registered via `addGenerator()` (hook-based path).
1311
- * Used by the build loop to decide whether to emit generator hooks for a given plugin.
1312
- */
1313
- #hookGeneratorPlugins = /* @__PURE__ */ new Set();
1314
- #resolvers = /* @__PURE__ */ new Map();
1315
- #defaultResolvers = /* @__PURE__ */ new Map();
1316
- /**
1317
1240
  * Removers for every listener the driver added (plugin, generator) so `dispose()` can detach
1318
1241
  * them in one pass. External `hooks.hook(...)` listeners are not tracked.
1319
1242
  */
@@ -1341,15 +1264,8 @@ var KubbDriver = class {
1341
1264
  dependencies: rawPlugin.dependencies,
1342
1265
  enforce: rawPlugin.enforce,
1343
1266
  hooks: rawPlugin.hooks,
1344
- options: rawPlugin.options ?? {
1345
- output: {
1346
- path: ".",
1347
- mode: "directory"
1348
- },
1349
- exclude: [],
1350
- override: []
1351
- },
1352
- resolver: this.#getDefaultResolver(rawPlugin.name)
1267
+ options: rawPlugin.options ?? defaultPluginOptions(),
1268
+ resolver: createResolver({ pluginName: rawPlugin.name })
1353
1269
  };
1354
1270
  }));
1355
1271
  for (const plugin of normalized) {
@@ -1458,46 +1374,30 @@ var KubbDriver = class {
1458
1374
  }
1459
1375
  }
1460
1376
  /**
1461
- * Registers a generator for the given plugin on the shared hook emitter.
1462
- *
1463
- * The generator's `schema`, `operation`, and `operations` methods are registered as
1464
- * listeners on `kubb:generate:schema`, `kubb:generate:operation`, and `kubb:generate:operations`
1465
- * respectively. Each listener is scoped to the owning plugin via a `ctx.plugin.name` check
1466
- * so that generators from different plugins do not cross-fire.
1377
+ * Appends a generator to its owning plugin so the generate loop can call it directly.
1467
1378
  *
1468
- * The renderer comes from `generator.renderer`. Set `generator.renderer = null` (or leave it
1469
- * unset) to opt out of rendering.
1379
+ * The generator's `schema`, `operation`, and `operations` methods run per node during the AST
1380
+ * walk in `#runGenerators`, and their result is routed through `dispatch`. Because a generator is
1381
+ * bound to a plugin, generators from different plugins never cross-fire without a name check. The
1382
+ * renderer comes from `generator.renderer`; set it to `null` (or leave it unset) to opt out of
1383
+ * rendering.
1470
1384
  *
1471
1385
  * Call this method inside `addGenerator()` (in `kubb:plugin:setup`) to wire up a generator.
1472
1386
  */
1473
1387
  registerGenerator(pluginName, generator) {
1474
- const wrap = (method) => {
1475
- if (!method) return void 0;
1476
- return async (node, ctx) => {
1477
- if (ctx.plugin.name !== pluginName) return;
1478
- const result = await method(node, ctx);
1479
- await this.dispatch({
1480
- result,
1481
- renderer: generator.renderer
1482
- });
1483
- };
1484
- };
1485
- this.#unhooks.push(this.hooks.addHooks({
1486
- "kubb:generate:schema": wrap(generator.schema),
1487
- "kubb:generate:operation": wrap(generator.operation),
1488
- "kubb:generate:operations": wrap(generator.operations)
1489
- }));
1490
- this.#hookGeneratorPlugins.add(pluginName);
1388
+ const plugin = this.plugins.get(pluginName);
1389
+ if (!plugin) return;
1390
+ plugin.generators = plugin.generators ? [...plugin.generators, generator] : [generator];
1491
1391
  }
1492
1392
  /**
1493
1393
  * Returns `true` when at least one generator was registered for the given plugin
1494
1394
  * via `addGenerator()` in `kubb:plugin:setup`.
1495
1395
  *
1496
- * Used by the build loop to decide whether to walk the AST and emit generator hooks
1396
+ * Used by the build loop to decide whether to walk the AST and run the generators
1497
1397
  * for a plugin.
1498
1398
  */
1499
1399
  hasHookGenerators(pluginName) {
1500
- return this.#hookGeneratorPlugins.has(pluginName);
1400
+ return (this.plugins.get(pluginName)?.generators?.length ?? 0) > 0;
1501
1401
  }
1502
1402
  /**
1503
1403
  * Runs the full plugin pipeline. Returns the diagnostics collected so far even
@@ -1508,21 +1408,25 @@ var KubbDriver = class {
1508
1408
  async run() {
1509
1409
  const { hooks, config, fileManager } = this;
1510
1410
  const diagnostics = [];
1511
- const updateBuffer = [];
1512
1411
  const parsersMap = /* @__PURE__ */ new Map();
1513
1412
  for (const parser of config.parsers) if (parser.extNames) for (const ext of parser.extNames) parsersMap.set(ext, parser);
1413
+ const updateBuffer = [];
1514
1414
  const unhookWrites = fileManager.hooks.addHooks({
1515
1415
  start: async (files) => {
1516
1416
  await hooks.callHook("kubb:files:processing:start", { files });
1517
1417
  },
1518
- update: (item) => {
1519
- updateBuffer.push(item);
1418
+ update: ({ file, processed, total, percentage }) => {
1419
+ updateBuffer.push({
1420
+ file,
1421
+ processed,
1422
+ total,
1423
+ percentage,
1424
+ config
1425
+ });
1520
1426
  },
1521
1427
  end: async (files) => {
1522
- await hooks.callHook("kubb:files:processing:update", { files: updateBuffer.map((item) => ({
1523
- ...item,
1524
- config
1525
- })) });
1428
+ updateBuffer.sort((a, b) => a.processed - b.processed);
1429
+ await hooks.callHook("kubb:files:processing:update", { files: updateBuffer });
1526
1430
  updateBuffer.length = 0;
1527
1431
  await hooks.callHook("kubb:files:processing:end", { files });
1528
1432
  }
@@ -1532,12 +1436,16 @@ var KubbDriver = class {
1532
1436
  const outputRoot = resolve(config.root, config.output.path);
1533
1437
  await this.#parseInput();
1534
1438
  await this.setupHooks();
1535
- if (this.adapter && this.inputNode) await hooks.callHook("kubb:build:start", Object.assign({
1536
- config,
1537
- adapter: this.adapter,
1538
- meta: this.inputNode.meta,
1539
- getPlugin: this.getPlugin.bind(this)
1540
- }, this.#filesPayload()));
1439
+ for (const plugin of this.plugins.values()) plugin.options = normalizePluginOptions(plugin.options, plugin.name);
1440
+ if (this.adapter && this.inputNode) {
1441
+ const buildStartContext = this.#withFiles({
1442
+ config,
1443
+ adapter: this.adapter,
1444
+ meta: this.inputNode.meta,
1445
+ getPlugin: this.getPlugin.bind(this)
1446
+ });
1447
+ await hooks.callHook("kubb:build:start", buildStartContext);
1448
+ }
1541
1449
  const generatorPlugins = [];
1542
1450
  for (const plugin of this.plugins.values()) {
1543
1451
  const context = this.getContext(plugin);
@@ -1582,10 +1490,11 @@ var KubbDriver = class {
1582
1490
  });
1583
1491
  }
1584
1492
  diagnostics.push(...await this.#runGenerators(generatorPlugins));
1585
- await hooks.callHook("kubb:plugins:end", Object.assign({ config }, this.#filesPayload()));
1493
+ await hooks.callHook("kubb:plugins:end", this.#withFiles({ config }));
1586
1494
  await fileManager.write(fileManager.files, {
1587
1495
  storage: config.storage,
1588
- parsers: parsersMap
1496
+ parsers: parsersMap,
1497
+ manifest: this.options.manifest
1589
1498
  });
1590
1499
  await hooks.callHook("kubb:build:end", {
1591
1500
  files: this.fileManager.files,
@@ -1601,35 +1510,42 @@ var KubbDriver = class {
1601
1510
  }
1602
1511
  });
1603
1512
  }
1604
- #filesPayload() {
1605
- const driver = this;
1513
+ /**
1514
+ * Widens `extra` with the files present at emit time and a bound `upsertFile`, the shape every
1515
+ * file-carrying hook context shares. Building it here in one place keeps the `files` and
1516
+ * `upsertFile` keys from being dropped by a stray spread at the call site.
1517
+ */
1518
+ #withFiles(extra) {
1606
1519
  return {
1607
- get files() {
1608
- return driver.fileManager.files;
1609
- },
1610
- upsertFile: (...files) => driver.fileManager.upsert(...files)
1520
+ ...extra,
1521
+ files: this.fileManager.files,
1522
+ upsertFile: (...files) => this.fileManager.upsert(...files)
1611
1523
  };
1612
1524
  }
1613
1525
  #emitPluginEnd({ plugin, duration, success, error }) {
1614
- return this.hooks.callHook("kubb:plugin:end", Object.assign({
1526
+ return this.hooks.callHook("kubb:plugin:end", this.#withFiles({
1615
1527
  plugin,
1616
1528
  duration,
1617
1529
  success,
1618
1530
  ...error ? { error } : {},
1619
1531
  config: this.config
1620
- }, this.#filesPayload()));
1532
+ }));
1621
1533
  }
1622
1534
  /**
1623
- * Runs schemas and operations through every plugin's generators. Each node is run
1624
- * through the plugin's macros (from `this.#transforms`) before the generator sees it,
1625
- * so plugins stay isolated and the hot path stays per-node. Schemas run before operations
1626
- * so file output stays deterministic across runs.
1627
- * A failing plugin contributes an error diagnostic so the rest of the build continues.
1628
- * Every plugin also contributes a `timing` diagnostic.
1535
+ * Runs schemas and operations through every plugin's generators. The walk is node-outer: each
1536
+ * schema is visited once and each operation once, and the node fans out to the matching
1537
+ * generators of every plugin in dependency order. A per-node cache (`createNodeCache`) is created
1538
+ * once per node and shared by all of that node's plugins, so node-derived work is computed once
1539
+ * and reused instead of recomputed per plugin. Each node still runs through the plugin's macros
1540
+ * (from `this.#transforms`) and its exclude/include/override filters before that plugin's
1541
+ * generator sees it, so plugins stay isolated. Schemas run before operations so file output
1542
+ * stays deterministic across runs. A generator with a `match` predicate that resolves `false`
1543
+ * for a node is skipped for that node, without calling `schema`/`operation`.
1629
1544
  *
1630
- * Plugins are processed one at a time, in full, so `kubb:plugin:end` fires as each one
1631
- * completes rather than all at once at the end. That ordering drives the CLI's
1632
- * `Plugins N/M` counter.
1545
+ * A failing plugin is dropped from the remaining walk, contributes an error diagnostic, and no
1546
+ * longer aborts the other plugins. `kubb:plugin:end` and each plugin's `timing` diagnostic fire
1547
+ * in dependency order once the walk finishes, driving the CLI's `Plugins N/M` counter. The
1548
+ * `operations` batch fires once per plugin after the single operation walk.
1633
1549
  *
1634
1550
  * When `this.inputNode` is `null`, every entry still gets a `kubb:plugin:end` so
1635
1551
  * post-plugin listeners (the barrel writer and friends) complete.
@@ -1654,9 +1570,6 @@ var KubbDriver = class {
1654
1570
  }
1655
1571
  const transforms = this.#transforms;
1656
1572
  const { schemas, operations } = this.inputNode;
1657
- const emitsSchemaHook = this.hooks.listenerCount("kubb:generate:schema") > 0;
1658
- const emitsOperationHook = this.hooks.listenerCount("kubb:generate:operation") > 0;
1659
- const emitsOperationsHook = this.hooks.listenerCount("kubb:generate:operations") > 0;
1660
1573
  const allowedSchemaNamesByPlugin = /* @__PURE__ */ new Map();
1661
1574
  for (const { plugin } of entries) {
1662
1575
  const { exclude, include, override } = plugin.options;
@@ -1670,88 +1583,133 @@ var KubbDriver = class {
1670
1583
  }) !== null);
1671
1584
  allowedSchemaNamesByPlugin.set(plugin.name, collectUsedSchemaNames(includedOps, schemas));
1672
1585
  }
1673
- for (const { plugin, context, hrStart } of entries) {
1586
+ const states = entries.map(({ plugin, context, hrStart }) => {
1674
1587
  const generatorContext = {
1675
1588
  ...context,
1676
1589
  resolver: this.getResolver(plugin.name)
1677
1590
  };
1678
1591
  const { exclude, include, override } = plugin.options;
1679
- const optionsAreStatic = !exclude?.length && !include?.length && !override?.length;
1680
- const allowedSchemaNames = allowedSchemaNamesByPlugin.get(plugin.name) ?? null;
1681
- let error = null;
1682
- const resolveForPlugin = (node) => {
1683
- const transformedNode = transforms.applyTo(plugin.name, node);
1684
- if (optionsAreStatic) return {
1685
- transformedNode,
1686
- options: plugin.options
1687
- };
1688
- const options = generatorContext.resolver.default.options(transformedNode, {
1689
- options: plugin.options,
1690
- exclude,
1691
- include,
1692
- override
1693
- });
1694
- if (options === null) return null;
1695
- return {
1696
- transformedNode,
1697
- options
1698
- };
1592
+ const generators = plugin.generators ?? [];
1593
+ return {
1594
+ plugin,
1595
+ hrStart,
1596
+ generatorContext,
1597
+ exclude,
1598
+ include,
1599
+ override,
1600
+ optionsAreStatic: !exclude?.length && !include?.length && !override?.length,
1601
+ allowedSchemaNames: allowedSchemaNamesByPlugin.get(plugin.name) ?? null,
1602
+ schemaGenerators: generators.filter((generator) => generator.schema),
1603
+ operationGenerators: generators.filter((generator) => generator.operation),
1604
+ operationsGenerators: generators.filter((generator) => generator.operations),
1605
+ pluginOperations: [],
1606
+ error: null
1699
1607
  };
1700
- if (emitsSchemaHook) for (const node of schemas) {
1701
- if (error) break;
1608
+ });
1609
+ const resolveForPlugin = (state, node) => {
1610
+ const transformedNode = transforms.applyTo(state.plugin.name, node);
1611
+ if (state.optionsAreStatic) return {
1612
+ transformedNode,
1613
+ options: state.plugin.options
1614
+ };
1615
+ const options = state.generatorContext.resolver.default.options(transformedNode, {
1616
+ options: state.plugin.options,
1617
+ exclude: state.exclude,
1618
+ include: state.include,
1619
+ override: state.override
1620
+ });
1621
+ if (options === null) return null;
1622
+ return {
1623
+ transformedNode,
1624
+ options
1625
+ };
1626
+ };
1627
+ for (const node of schemas) {
1628
+ const cache = createNodeCache();
1629
+ for (const state of states) {
1630
+ if (state.error || !state.schemaGenerators.length) continue;
1702
1631
  try {
1703
- const resolved = resolveForPlugin(node);
1632
+ const resolved = resolveForPlugin(state, node);
1704
1633
  if (!resolved) continue;
1705
1634
  const { transformedNode, options } = resolved;
1706
- if (allowedSchemaNames !== null && transformedNode.name && !allowedSchemaNames.has(transformedNode.name)) continue;
1707
- await this.hooks.callHook("kubb:generate:schema", transformedNode, {
1708
- ...generatorContext,
1709
- options
1710
- });
1635
+ if (state.allowedSchemaNames !== null && transformedNode.name && !state.allowedSchemaNames.has(transformedNode.name)) continue;
1636
+ const ctx = {
1637
+ ...state.generatorContext,
1638
+ options,
1639
+ cache
1640
+ };
1641
+ for (const generator of state.schemaGenerators) {
1642
+ if (!(generator.match ? await generator.match(transformedNode, ctx) : true)) continue;
1643
+ await this.dispatch({
1644
+ result: await generator.schema(transformedNode, ctx),
1645
+ renderer: generator.renderer
1646
+ });
1647
+ }
1648
+ await this.hooks.callHook("kubb:generate:schema", transformedNode, ctx);
1711
1649
  } catch (caughtError) {
1712
- error = toError(caughtError);
1650
+ state.error = toError(caughtError);
1713
1651
  }
1714
1652
  }
1715
- if (emitsOperationHook) for (const node of operations) {
1716
- if (error) break;
1653
+ }
1654
+ for (const node of operations) {
1655
+ const cache = createNodeCache();
1656
+ for (const state of states) {
1657
+ if (state.error || !state.operationGenerators.length && !state.operationsGenerators.length) continue;
1717
1658
  try {
1718
- const resolved = resolveForPlugin(node);
1659
+ const resolved = resolveForPlugin(state, node);
1719
1660
  if (!resolved) continue;
1720
- await this.hooks.callHook("kubb:generate:operation", resolved.transformedNode, {
1721
- ...generatorContext,
1722
- options: resolved.options
1723
- });
1661
+ state.pluginOperations.push(resolved.transformedNode);
1662
+ if (state.operationGenerators.length) {
1663
+ const ctx = {
1664
+ ...state.generatorContext,
1665
+ options: resolved.options,
1666
+ cache
1667
+ };
1668
+ for (const generator of state.operationGenerators) {
1669
+ if (!(generator.match ? await generator.match(resolved.transformedNode, ctx) : true)) continue;
1670
+ await this.dispatch({
1671
+ result: await generator.operation(resolved.transformedNode, ctx),
1672
+ renderer: generator.renderer
1673
+ });
1674
+ }
1675
+ await this.hooks.callHook("kubb:generate:operation", resolved.transformedNode, ctx);
1676
+ }
1724
1677
  } catch (caughtError) {
1725
- error = toError(caughtError);
1678
+ state.error = toError(caughtError);
1726
1679
  }
1727
1680
  }
1728
- if (!error && emitsOperationsHook) try {
1681
+ }
1682
+ for (const state of states) {
1683
+ if (state.error || !state.operationsGenerators.length) continue;
1684
+ try {
1729
1685
  const ctx = {
1730
- ...generatorContext,
1731
- options: plugin.options
1686
+ ...state.generatorContext,
1687
+ options: state.plugin.options,
1688
+ cache: createNodeCache()
1732
1689
  };
1733
- const pluginOperations = operations.reduce((acc, node) => {
1734
- const resolved = resolveForPlugin(node);
1735
- if (resolved) acc.push(resolved.transformedNode);
1736
- return acc;
1737
- }, []);
1738
- await this.hooks.callHook("kubb:generate:operations", pluginOperations, ctx);
1690
+ for (const generator of state.operationsGenerators) await this.dispatch({
1691
+ result: await generator.operations(state.pluginOperations, ctx),
1692
+ renderer: generator.renderer
1693
+ });
1694
+ await this.hooks.callHook("kubb:generate:operations", state.pluginOperations, ctx);
1739
1695
  } catch (caughtError) {
1740
- error = toError(caughtError);
1696
+ state.error = toError(caughtError);
1741
1697
  }
1742
- const duration = getElapsedMs(hrStart);
1698
+ }
1699
+ for (const state of states) {
1700
+ const duration = getElapsedMs(state.hrStart);
1743
1701
  await this.#emitPluginEnd({
1744
- plugin,
1702
+ plugin: state.plugin,
1745
1703
  duration,
1746
- success: !error,
1747
- error: error ?? void 0
1704
+ success: !state.error,
1705
+ error: state.error ?? void 0
1748
1706
  });
1749
- if (error) diagnostics.push({
1750
- ...Diagnostics.from(error),
1751
- plugin: plugin.name
1707
+ if (state.error) diagnostics.push({
1708
+ ...Diagnostics.from(state.error),
1709
+ plugin: state.plugin.name
1752
1710
  });
1753
1711
  diagnostics.push(Diagnostics.performance({
1754
- plugin: plugin.name,
1712
+ plugin: state.plugin.name,
1755
1713
  duration
1756
1714
  }));
1757
1715
  }
@@ -1796,10 +1754,7 @@ var KubbDriver = class {
1796
1754
  dispose() {
1797
1755
  for (const unhook of this.#unhooks) unhook();
1798
1756
  this.#unhooks.length = 0;
1799
- this.#hookGeneratorPlugins.clear();
1800
1757
  this.#transforms.dispose();
1801
- this.#resolvers.clear();
1802
- this.#defaultResolvers.clear();
1803
1758
  this.fileManager.dispose();
1804
1759
  this.inputNode = null;
1805
1760
  this.#adapterSource = null;
@@ -1807,21 +1762,17 @@ var KubbDriver = class {
1807
1762
  [Symbol.dispose]() {
1808
1763
  this.dispose();
1809
1764
  }
1810
- #getDefaultResolver = memoize(this.#defaultResolvers, (pluginName) => createResolver({ pluginName }));
1811
1765
  /**
1812
- * Merges `partial` with the plugin's default resolver and stores the result.
1813
- * Also mirrors it onto `plugin.resolver` so callers using `getPlugin(name).resolver`
1814
- * get the up-to-date resolver without going through `getResolver()`.
1766
+ * Merges `partial` onto a fresh default resolver and stores the result on `plugin.resolver`,
1767
+ * which is the single source `getResolver` and `getPlugin(name).resolver` both read.
1815
1768
  */
1816
1769
  setPluginResolver(pluginName, partial) {
1817
- const defaultResolver = this.#getDefaultResolver(pluginName);
1818
- const merged = Resolver.merge(defaultResolver, partial);
1819
- this.#resolvers.set(pluginName, merged);
1820
1770
  const plugin = this.plugins.get(pluginName);
1821
- if (plugin) plugin.resolver = merged;
1771
+ if (!plugin) return;
1772
+ plugin.resolver = Resolver.merge(createResolver({ pluginName }), partial);
1822
1773
  }
1823
1774
  getResolver(pluginName) {
1824
- return this.#resolvers.get(pluginName) ?? this.#getDefaultResolver(pluginName);
1775
+ return this.plugins.get(pluginName)?.resolver ?? createResolver({ pluginName });
1825
1776
  }
1826
1777
  getContext(plugin) {
1827
1778
  const driver = this;
@@ -1904,6 +1855,73 @@ var KubbDriver = class {
1904
1855
  }
1905
1856
  };
1906
1857
  //#endregion
1858
+ //#region src/outputManifest.ts
1859
+ /**
1860
+ * Bumped when the stored shape changes, so an older cache is discarded instead of misread.
1861
+ */
1862
+ const VERSION = 1;
1863
+ function hash$1(value) {
1864
+ return createHash("sha256").update(value).digest("hex");
1865
+ }
1866
+ __name(hash$1, "hash");
1867
+ const MANIFEST_KEY = "output-manifest.json";
1868
+ async function loadEntries({ cache }) {
1869
+ try {
1870
+ const stored = await cache.readItem(MANIFEST_KEY);
1871
+ if (stored === null) return {};
1872
+ const data = JSON.parse(stored);
1873
+ if (data.version !== VERSION) return {};
1874
+ if (typeof data.entries !== "object" || data.entries === null || Array.isArray(data.entries)) return {};
1875
+ return data.entries;
1876
+ } catch {
1877
+ return {};
1878
+ }
1879
+ }
1880
+ /**
1881
+ * Loads the stored manifest, starting empty when it is missing, unreadable, or from an older
1882
+ * version. `storage` holds the generated files, `cache` holds the manifest itself.
1883
+ *
1884
+ * @example
1885
+ * ```ts
1886
+ * const manifest = await createOutputManifest({ storage: config.storage, cache: cacheStorage({ root: config.root }) })
1887
+ * ```
1888
+ */
1889
+ async function createOutputManifest({ storage, cache }) {
1890
+ const entries = await loadEntries({ cache });
1891
+ const tracked = /* @__PURE__ */ new Map();
1892
+ return {
1893
+ isUpToDate({ key, source, disk }) {
1894
+ const entry = entries[key];
1895
+ if (!entry) return false;
1896
+ return entry.source === hash$1(source) && entry.output === hash$1(disk);
1897
+ },
1898
+ track({ key, source }) {
1899
+ tracked.set(key, hash$1(source));
1900
+ },
1901
+ async commit() {
1902
+ try {
1903
+ const next = { ...entries };
1904
+ await inParallel({
1905
+ items: [...tracked],
1906
+ limit: 50,
1907
+ run: async ([key, source]) => {
1908
+ const stored = await storage.readItem(key);
1909
+ if (stored === null) return;
1910
+ next[key] = {
1911
+ source,
1912
+ output: hash$1(stored)
1913
+ };
1914
+ }
1915
+ });
1916
+ await cache.writeItem(MANIFEST_KEY, JSON.stringify({
1917
+ version: VERSION,
1918
+ entries: next
1919
+ }));
1920
+ } catch {}
1921
+ }
1922
+ };
1923
+ }
1924
+ //#endregion
1907
1925
  //#region src/createStorage.ts
1908
1926
  /**
1909
1927
  * Defines a custom storage backend. The builder receives user options and
@@ -1920,23 +1938,23 @@ var KubbDriver = class {
1920
1938
  *
1921
1939
  * return {
1922
1940
  * name: 'memory',
1923
- * async hasItem(key) {
1941
+ * async existsItem(key) {
1924
1942
  * return store.has(key)
1925
1943
  * },
1926
- * async getItem(key) {
1944
+ * async readItem(key) {
1927
1945
  * return store.get(key) ?? null
1928
1946
  * },
1929
- * async setItem(key, value) {
1947
+ * async writeItem(key, value) {
1930
1948
  * store.set(key, value)
1931
1949
  * },
1932
1950
  * async removeItem(key) {
1933
1951
  * store.delete(key)
1934
1952
  * },
1935
- * async getKeys(base) {
1953
+ * async readKeys(base) {
1936
1954
  * const keys = [...store.keys()]
1937
1955
  * return base ? keys.filter((k) => k.startsWith(base)) : keys
1938
1956
  * },
1939
- * async clear(base) {
1957
+ * async empty(base) {
1940
1958
  * if (!base) store.clear()
1941
1959
  * },
1942
1960
  * }
@@ -1980,10 +1998,11 @@ function createLimiter(concurrency) {
1980
1998
  *
1981
1999
  * Writes are deduplicated and directory-safe:
1982
2000
  * - leading and trailing whitespace is trimmed before writing
1983
- * - the write is skipped when the file content is already identical
2001
+ * - the write is skipped when the file already holds that content, ignoring any trailing newline
2002
+ * a formatter left behind
1984
2003
  * - missing parent directories are created automatically
1985
2004
  * - Bun's native file API is used when running under Bun
1986
- * - concurrent `setItem` calls are capped at {@link WRITE_CONCURRENCY} in flight, so a caller
2005
+ * - concurrent `writeItem` calls are capped at {@link WRITE_CONCURRENCY} in flight, so a caller
1987
2006
  * can fire every file's write without pacing itself
1988
2007
  *
1989
2008
  * @example
@@ -2002,7 +2021,7 @@ const fsStorage = createStorage(() => {
2002
2021
  const limit = createLimiter(WRITE_CONCURRENCY);
2003
2022
  return {
2004
2023
  name: "fs",
2005
- async hasItem(key) {
2024
+ async existsItem(key) {
2006
2025
  try {
2007
2026
  await access(resolve(key));
2008
2027
  return true;
@@ -2010,20 +2029,20 @@ const fsStorage = createStorage(() => {
2010
2029
  return false;
2011
2030
  }
2012
2031
  },
2013
- async getItem(key) {
2032
+ async readItem(key) {
2014
2033
  try {
2015
2034
  return await readFile(resolve(key), "utf8");
2016
2035
  } catch (_error) {
2017
2036
  return null;
2018
2037
  }
2019
2038
  },
2020
- async setItem(key, value) {
2039
+ async writeItem(key, value) {
2021
2040
  await limit(() => write(resolve(key), value, { sanity: false }));
2022
2041
  },
2023
2042
  async removeItem(key) {
2024
2043
  await rm(resolve(key), { force: true });
2025
2044
  },
2026
- async getKeys(base) {
2045
+ async readKeys(base) {
2027
2046
  const resolvedBase = resolve(base ?? process.cwd());
2028
2047
  const keys = [];
2029
2048
  try {
@@ -2034,13 +2053,67 @@ const fsStorage = createStorage(() => {
2034
2053
  } catch (_error) {}
2035
2054
  return keys;
2036
2055
  },
2037
- async clear(base) {
2056
+ async empty(base) {
2038
2057
  if (!base) return;
2039
2058
  await clean(resolve(base));
2040
2059
  }
2041
2060
  };
2042
2061
  });
2043
2062
  //#endregion
2063
+ //#region src/storages/cacheStorage.ts
2064
+ /**
2065
+ * Directory Kubb keeps build caches in. A project with a `node_modules` gets
2066
+ * `node_modules/.cache/kubb`, the convention babel and eslint already use, so the cache stays out
2067
+ * of version control. Without one it falls back to the OS temp directory, keyed by root so two
2068
+ * projects sharing that directory keep their own cache.
2069
+ *
2070
+ * @example Inside a project
2071
+ * `resolveCacheDir('/project') // '/project/node_modules/.cache/kubb'`
2072
+ */
2073
+ function resolveCacheDir(root) {
2074
+ const nodeModules = join(root, "node_modules");
2075
+ if (existsSync(nodeModules)) return join(nodeModules, ".cache", "kubb");
2076
+ return join(tmpdir(), "kubb", createHash("sha256").update(root).digest("hex").slice(0, 16));
2077
+ }
2078
+ /**
2079
+ * Filesystem storage for build caches rather than generated code. Keys are plain names resolved
2080
+ * inside {@link resolveCacheDir}, so a caller stores `'x.json'` without knowing where the cache
2081
+ * lives. Kept apart from the configured output storage, which may not be a local disk at all.
2082
+ *
2083
+ * @example
2084
+ * ```ts
2085
+ * const cache = cacheStorage({ root: config.root })
2086
+ * await cache.writeItem('output-manifest.json', JSON.stringify(entries))
2087
+ * ```
2088
+ */
2089
+ const cacheStorage = createStorage(({ root = process.cwd() }) => {
2090
+ const dir = resolveCacheDir(root);
2091
+ const storage = fsStorage();
2092
+ const toPath = (key) => join(dir, key);
2093
+ return {
2094
+ name: "cache",
2095
+ async existsItem(key) {
2096
+ return storage.existsItem(toPath(key));
2097
+ },
2098
+ async readItem(key) {
2099
+ return storage.readItem(toPath(key));
2100
+ },
2101
+ async writeItem(key, value) {
2102
+ return storage.writeItem(toPath(key), value);
2103
+ },
2104
+ async removeItem(key) {
2105
+ return storage.removeItem(toPath(key));
2106
+ },
2107
+ async readKeys(base) {
2108
+ return storage.readKeys(base ? toPath(base) : dir);
2109
+ },
2110
+ async empty(base) {
2111
+ if (!base) return;
2112
+ return storage.empty(toPath(base));
2113
+ }
2114
+ };
2115
+ });
2116
+ //#endregion
2044
2117
  //#region src/createKubb.ts
2045
2118
  function resolveConfig(userConfig) {
2046
2119
  return {
@@ -2059,6 +2132,13 @@ function resolveConfig(userConfig) {
2059
2132
  };
2060
2133
  }
2061
2134
  /**
2135
+ * Whether anything runs over the output directory after the files are written. Only then can the
2136
+ * bytes on disk stop matching what Kubb wrote, which is what the manifest exists to track.
2137
+ */
2138
+ function hasOutputPasses(output) {
2139
+ return Boolean(output.format || output.lint || output.postGenerate?.length);
2140
+ }
2141
+ /**
2062
2142
  * Kubb code-generation instance bound to a single config entry. Resolves the user
2063
2143
  * config in the constructor, so `config` is available right away, and shares `hooks`,
2064
2144
  * `storage`, and `driver` across the `setup → build` lifecycle.
@@ -2080,6 +2160,7 @@ var Kubb = class {
2080
2160
  config;
2081
2161
  #driver = null;
2082
2162
  #storage = null;
2163
+ #manifest = null;
2083
2164
  constructor(userConfig, options = {}) {
2084
2165
  this.config = resolveConfig(userConfig);
2085
2166
  this.hooks = options.hooks ?? new Hookable();
@@ -2097,7 +2178,14 @@ var Kubb = class {
2097
2178
  */
2098
2179
  async setup() {
2099
2180
  const config = this.config;
2100
- const driver = new KubbDriver(config, { hooks: this.hooks });
2181
+ const manifest = hasOutputPasses(config.output) ? await createOutputManifest({
2182
+ storage: config.storage,
2183
+ cache: cacheStorage({ root: config.root })
2184
+ }) : void 0;
2185
+ const driver = new KubbDriver(config, {
2186
+ hooks: this.hooks,
2187
+ manifest
2188
+ });
2101
2189
  this.hooks.setMaxListeners(Math.max(10, config.plugins.length * 4));
2102
2190
  if (config.output.clean) {
2103
2191
  const cleanPath = resolve(config.root, config.output.path);
@@ -2108,11 +2196,12 @@ var Kubb = class {
2108
2196
  help: "Point `output.path` at a subdirectory such as `./src/gen` so clean only removes generated code.",
2109
2197
  location: { kind: "config" }
2110
2198
  });
2111
- await config.storage.clear(cleanPath);
2199
+ await config.storage.empty(cleanPath);
2112
2200
  }
2113
2201
  await driver.setup();
2114
2202
  this.#driver = driver;
2115
2203
  this.#storage = config.storage;
2204
+ this.#manifest = manifest ?? null;
2116
2205
  }
2117
2206
  /**
2118
2207
  * Runs the full pipeline and throws on any plugin error.
@@ -2151,6 +2240,70 @@ var Kubb = class {
2151
2240
  _usingCtx$1.d();
2152
2241
  }
2153
2242
  }
2243
+ /**
2244
+ * Run one build and its output passes end to end, emitting the surrounding `kubb:generation:*`
2245
+ * hooks. Never throws on a build error: the outcome comes back in {@link GenerateResult} so the
2246
+ * host decides how failures surface. Telemetry and progress narration stay with the host, which
2247
+ * reads the result and subscribes to the `kubb:*` hooks.
2248
+ *
2249
+ * @example
2250
+ * ```ts
2251
+ * const result = await createKubb(config, { hooks }).generate()
2252
+ * if (!result.success) process.exitCode = 1
2253
+ * ```
2254
+ */
2255
+ async generate(options = {}) {
2256
+ const { hooks, config } = this;
2257
+ const hrStart = process.hrtime();
2258
+ await hooks.callHook("kubb:generation:start", { config });
2259
+ await hooks.callHook("kubb:setup:start");
2260
+ await this.setup();
2261
+ await hooks.callHook("kubb:setup:end");
2262
+ const { files, diagnostics, storage } = await this.safeBuild();
2263
+ for (const diagnostic of diagnostics) {
2264
+ if (!Diagnostics.isProblem(diagnostic)) continue;
2265
+ if (diagnostic.code === Diagnostics.code.unknown) {
2266
+ await hooks.callHook("kubb:error", { error: diagnostic.cause ?? new Error(diagnostic.message) });
2267
+ continue;
2268
+ }
2269
+ await Diagnostics.emit(hooks, diagnostic);
2270
+ }
2271
+ if (Diagnostics.hasError(diagnostics)) {
2272
+ await hooks.callHook("kubb:generation:end", {
2273
+ config,
2274
+ storage,
2275
+ diagnostics,
2276
+ filesCreated: files.length,
2277
+ status: "failed",
2278
+ hrStart
2279
+ });
2280
+ return {
2281
+ success: false,
2282
+ files,
2283
+ diagnostics
2284
+ };
2285
+ }
2286
+ const outputDiagnostics = options.processOutput ? await options.processOutput({
2287
+ config,
2288
+ outputPath: resolve(config.root, config.output.path)
2289
+ }) : [];
2290
+ const finalDiagnostics = [...diagnostics, ...outputDiagnostics];
2291
+ const failed = Diagnostics.hasError(outputDiagnostics);
2292
+ if (!failed) await this.#manifest?.commit();
2293
+ await hooks.callHook("kubb:generation:end", {
2294
+ config,
2295
+ storage,
2296
+ diagnostics: finalDiagnostics,
2297
+ filesCreated: files.length,
2298
+ status: failed ? "failed" : "success",
2299
+ hrStart
2300
+ });
2301
+ return {
2302
+ success: !failed,
2303
+ files,
2304
+ diagnostics: finalDiagnostics
2305
+ };
2306
+ }
2154
2307
  dispose() {
2155
2308
  this.#driver?.dispose();
2156
2309
  }
@@ -2391,11 +2544,13 @@ const fileReporter = createReporter({
2391
2544
  const { diagnostics, config } = result;
2392
2545
  if (diagnostics.length === 0) return;
2393
2546
  const report = buildReport(result);
2394
- const content = stripVTControlCharacters([config.name ? `# ${config.name} — ${(/* @__PURE__ */ new Date()).toISOString()}` : `# ${(/* @__PURE__ */ new Date()).toISOString()}`, ...[
2547
+ const header = config.name ? `# ${config.name} — ${(/* @__PURE__ */ new Date()).toISOString()}` : `# ${(/* @__PURE__ */ new Date()).toISOString()}`;
2548
+ const sections = [
2395
2549
  buildSummarySection(report),
2396
2550
  buildProblemSection(diagnostics),
2397
2551
  buildTimingSection(report)
2398
- ].filter((section) => section.length > 0).map((section) => section.join("\n"))].join("\n\n"));
2552
+ ].filter((section) => section.length > 0);
2553
+ const content = stripVTControlCharacters([header, ...sections.map((section) => section.join("\n"))].join("\n\n"));
2399
2554
  const baseName = `${[
2400
2555
  "kubb",
2401
2556
  config.name,
@@ -2548,23 +2703,23 @@ const memoryStorage = createStorage(() => {
2548
2703
  const store = /* @__PURE__ */ new Map();
2549
2704
  return {
2550
2705
  name: "memory",
2551
- async hasItem(key) {
2706
+ async existsItem(key) {
2552
2707
  return store.has(key);
2553
2708
  },
2554
- async getItem(key) {
2709
+ async readItem(key) {
2555
2710
  return store.get(key) ?? null;
2556
2711
  },
2557
- async setItem(key, value) {
2712
+ async writeItem(key, value) {
2558
2713
  store.set(key, value);
2559
2714
  },
2560
2715
  async removeItem(key) {
2561
2716
  store.delete(key);
2562
2717
  },
2563
- async getKeys(base) {
2718
+ async readKeys(base) {
2564
2719
  const keys = [...store.keys()];
2565
2720
  return base ? keys.filter((k) => k.startsWith(base)) : keys;
2566
2721
  },
2567
- async clear(base) {
2722
+ async empty(base) {
2568
2723
  if (!base) {
2569
2724
  store.clear();
2570
2725
  return;