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
package/src/constants.ts CHANGED
@@ -110,6 +110,8 @@ export const ErrorCodes = {
110
110
 
111
111
  //#region reporter-errors
112
112
  REPORTER_ALREADY_REGISTERED: 'ERR_MB_REPORTER_ALREADY_REGISTERED',
113
+ REPORTER_INVALID: 'ERR_MB_REPORTER_INVALID',
114
+ REPORTER_LOAD_FAILED: 'ERR_MB_REPORTER_LOAD_FAILED',
113
115
  REPORTER_OUTPUT_FAILED: 'ERR_MB_REPORTER_OUTPUT_FAILED',
114
116
  REPORTER_UNKNOWN: 'ERR_MB_REPORTER_UNKNOWN',
115
117
  //#endregion
@@ -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
 
@@ -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
  *
@@ -53,7 +53,9 @@ export {
53
53
  // Reporter errors
54
54
  export {
55
55
  ReporterAlreadyRegisteredError,
56
+ ReporterLoadError,
56
57
  ReporterOutputError,
58
+ ReporterValidationError,
57
59
  UnknownReporterError,
58
60
  } from './reporter.js';
59
61
 
@@ -16,6 +16,26 @@ export class ReporterAlreadyRegisteredError extends ModestBenchError {
16
16
  readonly code = 'ERR_MB_REPORTER_ALREADY_REGISTERED';
17
17
  }
18
18
 
19
+ /**
20
+ * Reporter load failed
21
+ *
22
+ * Thrown when a reporter module cannot be loaded (file not found, syntax error,
23
+ * invalid module format, etc.).
24
+ */
25
+ export class ReporterLoadError extends ModestBenchError {
26
+ readonly code = 'ERR_MB_REPORTER_LOAD_FAILED';
27
+
28
+ /**
29
+ * The specifier (file path or package name) that failed to load
30
+ */
31
+ readonly specifier: string;
32
+
33
+ constructor(message: string, specifier: string, options?: ErrorOptions) {
34
+ super(`Failed to load reporter "${specifier}": ${message}`, options);
35
+ this.specifier = specifier;
36
+ }
37
+ }
38
+
19
39
  /**
20
40
  * Reporter output failed
21
41
  *
@@ -25,6 +45,41 @@ export class ReporterOutputError extends ModestBenchError {
25
45
  readonly code = 'ERR_MB_REPORTER_OUTPUT_FAILED';
26
46
  }
27
47
 
48
+ /**
49
+ * Reporter validation failed
50
+ *
51
+ * Thrown when a loaded module does not implement the required Reporter
52
+ * interface methods.
53
+ */
54
+ export class ReporterValidationError extends ModestBenchError {
55
+ readonly code = 'ERR_MB_REPORTER_INVALID';
56
+
57
+ /**
58
+ * The methods that are missing from the reporter
59
+ */
60
+ readonly missingMethods: string[];
61
+
62
+ /**
63
+ * The specifier (file path or package name) of the invalid reporter
64
+ */
65
+ readonly specifier: string;
66
+
67
+ constructor(
68
+ message: string,
69
+ specifier: string,
70
+ missingMethods: string[] = [],
71
+ options?: ErrorOptions,
72
+ ) {
73
+ const methodsInfo =
74
+ missingMethods.length > 0
75
+ ? ` Missing required methods: ${missingMethods.join(', ')}.`
76
+ : '';
77
+ super(`Invalid reporter "${specifier}": ${message}${methodsInfo}`, options);
78
+ this.specifier = specifier;
79
+ this.missingMethods = missingMethods;
80
+ }
81
+ }
82
+
28
83
  /**
29
84
  * Unknown reporter
30
85
  *
package/src/index.ts CHANGED
@@ -41,6 +41,15 @@ export { parseProfile } from './services/profiler/profile-parser.js';
41
41
 
42
42
  export { runWithProfiling } from './services/profiler/profile-runner.js';
43
43
  export { ModestBenchProgressManager } from './services/progress-manager.js';
44
+ // Reporter plugin loader
45
+ export {
46
+ createReporterContext,
47
+ isBuiltInReporter,
48
+ isFilePath,
49
+ loadReporter,
50
+ PLUGIN_API_VERSION,
51
+ } from './services/reporter-loader.js';
52
+
44
53
  export {
45
54
  BaseReporter,
46
55
  CompositeReporter,
@@ -51,4 +60,13 @@ export {
51
60
  export * from './types/index.js';
52
61
 
53
62
  // Utilities
54
- export { findPackageRoot } from './utils/package.js';
63
+ export { findPackageRoot, getPackageVersion } from './utils/package.js';
64
+
65
+ // Reporter utilities (for plugin authors)
66
+ export {
67
+ formatBytes,
68
+ formatDuration,
69
+ formatOpsPerSecond,
70
+ formatPercentage,
71
+ reporterUtils,
72
+ } from './utils/reporter-utils.js';
@@ -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
  }
@@ -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
  *
@@ -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
+ };
@@ -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,