modestbench 0.8.0 → 0.9.1

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 (171) hide show
  1. package/CHANGELOG.md +22 -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/baseline.cjs +8 -8
  8. package/dist/cli/commands/baseline.cjs.map +1 -1
  9. package/dist/cli/commands/baseline.js +8 -8
  10. package/dist/cli/commands/baseline.js.map +1 -1
  11. package/dist/cli/commands/init.cjs +2 -2
  12. package/dist/cli/commands/init.cjs.map +1 -1
  13. package/dist/cli/commands/init.js +2 -2
  14. package/dist/cli/commands/init.js.map +1 -1
  15. package/dist/cli/commands/run.cjs +12 -8
  16. package/dist/cli/commands/run.cjs.map +1 -1
  17. package/dist/cli/commands/run.d.cts +1 -13
  18. package/dist/cli/commands/run.d.cts.map +1 -1
  19. package/dist/cli/commands/run.d.ts +1 -13
  20. package/dist/cli/commands/run.d.ts.map +1 -1
  21. package/dist/cli/commands/run.js +11 -7
  22. package/dist/cli/commands/run.js.map +1 -1
  23. package/dist/cli/commands/test.cjs +1 -1
  24. package/dist/cli/commands/test.cjs.map +1 -1
  25. package/dist/cli/commands/test.js +1 -1
  26. package/dist/cli/commands/test.js.map +1 -1
  27. package/dist/cli/index.cjs +655 -870
  28. package/dist/cli/index.cjs.map +1 -1
  29. package/dist/cli/index.d.cts +27 -17
  30. package/dist/cli/index.d.cts.map +1 -1
  31. package/dist/cli/index.d.ts +27 -17
  32. package/dist/cli/index.d.ts.map +1 -1
  33. package/dist/cli/index.js +657 -869
  34. package/dist/cli/index.js.map +1 -1
  35. package/dist/{core → config}/benchmark-schema.cjs +1 -1
  36. package/dist/config/benchmark-schema.cjs.map +1 -0
  37. package/dist/config/benchmark-schema.d.cts +913 -0
  38. package/dist/config/benchmark-schema.d.cts.map +1 -0
  39. package/dist/config/benchmark-schema.d.ts +913 -0
  40. package/dist/config/benchmark-schema.d.ts.map +1 -0
  41. package/dist/{core → config}/benchmark-schema.js +1 -1
  42. package/dist/config/benchmark-schema.js.map +1 -0
  43. package/dist/config/budget-schema.cjs +1 -1
  44. package/dist/config/budget-schema.cjs.map +1 -1
  45. package/dist/config/budget-schema.js +1 -1
  46. package/dist/config/budget-schema.js.map +1 -1
  47. package/dist/config/schema.cjs +188 -105
  48. package/dist/config/schema.cjs.map +1 -1
  49. package/dist/config/schema.d.cts +208 -80
  50. package/dist/config/schema.d.cts.map +1 -1
  51. package/dist/config/schema.d.ts +208 -80
  52. package/dist/config/schema.d.ts.map +1 -1
  53. package/dist/config/schema.js +187 -104
  54. package/dist/config/schema.js.map +1 -1
  55. package/dist/core/engine.cjs +50 -45
  56. package/dist/core/engine.cjs.map +1 -1
  57. package/dist/core/engine.d.cts.map +1 -1
  58. package/dist/core/engine.d.ts.map +1 -1
  59. package/dist/core/engine.js +50 -45
  60. package/dist/core/engine.js.map +1 -1
  61. package/dist/core/engines/accurate-engine.cjs +1 -1
  62. package/dist/core/engines/accurate-engine.cjs.map +1 -1
  63. package/dist/core/engines/accurate-engine.d.cts.map +1 -1
  64. package/dist/core/engines/accurate-engine.d.ts.map +1 -1
  65. package/dist/core/engines/accurate-engine.js +1 -1
  66. package/dist/core/engines/accurate-engine.js.map +1 -1
  67. package/dist/core/output-path-resolver.cjs +15 -1
  68. package/dist/core/output-path-resolver.cjs.map +1 -1
  69. package/dist/core/output-path-resolver.d.cts +8 -0
  70. package/dist/core/output-path-resolver.d.cts.map +1 -1
  71. package/dist/core/output-path-resolver.d.ts +8 -0
  72. package/dist/core/output-path-resolver.d.ts.map +1 -1
  73. package/dist/core/output-path-resolver.js +13 -0
  74. package/dist/core/output-path-resolver.js.map +1 -1
  75. package/dist/formatters/history/compare.cjs +6 -6
  76. package/dist/formatters/history/compare.cjs.map +1 -1
  77. package/dist/formatters/history/compare.js +6 -6
  78. package/dist/formatters/history/compare.js.map +1 -1
  79. package/dist/formatters/history/show.cjs +2 -2
  80. package/dist/formatters/history/show.cjs.map +1 -1
  81. package/dist/formatters/history/show.js +2 -2
  82. package/dist/formatters/history/show.js.map +1 -1
  83. package/dist/formatters/history/trends.cjs +1 -1
  84. package/dist/formatters/history/trends.cjs.map +1 -1
  85. package/dist/formatters/history/trends.js +1 -1
  86. package/dist/formatters/history/trends.js.map +1 -1
  87. package/dist/reporters/human.cjs +3 -3
  88. package/dist/reporters/human.cjs.map +1 -1
  89. package/dist/reporters/human.d.cts.map +1 -1
  90. package/dist/reporters/human.d.ts.map +1 -1
  91. package/dist/reporters/human.js +3 -3
  92. package/dist/reporters/human.js.map +1 -1
  93. package/dist/reporters/json.cjs +1 -1
  94. package/dist/reporters/json.cjs.map +1 -1
  95. package/dist/reporters/json.js +1 -1
  96. package/dist/reporters/json.js.map +1 -1
  97. package/dist/reporters/nyan.cjs +1 -1
  98. package/dist/reporters/nyan.cjs.map +1 -1
  99. package/dist/reporters/nyan.js +1 -1
  100. package/dist/reporters/nyan.js.map +1 -1
  101. package/dist/schema/modestbench-config.schema.json +94 -87
  102. package/dist/services/budget-evaluator.cjs +10 -8
  103. package/dist/services/budget-evaluator.cjs.map +1 -1
  104. package/dist/services/budget-evaluator.d.cts +2 -2
  105. package/dist/services/budget-evaluator.d.cts.map +1 -1
  106. package/dist/services/budget-evaluator.d.ts +2 -2
  107. package/dist/services/budget-evaluator.d.ts.map +1 -1
  108. package/dist/services/budget-evaluator.js +10 -8
  109. package/dist/services/budget-evaluator.js.map +1 -1
  110. package/dist/services/budget-resolver.cjs +214 -0
  111. package/dist/services/budget-resolver.cjs.map +1 -0
  112. package/dist/services/budget-resolver.d.cts +98 -0
  113. package/dist/services/budget-resolver.d.cts.map +1 -0
  114. package/dist/services/budget-resolver.d.ts +98 -0
  115. package/dist/services/budget-resolver.d.ts.map +1 -0
  116. package/dist/services/budget-resolver.js +203 -0
  117. package/dist/services/budget-resolver.js.map +1 -0
  118. package/dist/services/config-manager.cjs +2 -2
  119. package/dist/services/config-manager.cjs.map +1 -1
  120. package/dist/services/config-manager.js +2 -2
  121. package/dist/services/config-manager.js.map +1 -1
  122. package/dist/services/file-loader.cjs +1 -1
  123. package/dist/services/file-loader.cjs.map +1 -1
  124. package/dist/services/file-loader.js +1 -1
  125. package/dist/services/file-loader.js.map +1 -1
  126. package/dist/services/reporter-registry.cjs +8 -8
  127. package/dist/services/reporter-registry.cjs.map +1 -1
  128. package/dist/services/reporter-registry.js +8 -8
  129. package/dist/services/reporter-registry.js.map +1 -1
  130. package/dist/types/budgets.d.cts +31 -0
  131. package/dist/types/budgets.d.cts.map +1 -1
  132. package/dist/types/budgets.d.ts +31 -0
  133. package/dist/types/budgets.d.ts.map +1 -1
  134. package/dist/types/core.cjs.map +1 -1
  135. package/dist/types/core.d.cts +28 -75
  136. package/dist/types/core.d.cts.map +1 -1
  137. package/dist/types/core.d.ts +28 -75
  138. package/dist/types/core.d.ts.map +1 -1
  139. package/dist/types/core.js.map +1 -1
  140. package/package.json +24 -15
  141. package/src/adapters/types.ts +1 -1
  142. package/src/cli/commands/baseline.ts +8 -8
  143. package/src/cli/commands/init.ts +2 -2
  144. package/src/cli/commands/run.ts +18 -6
  145. package/src/cli/commands/test.ts +1 -1
  146. package/src/cli/index.ts +806 -942
  147. package/src/{core → config}/benchmark-schema.ts +1 -1
  148. package/src/config/budget-schema.ts +1 -1
  149. package/src/config/schema.ts +379 -302
  150. package/src/core/engine.ts +74 -69
  151. package/src/core/engines/accurate-engine.ts +1 -1
  152. package/src/core/output-path-resolver.ts +14 -0
  153. package/src/formatters/history/compare.ts +6 -6
  154. package/src/formatters/history/show.ts +2 -2
  155. package/src/formatters/history/trends.ts +1 -1
  156. package/src/reporters/human.ts +5 -3
  157. package/src/reporters/json.ts +1 -1
  158. package/src/reporters/nyan.ts +1 -1
  159. package/src/services/budget-evaluator.ts +15 -11
  160. package/src/services/budget-resolver.ts +254 -0
  161. package/src/services/config-manager.ts +2 -2
  162. package/src/services/file-loader.ts +1 -1
  163. package/src/services/reporter-registry.ts +8 -8
  164. package/src/types/budgets.ts +38 -0
  165. package/src/types/core.ts +64 -99
  166. package/dist/core/benchmark-schema.cjs.map +0 -1
  167. package/dist/core/benchmark-schema.d.cts +0 -139
  168. package/dist/core/benchmark-schema.d.cts.map +0 -1
  169. package/dist/core/benchmark-schema.d.ts +0 -139
  170. package/dist/core/benchmark-schema.d.ts.map +0 -1
  171. package/dist/core/benchmark-schema.js.map +0 -1
@@ -16,7 +16,6 @@ import type {
16
16
  BenchmarkRun,
17
17
  BenchmarkSuite,
18
18
  BenchmarkTask,
19
- Budget,
20
19
  BudgetSummary,
21
20
  CiInfo,
22
21
  ConfigurationManager,
@@ -354,93 +353,99 @@ export abstract class ModestBenchEngine implements BenchmarkEngine {
354
353
  // Evaluate budgets if configured
355
354
  let budgetSummary: BudgetSummary | undefined;
356
355
 
357
- if (config.budgets && Object.keys(config.budgets).length > 0) {
358
- const evaluator = new BudgetEvaluator();
359
- const baselineStorage = new BaselineStorageService(process.cwd());
360
-
361
- // Collect task results
362
- const taskResults = new Map<TaskId, TaskResult>();
363
-
364
- for (const file of fileResults) {
365
- for (const suite of file.suites) {
366
- for (const task of suite.tasks) {
367
- if (!task.error) {
368
- // file.filePath is already relative to cwd
369
- const taskId = createTaskId(
370
- file.filePath,
371
- suite.name,
372
- task.name,
373
- );
374
- taskResults.set(taskId, task);
356
+ if (config.budgets) {
357
+ const budgets = config.budgets;
358
+ const hasBudgets =
359
+ Object.keys(budgets.exact).length > 0 || budgets.patterns.length > 0;
360
+
361
+ if (hasBudgets) {
362
+ const evaluator = new BudgetEvaluator();
363
+ const baselineStorage = new BaselineStorageService(process.cwd());
364
+
365
+ // Collect task results
366
+ const taskResults = new Map<TaskId, TaskResult>();
367
+
368
+ for (const file of fileResults) {
369
+ for (const suite of file.suites) {
370
+ for (const task of suite.tasks) {
371
+ if (!task.error) {
372
+ // file.filePath is already relative to cwd
373
+ const taskId = createTaskId(
374
+ file.filePath,
375
+ suite.name,
376
+ task.name,
377
+ );
378
+ taskResults.set(taskId, task);
379
+ }
375
380
  }
376
381
  }
377
382
  }
378
- }
379
383
 
380
- // Load baseline data if needed for relative budgets
381
- let baselineData: Map<TaskId, BaselineSummaryData> | undefined;
384
+ // Load baseline data if needed for relative budgets
385
+ let baselineData: Map<TaskId, BaselineSummaryData> | undefined;
382
386
 
383
- // Check if any budgets use relative thresholds
384
- const hasRelativeBudgets = Object.values(config.budgets).some(
385
- (budget) => (budget as Budget).relative,
386
- );
387
+ // Check if any budgets use relative thresholds
388
+ const hasRelativeBudgets =
389
+ Object.values(budgets.exact).some((budget) => budget.relative) ||
390
+ budgets.patterns.some((pattern) => pattern.budget.relative);
387
391
 
388
- if (hasRelativeBudgets) {
389
- const baselineName =
390
- config.baseline || (await baselineStorage.getDefault());
392
+ if (hasRelativeBudgets) {
393
+ const baselineName =
394
+ config.baseline || (await baselineStorage.getDefault());
391
395
 
392
- if (baselineName) {
393
- const baseline = await baselineStorage.getBaseline(baselineName);
396
+ if (baselineName) {
397
+ const baseline = await baselineStorage.getBaseline(baselineName);
394
398
 
395
- if (baseline) {
396
- // Cast keys to TaskId since they come from validated baseline storage
397
- baselineData = new Map(
398
- Object.entries(baseline.summary) as [
399
- TaskId,
400
- BaselineSummaryData,
401
- ][],
402
- );
399
+ if (baseline) {
400
+ // Cast keys to TaskId since they come from validated baseline storage
401
+ baselineData = new Map(
402
+ Object.entries(baseline.summary) as [
403
+ TaskId,
404
+ BaselineSummaryData,
405
+ ][],
406
+ );
407
+ } else {
408
+ console.warn(
409
+ `Warning: Baseline "${baselineName}" not found. Relative budgets will be skipped.`,
410
+ );
411
+ }
403
412
  } else {
404
413
  console.warn(
405
- `Warning: Baseline "${baselineName}" not found. Relative budgets will be skipped.`,
414
+ 'Warning: Relative budgets configured but no baseline specified. Relative budgets will be skipped.',
406
415
  );
407
416
  }
408
- } else {
409
- console.warn(
410
- 'Warning: Relative budgets configured but no baseline specified. Relative budgets will be skipped.',
411
- );
412
417
  }
413
- }
414
418
 
415
- // Evaluate budgets
416
- budgetSummary = evaluator.evaluateRun(
417
- config.budgets as Record<string, Budget>,
418
- taskResults,
419
- baselineData,
420
- );
419
+ // Evaluate budgets
420
+ budgetSummary = evaluator.evaluateRun(
421
+ budgets,
422
+ taskResults,
423
+ baselineData,
424
+ );
421
425
 
422
- // Notify reporters of budget results
423
- for (const reporter of reporters) {
424
- if (reporter.onBudgetResult) {
425
- await reporter.onBudgetResult(budgetSummary);
426
+ // Notify reporters of budget results
427
+ for (const reporter of reporters) {
428
+ if (reporter.onBudgetResult) {
429
+ await reporter.onBudgetResult(budgetSummary);
430
+ }
426
431
  }
427
- }
428
432
 
429
- // Handle budget failures based on budgetMode
430
- if (budgetSummary.failed > 0) {
431
- const mode = config.budgetMode || 'fail';
433
+ // Handle budget failures based on budgetMode
434
+ if (budgetSummary.failed > 0) {
435
+ const mode = config.budgetMode || 'fail';
432
436
 
433
- if (mode === 'fail') {
434
- throw new BudgetExceededError(
435
- `${budgetSummary.failed} of ${budgetSummary.total} budget(s) exceeded`,
436
- budgetSummary,
437
- );
438
- } else if (mode === 'warn') {
439
- console.warn(
440
- `Warning: ${budgetSummary.failed} of ${budgetSummary.total} budget(s) exceeded`,
441
- );
437
+ if (mode === 'fail') {
438
+ throw new BudgetExceededError(
439
+ `${budgetSummary.failed} of ${budgetSummary.total} budget(s) exceeded`,
440
+ budgetSummary,
441
+ );
442
+ } else if (mode === 'warn') {
443
+ console.warn(
444
+ `Warning: ${budgetSummary.failed} of ${budgetSummary.total} budget(s) exceeded`,
445
+ );
446
+ }
447
+ // mode === 'report': just include in output, don't fail
442
448
  }
443
- // mode === 'report': just include in output, don't fail
444
449
  }
445
450
  }
446
451
 
@@ -38,7 +38,7 @@ export class AccurateEngine extends ModestBenchEngine {
38
38
  * Maximum iterations per round to prevent overwhelming Node.js test runner
39
39
  * and excessive memory usage
40
40
  */
41
- private static readonly MAX_ITERATIONS_PER_ROUND = 10000;
41
+ private static readonly MAX_ITERATIONS_PER_ROUND = 10_000;
42
42
 
43
43
  /**
44
44
  * Maximum iterations per round for async functions (much lower due to
@@ -1,5 +1,19 @@
1
1
  import { extname, isAbsolute, join, resolve } from 'node:path';
2
2
 
3
+ /**
4
+ * Generates a timestamped filename for benchmark output files.
5
+ *
6
+ * @param extension - File extension without the dot (e.g., 'json', 'csv')
7
+ * @returns Filename in format `benchmarks-YYYY-MM-DD-HH-MM-SS.{extension}` (UTC
8
+ * time)
9
+ */
10
+ export const generateTimestampedFilename = (extension: string): string => {
11
+ const now = new Date();
12
+ const pad = (n: number) => n.toString().padStart(2, '0');
13
+ const timestamp = `${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())}-${pad(now.getUTCHours())}-${pad(now.getUTCMinutes())}-${pad(now.getUTCSeconds())}`;
14
+ return `benchmarks-${timestamp}.${extension}`;
15
+ };
16
+
3
17
  /**
4
18
  * Resolves the final output path for a reporter
5
19
  *
@@ -51,8 +51,8 @@ export class HistoryCompareFormatter implements HistoryFormatter<CompareResult>
51
51
  lines.push('');
52
52
 
53
53
  for (const comparison of data.tasksInBoth) {
54
- const mean1 = comparison.run1!.mean / 1000000; // Convert to ms
55
- const mean2 = comparison.run2!.mean / 1000000;
54
+ const mean1 = comparison.run1!.mean / 1_000_000; // Convert to ms
55
+ const mean2 = comparison.run2!.mean / 1_000_000;
56
56
  const changeSign = comparison.percentChange >= 0 ? '+' : '';
57
57
  const changeStr = `${changeSign}${comparison.percentChange.toFixed(1)}%`;
58
58
 
@@ -73,8 +73,8 @@ export class HistoryCompareFormatter implements HistoryFormatter<CompareResult>
73
73
  );
74
74
 
75
75
  // Min - highlight higher number
76
- const min1 = comparison.run1!.min / 1000000;
77
- const min2 = comparison.run2!.min / 1000000;
76
+ const min1 = comparison.run1!.min / 1_000_000;
77
+ const min2 = comparison.run2!.min / 1_000_000;
78
78
  const minHigher = min2 > min1;
79
79
  const min1Str = minHigher
80
80
  ? colorize('magenta', `${min1.toFixed(3)}ms`)
@@ -87,8 +87,8 @@ export class HistoryCompareFormatter implements HistoryFormatter<CompareResult>
87
87
  );
88
88
 
89
89
  // Max - highlight higher number
90
- const max1 = comparison.run1!.max / 1000000;
91
- const max2 = comparison.run2!.max / 1000000;
90
+ const max1 = comparison.run1!.max / 1_000_000;
91
+ const max2 = comparison.run2!.max / 1_000_000;
92
92
  const maxHigher = max2 > max1;
93
93
  const max1Str = maxHigher
94
94
  ? colorize('magenta', `${max1.toFixed(3)}ms`)
@@ -148,8 +148,8 @@ const formatTime = (ns: number): string => {
148
148
  if (ns < 1000) {
149
149
  return `${ns.toFixed(2)}ns`;
150
150
  }
151
- if (ns < 1000000) {
151
+ if (ns < 1_000_000) {
152
152
  return `${(ns / 1000).toFixed(3)}µs`;
153
153
  }
154
- return `${(ns / 1000000).toFixed(3)}ms`;
154
+ return `${(ns / 1_000_000).toFixed(3)}ms`;
155
155
  };
@@ -24,7 +24,7 @@ const NS_PER_MS = 1_000_000;
24
24
  /**
25
25
  * Nanoseconds per microsecond conversion constant
26
26
  */
27
- const NS_PER_US = 1_000;
27
+ const NS_PER_US = 1000;
28
28
 
29
29
  /**
30
30
  * Intelligently format a time range with appropriate precision Displays
@@ -234,7 +234,7 @@ export class HumanReporter extends BaseReporter {
234
234
  );
235
235
  }
236
236
  this.printLine(
237
- `${this.colorize('cyan', ansiChars.approx + ' Duration:')} ${this.colorize('brightWhite', BaseReporter.formatDuration(duration * 1000000))}`,
237
+ `${this.colorize('cyan', ansiChars.approx + ' Duration:')} ${this.colorize('brightWhite', BaseReporter.formatDuration(duration * 1_000_000))}`,
238
238
  );
239
239
  this.printLine();
240
240
 
@@ -463,7 +463,7 @@ export class HumanReporter extends BaseReporter {
463
463
  });
464
464
 
465
465
  const durationStr = BaseReporter.formatDuration(
466
- result.duration * 1000000,
466
+ result.duration * 1_000_000,
467
467
  );
468
468
 
469
469
  // Display suite setup failure
@@ -487,7 +487,9 @@ export class HumanReporter extends BaseReporter {
487
487
  const passed = result.tasks.filter((t) => !t.error && !t.aborted).length;
488
488
  const failed = result.tasks.filter((t) => t.error).length;
489
489
  const aborted = result.tasks.filter((t) => t.aborted).length;
490
- const durationStr = BaseReporter.formatDuration(result.duration * 1000000); // ms to ns
490
+ const durationStr = BaseReporter.formatDuration(
491
+ result.duration * 1_000_000,
492
+ ); // ms to ns
491
493
 
492
494
  // Build summary parts
493
495
  const parts: string[] = [];
@@ -90,7 +90,7 @@ export class JsonReporter extends BaseReporter {
90
90
  super('json', options);
91
91
 
92
92
  this.outputPath = options.outputPath;
93
- this.prettyPrint = options.prettyPrint ?? true;
93
+ this.prettyPrint = options.prettyPrint ?? false;
94
94
  this.includeStatistics = options.includeStatistics ?? true;
95
95
  this.includeMetadata = options.includeMetadata ?? true;
96
96
  }
@@ -345,7 +345,7 @@ export class NyanReporter extends BaseReporter {
345
345
  */
346
346
  private printEpilogue(run: BenchmarkRun): void {
347
347
  const duration = Date.now() - this.startTime;
348
- const durationStr = BaseReporter.formatDuration(duration * 1000000);
348
+ const durationStr = BaseReporter.formatDuration(duration * 1_000_000);
349
349
 
350
350
  console.log();
351
351
  console.log(
@@ -4,10 +4,13 @@ import type {
4
4
  BudgetResult,
5
5
  BudgetSummary,
6
6
  BudgetViolation,
7
+ ResolvedBudgets,
7
8
  TaskId,
8
9
  TaskResult,
9
10
  } from '../types/core.js';
10
11
 
12
+ import { resolveBudget } from './budget-resolver.js';
13
+
11
14
  /**
12
15
  * Service for evaluating performance budgets
13
16
  *
@@ -34,10 +37,10 @@ export class BudgetEvaluator {
34
37
  * Format time in nanoseconds to human-readable string
35
38
  */
36
39
  private static formatTime(this: void, nanoseconds: number): string {
37
- if (nanoseconds < 1_000) {
40
+ if (nanoseconds < 1000) {
38
41
  return `${nanoseconds.toFixed(0)}ns`;
39
42
  } else if (nanoseconds < 1_000_000) {
40
- return `${(nanoseconds / 1_000).toFixed(2)}μs`;
43
+ return `${(nanoseconds / 1000).toFixed(2)}μs`;
41
44
  } else if (nanoseconds < 1_000_000_000) {
42
45
  return `${(nanoseconds / 1_000_000).toFixed(2)}ms`;
43
46
  } else {
@@ -49,30 +52,31 @@ export class BudgetEvaluator {
49
52
  * Evaluate budgets for an entire benchmark run
50
53
  */
51
54
  evaluateRun(
52
- budgets: Record<string, Budget>,
55
+ budgets: ResolvedBudgets,
53
56
  taskResults: Map<TaskId, TaskResult>,
54
57
  baselineData?: Map<TaskId, BaselineSummaryData>,
55
58
  ): BudgetSummary {
56
59
  const results: BudgetResult[] = [];
57
60
 
58
- for (const [taskId, budget] of Object.entries(budgets)) {
59
- const taskResult = taskResults.get(taskId as TaskId);
61
+ for (const [taskId, taskResult] of taskResults) {
62
+ const budget = resolveBudget(taskId, budgets);
60
63
 
61
- // Skip if no result for this task
62
- if (!taskResult) {
64
+ // Skip if no budget matches this task
65
+ if (!budget) {
63
66
  continue;
64
67
  }
65
68
 
66
- // Skip relative budgets if no baseline data
67
- if (budget.relative && !baselineData) {
69
+ // Skip if budget has ONLY relative thresholds and no baseline data
70
+ // (absolute budgets can still be evaluated without baseline)
71
+ if (budget.relative && !budget.absolute && !baselineData) {
68
72
  continue;
69
73
  }
70
74
 
71
75
  const budgetResult = this.evaluateTask(
72
- taskId as TaskId,
76
+ taskId,
73
77
  budget,
74
78
  taskResult,
75
- baselineData?.get(taskId as TaskId),
79
+ baselineData?.get(taskId),
76
80
  );
77
81
 
78
82
  results.push(budgetResult);
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Budget resolution service for matching budgets to tasks
3
+ *
4
+ * This module provides pattern-based budget resolution using:
5
+ *
6
+ * - Minimatch glob patterns for file matching
7
+ * - Simple `*` wildcards for suite/task matching
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+
12
+ import { minimatch } from 'minimatch';
13
+
14
+ import type {
15
+ Budget,
16
+ BudgetPattern,
17
+ ResolvedBudgets,
18
+ TaskId,
19
+ } from '../types/core.js';
20
+
21
+ /**
22
+ * Check if a glob pattern contains wildcards
23
+ *
24
+ * @param pattern - The pattern to check
25
+ * @returns True if the pattern contains glob metacharacters
26
+ */
27
+ export const isGlobPattern = (pattern: string): boolean => {
28
+ return /[*?[\]]/.test(pattern);
29
+ };
30
+
31
+ /**
32
+ * Check if a file path matches a glob pattern
33
+ *
34
+ * @param pattern - Minimatch glob pattern
35
+ * @param filePath - File path to match against
36
+ * @returns True if the pattern matches the file path
37
+ */
38
+ export const matchesFile = (pattern: string, filePath: string): boolean => {
39
+ // Exact match fast path
40
+ if (pattern === filePath) {
41
+ return true;
42
+ }
43
+
44
+ return minimatch(filePath, pattern, { matchBase: true });
45
+ };
46
+
47
+ /**
48
+ * Check if a suite or task name matches a pattern
49
+ *
50
+ * @param pattern - Either an exact name or `*` for wildcard
51
+ * @param value - The value to match against
52
+ * @returns True if the pattern matches the value
53
+ */
54
+ export const matchesSuiteOrTask = (pattern: string, value: string): boolean => {
55
+ return pattern === '*' || pattern === value;
56
+ };
57
+
58
+ /**
59
+ * Calculate specificity score for a budget pattern
60
+ *
61
+ * Higher scores indicate more specific patterns. Scoring:
62
+ *
63
+ * - File: +2 for exact match, +1 for glob with specific parts, +0 for `**\/*`
64
+ * - Suite: +1 for exact match, +0 for `*`
65
+ * - Task: +1 for exact match, +0 for `*`
66
+ *
67
+ * @param pattern - The budget pattern to score
68
+ * @returns Specificity score (0-4)
69
+ */
70
+ export const calculateSpecificity = (
71
+ pattern: Pick<BudgetPattern, 'filePattern' | 'suitePattern' | 'taskPattern'>,
72
+ ): number => {
73
+ let score = 0;
74
+
75
+ // File specificity
76
+ if (!isGlobPattern(pattern.filePattern)) {
77
+ // Exact file match
78
+ score += 2;
79
+ } else if (pattern.filePattern !== '**/*' && pattern.filePattern !== '*') {
80
+ // Glob with some specificity (e.g., "**/api/**/*.bench.js")
81
+ score += 1;
82
+ }
83
+ // else: fully generic glob like "**/*" gets +0
84
+
85
+ // Suite specificity
86
+ if (pattern.suitePattern !== '*') {
87
+ score += 1;
88
+ }
89
+
90
+ // Task specificity
91
+ if (pattern.taskPattern !== '*') {
92
+ score += 1;
93
+ }
94
+
95
+ return score;
96
+ };
97
+
98
+ /**
99
+ * Parse a TaskId into its components
100
+ *
101
+ * TaskIds have the format: `{filePath}/{suiteName}/{taskName}` The file path
102
+ * can contain slashes, so we parse from the end.
103
+ *
104
+ * @param taskId - The TaskId to parse
105
+ * @returns Object with file, suite, and task components
106
+ */
107
+ export const parseTaskId = (
108
+ taskId: TaskId,
109
+ ): { file: string; suite: string; task: string } => {
110
+ const str = taskId as string;
111
+ const lastSlash = str.lastIndexOf('/');
112
+ const secondLastSlash = str.lastIndexOf('/', lastSlash - 1);
113
+
114
+ return {
115
+ file: str.substring(0, secondLastSlash),
116
+ suite: str.substring(secondLastSlash + 1, lastSlash),
117
+ task: str.substring(lastSlash + 1),
118
+ };
119
+ };
120
+
121
+ /**
122
+ * Deep merge two budget objects
123
+ *
124
+ * More specific (second) budget values override less specific (first) values.
125
+ * Merges at the absolute/relative level.
126
+ *
127
+ * @param base - Base budget (less specific)
128
+ * @param override - Override budget (more specific)
129
+ * @returns Merged budget
130
+ */
131
+ export const mergeBudgets = (base: Budget, override: Budget): Budget => {
132
+ return {
133
+ ...(base.absolute || override.absolute
134
+ ? {
135
+ absolute: {
136
+ ...base.absolute,
137
+ ...override.absolute,
138
+ },
139
+ }
140
+ : {}),
141
+ ...(base.relative || override.relative
142
+ ? {
143
+ relative: {
144
+ ...base.relative,
145
+ ...override.relative,
146
+ },
147
+ }
148
+ : {}),
149
+ };
150
+ };
151
+
152
+ /**
153
+ * Resolve the appropriate budget for a task
154
+ *
155
+ * Resolution order:
156
+ *
157
+ * 1. Check exact match in `exact` map (highest priority)
158
+ * 2. Find all matching patterns
159
+ * 3. Sort by specificity (ascending)
160
+ * 4. Merge matched budgets (more specific overrides less specific)
161
+ *
162
+ * @param taskId - The task identifier to resolve a budget for
163
+ * @param budgets - The resolved budgets structure
164
+ * @returns The resolved budget, or undefined if no budget matches
165
+ */
166
+ export const resolveBudget = (
167
+ taskId: TaskId,
168
+ budgets: ResolvedBudgets,
169
+ ): Budget | undefined => {
170
+ // Fast path: exact match
171
+ const exactMatch = budgets.exact[taskId as string];
172
+ if (exactMatch) {
173
+ // Still need to check patterns and merge if there are less-specific matches
174
+ const parsed = parseTaskId(taskId);
175
+ const matchingPatterns = budgets.patterns.filter(
176
+ (p) =>
177
+ matchesFile(p.filePattern, parsed.file) &&
178
+ matchesSuiteOrTask(p.suitePattern, parsed.suite) &&
179
+ matchesSuiteOrTask(p.taskPattern, parsed.task),
180
+ );
181
+
182
+ if (matchingPatterns.length === 0) {
183
+ return exactMatch;
184
+ }
185
+
186
+ // Sort by specificity ascending (least specific first, so more specific can override)
187
+ const sorted = [...matchingPatterns].sort(
188
+ (a, b) => a.specificity - b.specificity,
189
+ );
190
+
191
+ // Merge all patterns, then apply exact match last
192
+ let merged = sorted[0]!.budget;
193
+ for (let i = 1; i < sorted.length; i++) {
194
+ merged = mergeBudgets(merged, sorted[i]!.budget);
195
+ }
196
+
197
+ // Exact match has highest priority
198
+ return mergeBudgets(merged, exactMatch);
199
+ }
200
+
201
+ // Find all matching patterns
202
+ const parsed = parseTaskId(taskId);
203
+ const matchingPatterns = budgets.patterns.filter(
204
+ (p) =>
205
+ matchesFile(p.filePattern, parsed.file) &&
206
+ matchesSuiteOrTask(p.suitePattern, parsed.suite) &&
207
+ matchesSuiteOrTask(p.taskPattern, parsed.task),
208
+ );
209
+
210
+ if (matchingPatterns.length === 0) {
211
+ return undefined;
212
+ }
213
+
214
+ // Sort by specificity ascending (least specific first)
215
+ const sorted = [...matchingPatterns].sort(
216
+ (a, b) => a.specificity - b.specificity,
217
+ );
218
+
219
+ // Merge all matches (more specific overrides less specific)
220
+ let merged = sorted[0]!.budget;
221
+ for (let i = 1; i < sorted.length; i++) {
222
+ merged = mergeBudgets(merged, sorted[i]!.budget);
223
+ }
224
+
225
+ return merged;
226
+ };
227
+
228
+ /**
229
+ * Create a BudgetPattern from its components
230
+ *
231
+ * @param filePattern - Glob pattern for file matching
232
+ * @param suitePattern - Suite name or `*` for wildcard
233
+ * @param taskPattern - Task name or `*` for wildcard
234
+ * @param budget - The budget to apply
235
+ * @returns A BudgetPattern with computed specificity
236
+ */
237
+ export const createBudgetPattern = (
238
+ filePattern: string,
239
+ suitePattern: string,
240
+ taskPattern: string,
241
+ budget: Budget,
242
+ ): BudgetPattern => {
243
+ return {
244
+ budget,
245
+ filePattern,
246
+ specificity: calculateSpecificity({
247
+ filePattern,
248
+ suitePattern,
249
+ taskPattern,
250
+ }),
251
+ suitePattern,
252
+ taskPattern,
253
+ };
254
+ };
@@ -63,7 +63,7 @@ const DEFAULT_CONFIG: ModestBenchConfig = {
63
63
  tags: [],
64
64
  thresholds: {},
65
65
  time: 1000, // 1 second minimum for tinybench to gather samples
66
- timeout: 30000, // 30 seconds
66
+ timeout: 30_000, // 30 seconds
67
67
  verbose: false, // No verbose output by default
68
68
  warmup: 30, // Light warmup by default - enough for basic JIT optimization
69
69
  };
@@ -291,7 +291,7 @@ export class ModestBenchConfigurationManager implements ConfigurationManager {
291
291
  }
292
292
 
293
293
  // Warn about potentially long runtime
294
- if (validConfig.iterations > 1000 && validConfig.time > 60000) {
294
+ if (validConfig.iterations > 1000 && validConfig.time > 60_000) {
295
295
  warnings.push({
296
296
  code: 'LONG_RUNTIME_WARNING',
297
297
  file: 'configuration',
@@ -18,11 +18,11 @@ import type {
18
18
  ValidationWarning,
19
19
  } from '../types/index.js';
20
20
 
21
+ import { benchmarkFileSchema } from '../config/benchmark-schema.js';
21
22
  import {
22
23
  BENCHMARK_FILE_EXTENSIONS,
23
24
  BENCHMARK_FILE_PATTERN,
24
25
  } from '../constants.js';
25
- import { benchmarkFileSchema } from '../core/benchmark-schema.js';
26
26
  import {
27
27
  FileDiscoveryError,
28
28
  FileLoadError,