modestbench 0.7.0 → 0.9.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.
Files changed (184) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +37 -4
  3. package/dist/adapters/types.d.cts +1 -1
  4. package/dist/adapters/types.d.cts.map +1 -1
  5. package/dist/adapters/types.d.ts +1 -1
  6. package/dist/adapters/types.d.ts.map +1 -1
  7. package/dist/cli/commands/run.cjs +93 -49
  8. package/dist/cli/commands/run.cjs.map +1 -1
  9. package/dist/cli/commands/run.d.cts +1 -0
  10. package/dist/cli/commands/run.d.cts.map +1 -1
  11. package/dist/cli/commands/run.d.ts +1 -0
  12. package/dist/cli/commands/run.d.ts.map +1 -1
  13. package/dist/cli/commands/run.js +95 -51
  14. package/dist/cli/commands/run.js.map +1 -1
  15. package/dist/cli/index.cjs +7 -1
  16. package/dist/cli/index.cjs.map +1 -1
  17. package/dist/cli/index.d.cts.map +1 -1
  18. package/dist/cli/index.d.ts.map +1 -1
  19. package/dist/cli/index.js +7 -1
  20. package/dist/cli/index.js.map +1 -1
  21. package/dist/{core → config}/benchmark-schema.cjs +1 -1
  22. package/dist/config/benchmark-schema.cjs.map +1 -0
  23. package/dist/config/benchmark-schema.d.cts +913 -0
  24. package/dist/config/benchmark-schema.d.cts.map +1 -0
  25. package/dist/config/benchmark-schema.d.ts +913 -0
  26. package/dist/config/benchmark-schema.d.ts.map +1 -0
  27. package/dist/{core → config}/benchmark-schema.js +1 -1
  28. package/dist/config/benchmark-schema.js.map +1 -0
  29. package/dist/config/schema.cjs +188 -105
  30. package/dist/config/schema.cjs.map +1 -1
  31. package/dist/config/schema.d.cts +208 -80
  32. package/dist/config/schema.d.cts.map +1 -1
  33. package/dist/config/schema.d.ts +208 -80
  34. package/dist/config/schema.d.ts.map +1 -1
  35. package/dist/config/schema.js +187 -104
  36. package/dist/config/schema.js.map +1 -1
  37. package/dist/constants.cjs +2 -0
  38. package/dist/constants.cjs.map +1 -1
  39. package/dist/constants.d.cts +2 -0
  40. package/dist/constants.d.cts.map +1 -1
  41. package/dist/constants.d.ts +2 -0
  42. package/dist/constants.d.ts.map +1 -1
  43. package/dist/constants.js +2 -0
  44. package/dist/constants.js.map +1 -1
  45. package/dist/core/engine.cjs +50 -45
  46. package/dist/core/engine.cjs.map +1 -1
  47. package/dist/core/engine.d.cts.map +1 -1
  48. package/dist/core/engine.d.ts.map +1 -1
  49. package/dist/core/engine.js +50 -45
  50. package/dist/core/engine.js.map +1 -1
  51. package/dist/core/output-path-resolver.cjs +15 -1
  52. package/dist/core/output-path-resolver.cjs.map +1 -1
  53. package/dist/core/output-path-resolver.d.cts +8 -0
  54. package/dist/core/output-path-resolver.d.cts.map +1 -1
  55. package/dist/core/output-path-resolver.d.ts +8 -0
  56. package/dist/core/output-path-resolver.d.ts.map +1 -1
  57. package/dist/core/output-path-resolver.js +13 -0
  58. package/dist/core/output-path-resolver.js.map +1 -1
  59. package/dist/errors/index.cjs +3 -1
  60. package/dist/errors/index.cjs.map +1 -1
  61. package/dist/errors/index.d.cts +1 -1
  62. package/dist/errors/index.d.cts.map +1 -1
  63. package/dist/errors/index.d.ts +1 -1
  64. package/dist/errors/index.d.ts.map +1 -1
  65. package/dist/errors/index.js +1 -1
  66. package/dist/errors/index.js.map +1 -1
  67. package/dist/errors/reporter.cjs +45 -1
  68. package/dist/errors/reporter.cjs.map +1 -1
  69. package/dist/errors/reporter.d.cts +32 -0
  70. package/dist/errors/reporter.d.cts.map +1 -1
  71. package/dist/errors/reporter.d.ts +32 -0
  72. package/dist/errors/reporter.d.ts.map +1 -1
  73. package/dist/errors/reporter.js +42 -0
  74. package/dist/errors/reporter.js.map +1 -1
  75. package/dist/index.cjs +16 -1
  76. package/dist/index.cjs.map +1 -1
  77. package/dist/index.d.cts +3 -1
  78. package/dist/index.d.cts.map +1 -1
  79. package/dist/index.d.ts +3 -1
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +5 -1
  82. package/dist/index.js.map +1 -1
  83. package/dist/reporters/json.cjs +1 -1
  84. package/dist/reporters/json.cjs.map +1 -1
  85. package/dist/reporters/json.js +1 -1
  86. package/dist/reporters/json.js.map +1 -1
  87. package/dist/schema/modestbench-config.schema.json +94 -87
  88. package/dist/services/budget-evaluator.cjs +8 -6
  89. package/dist/services/budget-evaluator.cjs.map +1 -1
  90. package/dist/services/budget-evaluator.d.cts +2 -2
  91. package/dist/services/budget-evaluator.d.cts.map +1 -1
  92. package/dist/services/budget-evaluator.d.ts +2 -2
  93. package/dist/services/budget-evaluator.d.ts.map +1 -1
  94. package/dist/services/budget-evaluator.js +8 -6
  95. package/dist/services/budget-evaluator.js.map +1 -1
  96. package/dist/services/budget-resolver.cjs +214 -0
  97. package/dist/services/budget-resolver.cjs.map +1 -0
  98. package/dist/services/budget-resolver.d.cts +98 -0
  99. package/dist/services/budget-resolver.d.cts.map +1 -0
  100. package/dist/services/budget-resolver.d.ts +98 -0
  101. package/dist/services/budget-resolver.d.ts.map +1 -0
  102. package/dist/services/budget-resolver.js +203 -0
  103. package/dist/services/budget-resolver.js.map +1 -0
  104. package/dist/services/file-loader.cjs +1 -1
  105. package/dist/services/file-loader.cjs.map +1 -1
  106. package/dist/services/file-loader.js +1 -1
  107. package/dist/services/file-loader.js.map +1 -1
  108. package/dist/services/reporter-loader.cjs +281 -0
  109. package/dist/services/reporter-loader.cjs.map +1 -0
  110. package/dist/services/reporter-loader.d.cts +67 -0
  111. package/dist/services/reporter-loader.d.cts.map +1 -0
  112. package/dist/services/reporter-loader.d.ts +67 -0
  113. package/dist/services/reporter-loader.d.ts.map +1 -0
  114. package/dist/services/reporter-loader.js +241 -0
  115. package/dist/services/reporter-loader.js.map +1 -0
  116. package/dist/types/budgets.d.cts +31 -0
  117. package/dist/types/budgets.d.cts.map +1 -1
  118. package/dist/types/budgets.d.ts +31 -0
  119. package/dist/types/budgets.d.ts.map +1 -1
  120. package/dist/types/core.cjs.map +1 -1
  121. package/dist/types/core.d.cts +28 -75
  122. package/dist/types/core.d.cts.map +1 -1
  123. package/dist/types/core.d.ts +28 -75
  124. package/dist/types/core.d.ts.map +1 -1
  125. package/dist/types/core.js.map +1 -1
  126. package/dist/types/index.cjs.map +1 -1
  127. package/dist/types/index.d.cts +1 -0
  128. package/dist/types/index.d.cts.map +1 -1
  129. package/dist/types/index.d.ts +1 -0
  130. package/dist/types/index.d.ts.map +1 -1
  131. package/dist/types/index.js.map +1 -1
  132. package/dist/types/plugin.cjs +9 -0
  133. package/dist/types/plugin.cjs.map +1 -0
  134. package/dist/types/plugin.d.cts +179 -0
  135. package/dist/types/plugin.d.cts.map +1 -0
  136. package/dist/types/plugin.d.ts +179 -0
  137. package/dist/types/plugin.d.ts.map +1 -0
  138. package/dist/types/plugin.js +8 -0
  139. package/dist/types/plugin.js.map +1 -0
  140. package/dist/utils/package.cjs +66 -5
  141. package/dist/utils/package.cjs.map +1 -1
  142. package/dist/utils/package.d.cts +6 -0
  143. package/dist/utils/package.d.cts.map +1 -1
  144. package/dist/utils/package.d.ts +6 -0
  145. package/dist/utils/package.d.ts.map +1 -1
  146. package/dist/utils/package.js +31 -1
  147. package/dist/utils/package.js.map +1 -1
  148. package/dist/utils/reporter-utils.cjs +90 -0
  149. package/dist/utils/reporter-utils.cjs.map +1 -0
  150. package/dist/utils/reporter-utils.d.cts +42 -0
  151. package/dist/utils/reporter-utils.d.cts.map +1 -0
  152. package/dist/utils/reporter-utils.d.ts +42 -0
  153. package/dist/utils/reporter-utils.d.ts.map +1 -0
  154. package/dist/utils/reporter-utils.js +83 -0
  155. package/dist/utils/reporter-utils.js.map +1 -0
  156. package/package.json +20 -9
  157. package/src/adapters/types.ts +1 -1
  158. package/src/cli/commands/run.ts +140 -69
  159. package/src/cli/index.ts +8 -1
  160. package/src/{core → config}/benchmark-schema.ts +1 -1
  161. package/src/config/schema.ts +379 -302
  162. package/src/constants.ts +2 -0
  163. package/src/core/engine.ts +74 -69
  164. package/src/core/output-path-resolver.ts +14 -0
  165. package/src/errors/index.ts +2 -0
  166. package/src/errors/reporter.ts +55 -0
  167. package/src/index.ts +19 -1
  168. package/src/reporters/json.ts +1 -1
  169. package/src/services/budget-evaluator.ts +13 -9
  170. package/src/services/budget-resolver.ts +254 -0
  171. package/src/services/file-loader.ts +1 -1
  172. package/src/services/reporter-loader.ts +323 -0
  173. package/src/types/budgets.ts +38 -0
  174. package/src/types/core.ts +64 -99
  175. package/src/types/index.ts +3 -0
  176. package/src/types/plugin.ts +197 -0
  177. package/src/utils/package.ts +32 -1
  178. package/src/utils/reporter-utils.ts +85 -0
  179. package/dist/core/benchmark-schema.cjs.map +0 -1
  180. package/dist/core/benchmark-schema.d.cts +0 -139
  181. package/dist/core/benchmark-schema.d.cts.map +0 -1
  182. package/dist/core/benchmark-schema.d.ts +0 -139
  183. package/dist/core/benchmark-schema.d.ts.map +0 -1
  184. package/dist/core/benchmark-schema.js.map +0 -1
@@ -8,11 +8,46 @@
8
8
 
9
9
  import * as z from 'zod';
10
10
 
11
- import type { ModestBenchConfig } from '../types/core.js';
11
+ import type {
12
+ Budget,
13
+ BudgetPattern,
14
+ ResolvedBudgets,
15
+ } from '../types/budgets.js';
12
16
 
13
17
  import { BENCHMARK_FILE_PATTERN } from '../constants.js';
18
+ import {
19
+ createBudgetPattern,
20
+ isGlobPattern,
21
+ } from '../services/budget-resolver.js';
14
22
  import { parsePercentageString, parseTimeString } from './budget-schema.js';
15
23
 
24
+ /**
25
+ * Schema for JSON reporter configuration options
26
+ */
27
+ export const jsonReporterConfigSchema = z.object({
28
+ prettyPrint: z
29
+ .boolean()
30
+ .optional()
31
+ .describe('Whether to pretty-print JSON output (default: false)'),
32
+ });
33
+
34
+ /**
35
+ * Schema for reporter-specific configuration
36
+ *
37
+ * Allows typed configuration for known reporters while permitting unknown
38
+ * reporter configs via catchall.
39
+ */
40
+ export const reporterConfigSchema = z
41
+ .object({
42
+ json: jsonReporterConfigSchema
43
+ .optional()
44
+ .describe('Configuration options for the JSON reporter'),
45
+ })
46
+ .catchall(z.unknown())
47
+ .describe(
48
+ 'Configuration options specific to individual reporters, keyed by reporter name',
49
+ );
50
+
16
51
  /**
17
52
  * Schema for threshold configuration
18
53
  *
@@ -58,11 +93,11 @@ const thresholdConfigSchema = z
58
93
  });
59
94
 
60
95
  /**
61
- * Inline budget schema for configuration (no transforms for JSON Schema
62
- * compatibility - transforms are applied manually in transformBudgets
63
- * function)
96
+ * Input schema for budget values (before transformation)
97
+ *
98
+ * Accepts string values like "10ms" or "10%" for human-readable configuration.
64
99
  */
65
- const budgetSchema = z
100
+ const budgetInputSchema = z
66
101
  .object({
67
102
  absolute: z
68
103
  .object({
@@ -72,6 +107,7 @@ const budgetSchema = z
72
107
  .describe('Maximum 99th percentile in nanoseconds or time string'),
73
108
  maxTime: z
74
109
  .union([z.number().positive(), z.string()])
110
+ .optional()
75
111
  .describe(
76
112
  'Maximum mean time in nanoseconds or time string (e.g., "10ms")',
77
113
  ),
@@ -98,324 +134,367 @@ const budgetSchema = z
98
134
  .describe('Performance budget with absolute and/or relative thresholds');
99
135
 
100
136
  /**
101
- * Schema for the main ModestBench configuration
137
+ * Transform budget values (parse time/percentage strings to numbers)
102
138
  *
103
- * This is the complete configuration schema used for validating benchmark
104
- * configuration from all sources (files, CLI args, defaults).
139
+ * Returns a Budget object with all string values converted to numbers.
105
140
  */
106
- const modestBenchConfigSchema = z
107
- .object({
108
- $schema: z
109
- .string()
110
- .optional()
111
- .describe(
112
- 'JSON Schema reference for IDE support (not used by ModestBench)',
113
- ),
114
- bail: z.boolean().describe('Stop benchmark execution on first failure'),
115
- baseline: z
116
- .string()
117
- .optional()
118
- .describe(
119
- 'Name of baseline to use for relative budget comparisons. Must match a saved baseline name.',
120
- ),
121
- budgetMode: z
122
- .enum(['fail', 'warn', 'report'])
123
- .optional()
124
- .describe(
125
- 'How to handle budget violations: "fail" exits with error (default), "warn" shows warnings, "report" includes in output without failing',
126
- ),
127
- budgets: z
128
- .record(
129
- z.string(),
130
- z.record(z.string(), z.record(z.string(), budgetSchema)),
131
- )
132
- .optional()
133
- .describe(
134
- 'Performance budgets organized by file → suite → task. Budgets define acceptable performance thresholds.',
135
- ),
136
- exclude: z
137
- .array(z.string())
138
- .describe(
139
- 'Glob patterns to exclude from benchmark file discovery (e.g., "node_modules/**", ".git/**")',
140
- ),
141
- excludeTags: z
142
- .array(z.string())
143
- .describe(
144
- 'Tags to exclude from benchmark execution. Benchmarks matching any of these tags will be skipped.',
145
- ),
146
- iterations: z
147
- .number()
148
- .int()
149
- .positive()
150
- .describe(
151
- 'Default number of iterations to run for each benchmark task. Higher values provide more accurate statistics but take longer to execute.',
152
- ),
153
- limitBy: z
154
- .enum(['time', 'iterations', 'any', 'all'])
155
- .describe(
156
- 'How to limit benchmark execution: "time" stops after time limit, "iterations" stops after iteration count, "any" stops at whichever comes first, "all" runs until both limits are reached',
157
- ),
158
- metadata: z
159
- .record(z.string(), z.unknown())
160
- .describe(
161
- 'Custom metadata to attach to benchmark runs. Can include project name, version, environment details, etc.',
162
- ),
163
- outputDir: z
164
- .string()
165
- .min(1)
166
- .optional()
167
- .describe(
168
- 'Directory path where benchmark results and reports will be written. If not specified, data reporters will write to stdout.',
169
- ),
170
- pattern: z
171
- .union([z.string().min(1), z.array(z.string().min(1))])
172
- .describe(
173
- `Glob pattern(s) for discovering benchmark files. Can be a single pattern string or array of patterns (e.g., "**/*${BENCHMARK_FILE_PATTERN}")`,
174
- ),
175
- profile: z
176
- .object({
177
- exclude: z
178
- .array(z.string())
179
- .optional()
180
- .describe('Glob patterns to exclude from profiling results'),
181
- focus: z
182
- .array(z.string())
183
- .optional()
184
- .describe(
185
- 'Glob patterns to focus on in profiling results. If specified, only matching files will be shown',
186
- ),
187
- minCallCount: z
188
- .number()
189
- .int()
190
- .nonnegative()
191
- .optional()
192
- .describe(
193
- 'Minimum number of times a function must be called to be included in results',
194
- ),
195
- minExecutionPercent: z
196
- .number()
197
- .nonnegative()
198
- .max(100)
199
- .default(1.0)
200
- .describe(
201
- 'Minimum execution percentage threshold for including functions in results',
202
- ),
203
- outputFile: z
204
- .string()
205
- .optional()
206
- .describe('Path to write profile report to file'),
207
- smartDetection: z
208
- .boolean()
209
- .default(true)
210
- .describe(
211
- 'Automatically detect and focus on user code, excluding node_modules and Node.js internals',
212
- ),
213
- topN: z
214
- .number()
215
- .int()
216
- .positive()
217
- .default(25)
218
- .describe('Maximum number of top functions to show in results'),
219
- })
220
- .optional()
221
- .describe(
222
- 'Configuration for profile command to identify benchmark candidates',
223
- ),
224
- quiet: z
225
- .boolean()
226
- .describe(
227
- 'Run in quiet mode with minimal console output (only errors and final results)',
228
- ),
229
- reporterConfig: z
230
- .record(z.string(), z.unknown())
231
- .describe(
232
- 'Configuration options specific to individual reporters, keyed by reporter name',
233
- ),
234
- reporters: z
235
- .array(z.string())
236
- .min(1)
237
- .describe(
238
- 'List of reporter names to use for output. Available reporters: "human", "json", "csv"',
239
- ),
240
- tags: z
241
- .array(z.string())
242
- .describe(
243
- 'Tags to filter which benchmarks to run. If empty, all benchmarks are included. Only benchmarks with matching tags will execute.',
244
- ),
245
- thresholds: thresholdConfigSchema,
246
- time: z
247
- .number()
248
- .int()
249
- .positive()
250
- .describe(
251
- 'Maximum time to spend on each benchmark task in milliseconds. Tasks will run at least until this duration or iteration count is reached, depending on limitBy setting.',
252
- ),
253
- timeout: z
254
- .number()
255
- .int()
256
- .positive()
257
- .describe(
258
- 'Timeout for individual benchmark tasks in milliseconds. Tasks exceeding this duration will be terminated and marked as failed.',
259
- ),
260
- verbose: z
261
- .boolean()
262
- .describe(
263
- 'Enable verbose output. Provides more detailed console output including progress, intermediate results, and diagnostic information',
264
- ),
265
- warmup: z
266
- .number()
267
- .int()
268
- .nonnegative()
269
- .describe(
270
- 'Number of warmup iterations to run before measurement begins. Warmup helps stabilize performance by allowing JIT compilation and caching to occur.',
271
- ),
272
- })
273
- .strict()
274
- .describe(
275
- 'ModestBench configuration for controlling benchmark discovery, execution, and reporting',
276
- )
277
- .meta({
278
- title: 'ModestBench Configuration',
279
- });
141
+ const transformBudgetValues = (
142
+ budget: z.infer<typeof budgetInputSchema>,
143
+ ): Budget => {
144
+ return {
145
+ // Build absolute budget object if present
146
+ absolute: budget.absolute
147
+ ? {
148
+ maxP99:
149
+ budget.absolute.maxP99 !== undefined
150
+ ? typeof budget.absolute.maxP99 === 'string'
151
+ ? parseTimeString(budget.absolute.maxP99)
152
+ : budget.absolute.maxP99
153
+ : undefined,
154
+ maxTime:
155
+ budget.absolute.maxTime !== undefined
156
+ ? typeof budget.absolute.maxTime === 'string'
157
+ ? parseTimeString(budget.absolute.maxTime)
158
+ : budget.absolute.maxTime
159
+ : undefined,
160
+ minOpsPerSec: budget.absolute.minOpsPerSec,
161
+ }
162
+ : undefined,
163
+ // Build relative budget object if present
164
+ relative: budget.relative
165
+ ? {
166
+ maxRegression:
167
+ budget.relative.maxRegression !== undefined
168
+ ? typeof budget.relative.maxRegression === 'string'
169
+ ? parsePercentageString(budget.relative.maxRegression)
170
+ : budget.relative.maxRegression
171
+ : undefined,
172
+ }
173
+ : undefined,
174
+ };
175
+ };
280
176
 
281
177
  /**
282
- * Validate a partial configuration object
178
+ * Budget schema with transform for string-to-number conversion
283
179
  *
284
- * This is used for validating configuration from files or CLI args before
285
- * merging with defaults.
180
+ * Input: Budget with string values like "10ms" or "10%" Output: Budget with
181
+ * numeric values only
286
182
  */
287
- export const partialModestBenchConfigSchema: z.ZodType<
288
- Partial<ModestBenchConfig>
289
- > = modestBenchConfigSchema.partial();
183
+ const budgetSchema = budgetInputSchema.transform(transformBudgetValues);
290
184
 
291
185
  /**
292
- * Input budget type (before transformation)
186
+ * Input schema for budgets (nested file → suite → task → budget structure)
187
+ * without transforms - used for JSON Schema generation.
293
188
  */
294
- interface BudgetInput {
295
- absolute?: {
296
- maxP99?: number | string;
297
- maxTime?: number | string;
298
- minOpsPerSec?: number;
299
- };
300
- relative?: {
301
- maxRegression?: number | string;
302
- };
303
- }
189
+ const budgetsRawInputSchema = z.record(
190
+ z.string(),
191
+ z.record(z.string(), z.record(z.string(), budgetInputSchema)),
192
+ );
304
193
 
305
194
  /**
306
- * Output budget type (after transformation)
195
+ * Input schema for budgets with individual budget transforms applied.
196
+ *
197
+ * Used to validate the human-readable nested format from config files.
307
198
  */
308
- interface BudgetOutput {
309
- absolute?: {
310
- maxP99?: number;
311
- maxTime?: number;
312
- minOpsPerSec?: number;
313
- };
314
- relative?: {
315
- maxRegression?: number;
316
- };
317
- }
199
+ const budgetsInputSchema = z.record(
200
+ z.string(),
201
+ z.record(z.string(), z.record(z.string(), budgetSchema)),
202
+ );
318
203
 
319
204
  /**
320
- * Transform budget values (parse time/percentage strings)
205
+ * Check if a suite or task name is a wildcard
206
+ *
207
+ * @param name - The suite or task name
208
+ * @returns True if the name is a wildcard (`*`)
321
209
  */
322
- const transformBudgetValues = (budget: BudgetInput): BudgetOutput => {
323
- const transformed: BudgetOutput = {};
210
+ const isWildcard = (name: string): boolean => name === '*';
324
211
 
325
- if (budget.absolute) {
326
- transformed.absolute = {};
212
+ /**
213
+ * Check if a budget entry contains any wildcards or glob patterns
214
+ *
215
+ * @param file - File pattern
216
+ * @param suite - Suite name or wildcard
217
+ * @param task - Task name or wildcard
218
+ * @returns True if any part contains wildcards
219
+ */
220
+ const hasWildcards = (file: string, suite: string, task: string): boolean => {
221
+ return isGlobPattern(file) || isWildcard(suite) || isWildcard(task);
222
+ };
327
223
 
328
- // Copy minOpsPerSec as-is (already a number)
329
- if (budget.absolute.minOpsPerSec !== undefined) {
330
- transformed.absolute.minOpsPerSec = budget.absolute.minOpsPerSec;
331
- }
224
+ /**
225
+ * Transform nested budget structure to ResolvedBudgets with exact matches and
226
+ * patterns separated
227
+ *
228
+ * @param nested - Nested budgets structure (file → suite → task → budget)
229
+ * @returns ResolvedBudgets with exact matches and wildcard patterns
230
+ */
231
+ const flattenBudgets = (
232
+ nested: z.infer<typeof budgetsInputSchema>,
233
+ ): ResolvedBudgets => {
234
+ const exact: Record<string, Budget> = {};
235
+ const patterns: BudgetPattern[] = [];
332
236
 
333
- // Parse time strings
334
- if (budget.absolute.maxTime !== undefined) {
335
- transformed.absolute.maxTime =
336
- typeof budget.absolute.maxTime === 'string'
337
- ? parseTimeString(budget.absolute.maxTime)
338
- : budget.absolute.maxTime;
339
- }
340
- if (budget.absolute.maxP99 !== undefined) {
341
- transformed.absolute.maxP99 =
342
- typeof budget.absolute.maxP99 === 'string'
343
- ? parseTimeString(budget.absolute.maxP99)
344
- : budget.absolute.maxP99;
237
+ for (const [file, suites] of Object.entries(nested)) {
238
+ for (const [suite, tasks] of Object.entries(suites)) {
239
+ for (const [task, budget] of Object.entries(tasks)) {
240
+ if (hasWildcards(file, suite, task)) {
241
+ // This is a pattern budget
242
+ patterns.push(createBudgetPattern(file, suite, task, budget));
243
+ } else {
244
+ // This is an exact match
245
+ const taskId = `${file}/${suite}/${task}`;
246
+ exact[taskId] = budget;
247
+ }
248
+ }
345
249
  }
346
250
  }
347
251
 
348
- if (budget.relative) {
349
- transformed.relative = {};
252
+ // Sort patterns by specificity descending for consistent iteration order
253
+ patterns.sort((a, b) => b.specificity - a.specificity);
350
254
 
351
- // Parse percentage strings
352
- if (budget.relative.maxRegression !== undefined) {
353
- transformed.relative.maxRegression =
354
- typeof budget.relative.maxRegression === 'string'
355
- ? parsePercentageString(budget.relative.maxRegression)
356
- : budget.relative.maxRegression;
357
- }
358
- }
255
+ return { exact, patterns };
256
+ };
257
+
258
+ /**
259
+ * Budgets schema with transform for nested-to-ResolvedBudgets conversion
260
+ *
261
+ * Input: { [file]: { [suite]: { [task]: Budget } } } Output: ResolvedBudgets {
262
+ * exact: { [taskId]: Budget }, patterns: BudgetPattern[] }
263
+ */
264
+ const budgetsSchema = budgetsInputSchema.transform(flattenBudgets);
359
265
 
360
- return transformed;
266
+ /**
267
+ * Shared configuration properties (everything except budgets)
268
+ *
269
+ * These properties are identical between the runtime schema (with transforms)
270
+ * and the JSON Schema generation schema (without transforms).
271
+ */
272
+ const baseConfigProperties = {
273
+ $schema: z
274
+ .string()
275
+ .optional()
276
+ .describe(
277
+ 'JSON Schema reference for IDE support (not used by ModestBench)',
278
+ ),
279
+ bail: z.boolean().describe('Stop benchmark execution on first failure'),
280
+ baseline: z
281
+ .string()
282
+ .optional()
283
+ .describe(
284
+ 'Name of baseline to use for relative budget comparisons. Must match a saved baseline name.',
285
+ ),
286
+ budgetMode: z
287
+ .enum(['fail', 'warn', 'report'])
288
+ .optional()
289
+ .describe(
290
+ 'How to handle budget violations: "fail" exits with error (default), "warn" shows warnings, "report" includes in output without failing',
291
+ ),
292
+ exclude: z
293
+ .array(z.string())
294
+ .describe(
295
+ 'Glob patterns to exclude from benchmark file discovery (e.g., "node_modules/**", ".git/**")',
296
+ ),
297
+ excludeTags: z
298
+ .array(z.string())
299
+ .describe(
300
+ 'Tags to exclude from benchmark execution. Benchmarks matching any of these tags will be skipped.',
301
+ ),
302
+ iterations: z
303
+ .number()
304
+ .int()
305
+ .positive()
306
+ .describe(
307
+ 'Default number of iterations to run for each benchmark task. Higher values provide more accurate statistics but take longer to execute.',
308
+ ),
309
+ limitBy: z
310
+ .enum(['time', 'iterations', 'any', 'all'])
311
+ .describe(
312
+ 'How to limit benchmark execution: "time" stops after time limit, "iterations" stops after iteration count, "any" stops at whichever comes first, "all" runs until both limits are reached',
313
+ ),
314
+ metadata: z
315
+ .record(z.string(), z.unknown())
316
+ .describe(
317
+ 'Custom metadata to attach to benchmark runs. Can include project name, version, environment details, etc.',
318
+ ),
319
+ outputDir: z
320
+ .string()
321
+ .min(1)
322
+ .optional()
323
+ .describe(
324
+ 'Directory path where benchmark results and reports will be written. If not specified, data reporters will write to stdout.',
325
+ ),
326
+ pattern: z
327
+ .union([z.string().min(1), z.array(z.string().min(1))])
328
+ .describe(
329
+ `Glob pattern(s) for discovering benchmark files. Can be a single pattern string or array of patterns (e.g., "**/*${BENCHMARK_FILE_PATTERN}")`,
330
+ ),
331
+ profile: z
332
+ .object({
333
+ exclude: z
334
+ .array(z.string())
335
+ .optional()
336
+ .describe('Glob patterns to exclude from profiling results'),
337
+ focus: z
338
+ .array(z.string())
339
+ .optional()
340
+ .describe(
341
+ 'Glob patterns to focus on in profiling results. If specified, only matching files will be shown',
342
+ ),
343
+ minCallCount: z
344
+ .number()
345
+ .int()
346
+ .nonnegative()
347
+ .optional()
348
+ .describe(
349
+ 'Minimum number of times a function must be called to be included in results',
350
+ ),
351
+ minExecutionPercent: z
352
+ .number()
353
+ .nonnegative()
354
+ .max(100)
355
+ .default(1.0)
356
+ .describe(
357
+ 'Minimum execution percentage threshold for including functions in results',
358
+ ),
359
+ outputFile: z
360
+ .string()
361
+ .optional()
362
+ .describe('Path to write profile report to file'),
363
+ smartDetection: z
364
+ .boolean()
365
+ .default(true)
366
+ .describe(
367
+ 'Automatically detect and focus on user code, excluding node_modules and Node.js internals',
368
+ ),
369
+ topN: z
370
+ .number()
371
+ .int()
372
+ .positive()
373
+ .default(25)
374
+ .describe('Maximum number of top functions to show in results'),
375
+ })
376
+ .optional()
377
+ .describe(
378
+ 'Configuration for profile command to identify benchmark candidates',
379
+ ),
380
+ quiet: z
381
+ .boolean()
382
+ .describe(
383
+ 'Run in quiet mode with minimal console output (only errors and final results)',
384
+ ),
385
+ reporterConfig: reporterConfigSchema,
386
+ reporters: z
387
+ .array(z.string())
388
+ .min(1)
389
+ .describe(
390
+ 'List of reporter names to use for output. Available reporters: "human", "json", "csv"',
391
+ ),
392
+ tags: z
393
+ .array(z.string())
394
+ .describe(
395
+ 'Tags to filter which benchmarks to run. If empty, all benchmarks are included. Only benchmarks with matching tags will execute.',
396
+ ),
397
+ thresholds: thresholdConfigSchema,
398
+ time: z
399
+ .number()
400
+ .int()
401
+ .positive()
402
+ .describe(
403
+ 'Maximum time to spend on each benchmark task in milliseconds. Tasks will run at least until this duration or iteration count is reached, depending on limitBy setting.',
404
+ ),
405
+ timeout: z
406
+ .number()
407
+ .int()
408
+ .positive()
409
+ .describe(
410
+ 'Timeout for individual benchmark tasks in milliseconds. Tasks exceeding this duration will be terminated and marked as failed.',
411
+ ),
412
+ verbose: z
413
+ .boolean()
414
+ .describe(
415
+ 'Enable verbose output. Provides more detailed console output including progress, intermediate results, and diagnostic information',
416
+ ),
417
+ warmup: z
418
+ .number()
419
+ .int()
420
+ .nonnegative()
421
+ .describe(
422
+ 'Number of warmup iterations to run before measurement begins. Warmup helps stabilize performance by allowing JIT compilation and caching to occur.',
423
+ ),
361
424
  };
362
425
 
426
+ /** Description for the budgets field */
427
+ const budgetsDescription =
428
+ 'Performance budgets organized by file → suite → task. Budgets define acceptable performance thresholds. Supports wildcards (* for suite/task, glob patterns for files).';
429
+
430
+ /** Description and metadata for the config schema */
431
+ const configSchemaDescription =
432
+ 'ModestBench configuration for controlling benchmark discovery, execution, and reporting';
433
+ const configSchemaMeta = { title: 'ModestBench Configuration' };
434
+
363
435
  /**
364
- * Transform nested budget structure to flat TaskId → Budget mapping Also parses
365
- * time and percentage strings
436
+ * Schema for the main ModestBench configuration
437
+ *
438
+ * This is the complete configuration schema used for validating benchmark
439
+ * configuration from all sources (files, CLI args, defaults).
366
440
  *
367
- * @internal
441
+ * The budgets field uses transforms to:
442
+ *
443
+ * 1. Parse string values like "10ms" or "10%" to numbers
444
+ * 2. Separate exact matches from wildcard patterns into ResolvedBudgets
368
445
  */
369
- const transformBudgets = (
370
- nested: Record<string, Record<string, Record<string, unknown>>> | undefined,
371
- ): Record<string, unknown> | undefined => {
372
- if (!nested) {
373
- return undefined;
374
- }
446
+ const modestBenchConfigSchema = z
447
+ .object({
448
+ ...baseConfigProperties,
449
+ budgets: budgetsSchema.optional().describe(budgetsDescription),
450
+ })
451
+ .strict()
452
+ .describe(configSchemaDescription)
453
+ .meta(configSchemaMeta);
375
454
 
376
- const flat: Record<string, unknown> = {};
455
+ /**
456
+ * Input schema for configuration (without transforms)
457
+ *
458
+ * This schema is used for JSON Schema generation. It validates the same input
459
+ * structure but without transforms, which can't be represented in JSON Schema
460
+ * format.
461
+ */
462
+ const modestBenchConfigInputSchema = z
463
+ .object({
464
+ ...baseConfigProperties,
465
+ budgets: budgetsRawInputSchema.optional().describe(budgetsDescription),
466
+ })
467
+ .strict()
468
+ .describe(configSchemaDescription)
469
+ .meta(configSchemaMeta);
377
470
 
378
- for (const [file, suites] of Object.entries(nested)) {
379
- for (const [suite, tasks] of Object.entries(suites)) {
380
- for (const [task, budget] of Object.entries(tasks)) {
381
- const taskId = `${file}/${suite}/${task}`;
382
- // Transform budget values (parse strings)
383
- flat[taskId] = transformBudgetValues(budget as BudgetInput);
384
- }
385
- }
386
- }
471
+ /**
472
+ * Partial input schema for JSON Schema generation
473
+ *
474
+ * This is used for generating JSON Schema for IDE autocomplete in config files.
475
+ */
476
+ export const partialModestBenchConfigInputSchema =
477
+ modestBenchConfigInputSchema.partial();
387
478
 
388
- return flat;
389
- };
479
+ /**
480
+ * Validate a partial configuration object
481
+ *
482
+ * This is used for validating configuration from files or CLI args before
483
+ * merging with defaults.
484
+ */
485
+ export const partialModestBenchConfigSchema: z.ZodType<
486
+ Partial<ModestBenchConfig>
487
+ > = modestBenchConfigSchema.partial();
390
488
 
391
489
  /**
392
- * Safely parse and validate a partial configuration object with budget
393
- * transformation
490
+ * Safely parse and validate a partial configuration object
394
491
  *
395
492
  * @param config - The configuration object to validate
396
493
  * @returns A result object with either success: true and data, or success:
397
494
  * false and error
398
495
  */
399
496
  export const safeParsePartialConfig = (config: unknown) => {
400
- const result = partialModestBenchConfigSchema.safeParse(config);
401
-
402
- // Transform nested budgets to flat structure after validation
403
- if (result.success && result.data.budgets) {
404
- return {
405
- ...result,
406
- data: {
407
- ...result.data,
408
- budgets: transformBudgets(
409
- result.data.budgets as Record<
410
- string,
411
- Record<string, Record<string, unknown>>
412
- >,
413
- ),
414
- },
415
- };
416
- }
417
-
418
- return result;
497
+ return partialModestBenchConfigSchema.safeParse(config);
419
498
  };
420
499
 
421
500
  /**
@@ -426,23 +505,21 @@ export const safeParsePartialConfig = (config: unknown) => {
426
505
  * false and error
427
506
  */
428
507
  export const safeParseConfig = (config: unknown) => {
429
- const result = modestBenchConfigSchema.safeParse(config);
508
+ return modestBenchConfigSchema.safeParse(config);
509
+ };
430
510
 
431
- // Transform nested budgets to flat structure after validation
432
- if (result.success && result.data.budgets) {
433
- return {
434
- ...result,
435
- data: {
436
- ...result.data,
437
- budgets: transformBudgets(
438
- result.data.budgets as Record<
439
- string,
440
- Record<string, Record<string, unknown>>
441
- >,
442
- ),
443
- },
444
- };
445
- }
511
+ /**
512
+ * Configuration type after parsing (output type)
513
+ *
514
+ * This is the type you get after parsing a config file - budgets are
515
+ * transformed to ResolvedBudgets and string values are converted to numbers.
516
+ */
517
+ export type ModestBenchConfig = z.infer<typeof modestBenchConfigSchema>;
446
518
 
447
- return result;
448
- };
519
+ /**
520
+ * Configuration type before parsing (input type)
521
+ *
522
+ * This is the type of config files written by users - budgets are nested (file
523
+ * → suite → task) and values can be strings like "10ms" or "10%".
524
+ */
525
+ export type ModestBenchConfigInput = z.input<typeof modestBenchConfigSchema>;