canon 0.2.11 → 0.2.12

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 (148) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +12 -22
  3. data/Rakefile +5 -2
  4. data/lib/canon/cache.rb +3 -1
  5. data/lib/canon/cli.rb +0 -3
  6. data/lib/canon/commands/diff_command.rb +0 -6
  7. data/lib/canon/commands/format_command.rb +0 -4
  8. data/lib/canon/commands.rb +9 -0
  9. data/lib/canon/comparison/child_realignment.rb +0 -2
  10. data/lib/canon/comparison/compare_profile.rb +30 -36
  11. data/lib/canon/comparison/comparison_result.rb +0 -2
  12. data/lib/canon/comparison/diff_node_builder.rb +353 -0
  13. data/lib/canon/comparison/dimensions/dimension.rb +51 -0
  14. data/lib/canon/comparison/dimensions/dimension_set.rb +49 -0
  15. data/lib/canon/comparison/dimensions/registry.rb +101 -60
  16. data/lib/canon/comparison/dimensions.rb +15 -46
  17. data/lib/canon/comparison/html_comparator.rb +18 -141
  18. data/lib/canon/comparison/html_compare_profile.rb +15 -18
  19. data/lib/canon/comparison/json_comparator.rb +4 -165
  20. data/lib/canon/comparison/json_parser.rb +0 -2
  21. data/lib/canon/comparison/markup_comparator.rb +14 -210
  22. data/lib/canon/comparison/match_options/base_resolver.rb +18 -29
  23. data/lib/canon/comparison/match_options/json_resolver.rb +4 -28
  24. data/lib/canon/comparison/match_options/xml_resolver.rb +4 -45
  25. data/lib/canon/comparison/match_options/yaml_resolver.rb +4 -30
  26. data/lib/canon/comparison/match_options.rb +13 -88
  27. data/lib/canon/comparison/pipeline.rb +269 -0
  28. data/lib/canon/comparison/profile_definition.rb +0 -2
  29. data/lib/canon/comparison/ruby_object_comparator.rb +1 -1
  30. data/lib/canon/comparison/strategies/match_strategy_factory.rb +9 -58
  31. data/lib/canon/comparison/strategies/semantic_tree_match_strategy.rb +4 -11
  32. data/lib/canon/comparison/strategies.rb +16 -0
  33. data/lib/canon/comparison/xml_comparator/attribute_comparator.rb +0 -3
  34. data/lib/canon/comparison/xml_comparator/attribute_filter.rb +0 -3
  35. data/lib/canon/comparison/xml_comparator/child_comparison.rb +0 -6
  36. data/lib/canon/comparison/xml_comparator/namespace_comparator.rb +1 -6
  37. data/lib/canon/comparison/xml_comparator/node_parser.rb +0 -4
  38. data/lib/canon/comparison/xml_comparator.rb +4 -492
  39. data/lib/canon/comparison/xml_comparator_helpers.rb +21 -0
  40. data/lib/canon/comparison/xml_node_comparison.rb +4 -119
  41. data/lib/canon/comparison/yaml_comparator.rb +0 -3
  42. data/lib/canon/comparison.rb +143 -266
  43. data/lib/canon/config/config_dsl.rb +159 -0
  44. data/lib/canon/config/env_provider.rb +0 -3
  45. data/lib/canon/config/env_schema.rb +48 -58
  46. data/lib/canon/config/profile_loader.rb +0 -1
  47. data/lib/canon/config.rb +116 -468
  48. data/lib/canon/diff/diff_block_builder.rb +0 -2
  49. data/lib/canon/diff/diff_classifier.rb +0 -5
  50. data/lib/canon/diff/diff_context.rb +0 -2
  51. data/lib/canon/diff/diff_context_builder.rb +0 -2
  52. data/lib/canon/diff/diff_line_builder.rb +0 -3
  53. data/lib/canon/diff/diff_node_enricher.rb +0 -4
  54. data/lib/canon/diff/diff_node_mapper.rb +0 -4
  55. data/lib/canon/diff/diff_report_builder.rb +0 -4
  56. data/lib/canon/diff/formatting_detector.rb +0 -1
  57. data/lib/canon/diff/node_serializer.rb +0 -7
  58. data/lib/canon/diff.rb +39 -0
  59. data/lib/canon/diff_formatter/by_line/base_formatter.rb +4 -17
  60. data/lib/canon/diff_formatter/by_line/html_formatter.rb +7 -19
  61. data/lib/canon/diff_formatter/by_line/json_formatter.rb +0 -3
  62. data/lib/canon/diff_formatter/by_line/simple_formatter.rb +0 -3
  63. data/lib/canon/diff_formatter/by_line/xml_formatter.rb +7 -26
  64. data/lib/canon/diff_formatter/by_line/yaml_formatter.rb +0 -3
  65. data/lib/canon/diff_formatter/by_object/base_formatter.rb +8 -15
  66. data/lib/canon/diff_formatter/by_object/json_formatter.rb +0 -2
  67. data/lib/canon/diff_formatter/by_object/xml_formatter.rb +0 -2
  68. data/lib/canon/diff_formatter/by_object/yaml_formatter.rb +0 -2
  69. data/lib/canon/diff_formatter/debug_output.rb +0 -2
  70. data/lib/canon/diff_formatter/diff_detail_formatter/dimension_formatter.rb +24 -58
  71. data/lib/canon/diff_formatter/diff_detail_formatter/location_extractor.rb +0 -2
  72. data/lib/canon/diff_formatter/diff_detail_formatter/node_utils.rb +1 -2
  73. data/lib/canon/diff_formatter/diff_detail_formatter/text_utils.rb +1 -7
  74. data/lib/canon/diff_formatter/diff_detail_formatter.rb +0 -7
  75. data/lib/canon/diff_formatter/diff_detail_formatter_helpers.rb +23 -0
  76. data/lib/canon/diff_formatter.rb +11 -9
  77. data/lib/canon/formatters/html4_formatter.rb +0 -2
  78. data/lib/canon/formatters/html5_formatter.rb +0 -2
  79. data/lib/canon/formatters/html_formatter.rb +0 -3
  80. data/lib/canon/formatters/json_formatter.rb +0 -1
  81. data/lib/canon/formatters/xml_formatter.rb +0 -4
  82. data/lib/canon/formatters/yaml_formatter.rb +0 -1
  83. data/lib/canon/formatters.rb +16 -0
  84. data/lib/canon/html/data_model.rb +0 -10
  85. data/lib/canon/html.rb +4 -3
  86. data/lib/canon/options/cli_generator.rb +0 -2
  87. data/lib/canon/options/registry.rb +0 -2
  88. data/lib/canon/options.rb +9 -0
  89. data/lib/canon/pretty_printer/html.rb +0 -1
  90. data/lib/canon/pretty_printer/xml_normalized.rb +0 -2
  91. data/lib/canon/pretty_printer.rb +12 -0
  92. data/lib/canon/tree_diff/adapters/html_adapter.rb +1 -1
  93. data/lib/canon/tree_diff/adapters.rb +14 -0
  94. data/lib/canon/tree_diff/core/attribute_comparator.rb +0 -6
  95. data/lib/canon/tree_diff/core/node_signature.rb +1 -1
  96. data/lib/canon/tree_diff/core/tree_node.rb +12 -5
  97. data/lib/canon/tree_diff/core.rb +17 -0
  98. data/lib/canon/tree_diff/matchers/hash_matcher.rb +0 -7
  99. data/lib/canon/tree_diff/matchers/similarity_matcher.rb +1 -5
  100. data/lib/canon/tree_diff/matchers/structural_propagator.rb +1 -5
  101. data/lib/canon/tree_diff/matchers.rb +15 -0
  102. data/lib/canon/tree_diff/operation_converter.rb +0 -8
  103. data/lib/canon/tree_diff/operation_converter_helpers/metadata_enricher.rb +2 -12
  104. data/lib/canon/tree_diff/operation_converter_helpers/post_processor.rb +13 -7
  105. data/lib/canon/tree_diff/operation_converter_helpers/reason_builder.rb +2 -2
  106. data/lib/canon/tree_diff/operation_converter_helpers/update_change_handler.rb +4 -6
  107. data/lib/canon/tree_diff/operation_converter_helpers.rb +18 -0
  108. data/lib/canon/tree_diff/operations/operation_detector.rb +2 -5
  109. data/lib/canon/tree_diff/operations.rb +13 -0
  110. data/lib/canon/tree_diff.rb +26 -27
  111. data/lib/canon/validators/base_validator.rb +0 -2
  112. data/lib/canon/validators/html_validator.rb +0 -1
  113. data/lib/canon/validators/json_validator.rb +0 -1
  114. data/lib/canon/validators/xml_validator.rb +0 -1
  115. data/lib/canon/validators/yaml_validator.rb +0 -1
  116. data/lib/canon/validators.rb +12 -0
  117. data/lib/canon/version.rb +1 -1
  118. data/lib/canon/xml/c14n.rb +0 -4
  119. data/lib/canon/xml/data_model.rb +0 -10
  120. data/lib/canon/xml/line_range_mapper.rb +0 -2
  121. data/lib/canon/xml/nodes/attribute_node.rb +0 -2
  122. data/lib/canon/xml/nodes/comment_node.rb +0 -2
  123. data/lib/canon/xml/nodes/element_node.rb +0 -2
  124. data/lib/canon/xml/nodes/namespace_node.rb +0 -2
  125. data/lib/canon/xml/nodes/processing_instruction_node.rb +0 -2
  126. data/lib/canon/xml/nodes/root_node.rb +0 -2
  127. data/lib/canon/xml/nodes/text_node.rb +0 -2
  128. data/lib/canon/xml/nodes.rb +19 -0
  129. data/lib/canon/xml/processor.rb +0 -5
  130. data/lib/canon/xml/sax_builder.rb +0 -7
  131. data/lib/canon/xml.rb +33 -0
  132. data/lib/canon/xml_backend.rb +50 -14
  133. data/lib/canon/xml_parsing.rb +4 -2
  134. data/lib/canon.rb +25 -15
  135. data/lib/tasks/performance.rake +0 -58
  136. data/lib/tasks/performance_comparator.rb +132 -65
  137. data/lib/tasks/performance_helpers.rb +4 -249
  138. data/lib/tasks/performance_report.rb +309 -0
  139. metadata +24 -11
  140. data/lib/canon/comparison/dimensions/attribute_order_dimension.rb +0 -64
  141. data/lib/canon/comparison/dimensions/attribute_presence_dimension.rb +0 -64
  142. data/lib/canon/comparison/dimensions/attribute_values_dimension.rb +0 -167
  143. data/lib/canon/comparison/dimensions/base_dimension.rb +0 -107
  144. data/lib/canon/comparison/dimensions/comments_dimension.rb +0 -117
  145. data/lib/canon/comparison/dimensions/element_position_dimension.rb +0 -86
  146. data/lib/canon/comparison/dimensions/structural_whitespace_dimension.rb +0 -115
  147. data/lib/canon/comparison/dimensions/text_content_dimension.rb +0 -102
  148. data/lib/canon/comparison/xml_comparator/diff_node_builder.rb +0 -300
@@ -1,100 +1,167 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "performance_helpers"
3
+ require "json"
4
+ require "open3"
5
+ require "fileutils"
6
+ require "table_tennis"
4
7
 
8
+ # Compares performance between the current branch and a base branch (default:
9
+ # main) by running the same benchmark suite in two separate Ruby processes —
10
+ # one per branch. Each process loads its own Canon implementation from disk,
11
+ # fully isolated from the other, and emits a JSON result. The comparator then
12
+ # diffs the two JSON payloads and reports regressions.
13
+ #
14
+ # Running each branch in its own process is required because Canon uses Ruby
15
+ # autoload extensively; loading both branches' code into a single process
16
+ # causes constant resolution and LOAD_PATH conflicts.
5
17
  class PerformanceComparator
6
18
  REPO_ROOT = File.expand_path(File.join(__dir__, "..", ".."))
7
- DEFAULT_RUN_TIME = 10 # seconds
8
- DEFAULT_THRESHOLD = 0.10 # 10% (more lenient for complex operations)
19
+ DEFAULT_RUN_TIME = Integer(ENV.fetch("CANON_PERF_RUN_TIME", "10"))
20
+ DEFAULT_THRESHOLD = 0.10 # 10%
9
21
  DEFAULT_BASE = "main"
10
22
  TMP_PERF_DIR = File.join(REPO_ROOT, "tmp", "performance")
11
- BENCH_SCRIPT = File.join(TMP_PERF_DIR, "benchmark_runner.rb")
12
-
13
- # Benchmark categories - run specific subsets
14
- BENCHMARK_CATEGORIES = {
15
- xml_parsing: %w[xml_parse_dom_simple xml_parse_sax_simple
16
- xml_parse_dom_large xml_parse_sax_large],
17
- html_parsing: %w[html_parse_simple html_parse_complex],
18
- xml_comparison: %w[xml_compare_identical xml_compare_similar
19
- xml_compare_different],
20
- html_comparison: %w[html_compare_identical html_compare_similar
21
- html_compare_different],
22
- formatting: %w[xml_c14n_format json_format yaml_format],
23
- }.freeze
23
+ REPORT_SCRIPT = File.expand_path("performance_report.rb", __dir__)
24
+
25
+ RED = "\e[31m"
26
+ GREEN = "\e[32m"
27
+ YELLOW = "\e[33m"
28
+ CYAN = "\e[36m"
29
+ BOLD = "\e[1m"
30
+ DIM = "\e[2m"
31
+ CLEAR = "\e[0m"
24
32
 
25
33
  def run
26
- setup_environment
27
- run_benchmarks_comparison
34
+ clone_base
35
+ current = run_report(REPO_ROOT, "current")
36
+ base = run_report(base_clone_dir, "base (#{DEFAULT_BASE})")
37
+ print_report(current, base)
38
+ exit(1) if regressions?(current, base)
28
39
  ensure
29
40
  cleanup
30
41
  end
31
42
 
32
43
  private
33
44
 
34
- def setup_environment
35
- Dir.chdir(REPO_ROOT)
45
+ def clone_base
46
+ FileUtils.rm_rf(TMP_PERF_DIR)
36
47
  FileUtils.mkdir_p(TMP_PERF_DIR)
37
- FileUtils.cp(File.join(REPO_ROOT, "lib", "tasks", "benchmark_runner.rb"),
38
- BENCH_SCRIPT)
39
48
 
40
- PerformanceHelpers.load_into_namespace(PerformanceHelpers::Current,
41
- BENCH_SCRIPT)
42
- PerformanceHelpers.clone_base_repo(DEFAULT_BASE, TMP_PERF_DIR, BENCH_SCRIPT)
49
+ puts "#{DIM}Cloning base #{DEFAULT_BASE}...#{CLEAR}"
50
+ repo_url, = exec("git config --get remote.origin.url")
51
+ out, err, status = exec(
52
+ "git clone --branch #{DEFAULT_BASE} --single-branch #{repo_url.strip} #{base_clone_dir}",
53
+ )
54
+ return if status.success?
55
+
56
+ raise "git clone failed: #{err}\n#{out}"
43
57
  end
44
58
 
45
- def run_benchmarks_comparison
46
- all_current = {}
47
- all_base = {}
59
+ def run_report(working_dir, label)
60
+ puts "#{DIM}Running benchmarks for #{label}...#{CLEAR}"
61
+ env = {
62
+ "CANON_PERF_RUN_TIME" => DEFAULT_RUN_TIME.to_s,
63
+ "BUNDLE_GEMFILE" => File.join(working_dir, "Gemfile"),
64
+ }
48
65
 
49
- puts PerformanceHelpers::Term.header("Performance Comparison", color: PerformanceHelpers::CYAN)
50
- puts
51
- puts " #{PerformanceHelpers::DIM}Comparing#{PerformanceHelpers::CLEAR}:"
52
- puts " #{PerformanceHelpers::CYAN} Current#{PerformanceHelpers::CLEAR}: #{PerformanceHelpers.current_branch}"
53
- puts " #{PerformanceHelpers::CYAN} Base#{PerformanceHelpers::CLEAR}: #{DEFAULT_BASE}"
54
- puts " #{PerformanceHelpers::CYAN} Threshold#{PerformanceHelpers::CLEAR}: #{(DEFAULT_THRESHOLD * 100).round(0)}% regression allowed"
55
- puts
66
+ script_copy = File.join(working_dir, "tmp", "performance_report.rb")
67
+ FileUtils.mkdir_p(File.dirname(script_copy))
68
+ FileUtils.cp(REPORT_SCRIPT, script_copy)
56
69
 
57
- # Run all benchmarks
58
- base_runner = PerformanceHelpers::Base::BenchmarkRunner.new(
59
- run_time: DEFAULT_RUN_TIME,
60
- )
61
- current_runner = PerformanceHelpers::Current::BenchmarkRunner.new(
62
- run_time: DEFAULT_RUN_TIME,
63
- )
70
+ stdout, stderr, status = Open3.capture3(env, "bundle", "exec", "ruby",
71
+ script_copy,
72
+ chdir: working_dir)
73
+ unless status.success?
74
+ raise "Benchmark failed for #{label}: #{stderr}\n#{stdout}"
75
+ end
64
76
 
65
- PerformanceHelpers.run_benchmarks(
66
- base_runner,
67
- current_runner,
68
- DEFAULT_THRESHOLD,
69
- all_base,
70
- all_current,
71
- )
77
+ JSON.parse(stdout)
78
+ rescue JSON::ParserError => e
79
+ raise "Invalid JSON from #{label}: #{e.message}"
80
+ end
72
81
 
73
- summary = PerformanceHelpers.summary_report(
74
- all_current,
75
- all_base,
76
- DEFAULT_BASE,
77
- DEFAULT_RUN_TIME,
78
- DEFAULT_THRESHOLD,
79
- )
82
+ def regressions?(current, base)
83
+ threshold = DEFAULT_THRESHOLD
84
+ current.fetch("benchmarks").any? do |label, metrics|
85
+ base_metrics = base.fetch("benchmarks")[label]
86
+ next false unless base_metrics
87
+
88
+ change = change_fraction(metrics, base_metrics)
89
+ change && change < -threshold
90
+ end
91
+ end
92
+
93
+ def change_fraction(curr, base)
94
+ base_ips = base.fetch("lower").to_f
95
+ curr_ips = curr.fetch("upper").to_f
96
+ return nil if base_ips.zero?
80
97
 
81
- handle_results(summary)
98
+ (curr_ips - base_ips) / base_ips
82
99
  end
83
100
 
84
- def handle_results(summary)
101
+ def print_report(current, base)
102
+ threshold = DEFAULT_THRESHOLD
103
+ rows = current.fetch("benchmarks").map do |label, metrics|
104
+ base_metrics = base.fetch("benchmarks")[label]
105
+ change = change_fraction(metrics, base_metrics)
106
+ status = if base_metrics.nil?
107
+ "NEW"
108
+ elsif change < -threshold
109
+ "REGRESSED"
110
+ else
111
+ "OK"
112
+ end
113
+ {
114
+ benchmark: label,
115
+ base_ips: base_metrics&.fetch("lower")&.round(1),
116
+ curr_ips: metrics.fetch("upper").round(1),
117
+ change: change ? format("%+0.1f%%", change * 100) : "N/A",
118
+ status: status,
119
+ }
120
+ end
121
+
122
+ table = TableTennis.new(rows,
123
+ title: "Performance Comparison",
124
+ theme: :dark,
125
+ headers: {
126
+ benchmark: "Benchmark",
127
+ base_ips: "Base IPS",
128
+ curr_ips: "Curr IPS",
129
+ change: "Change",
130
+ status: "Status",
131
+ })
132
+ table.render
85
133
  puts
86
- if summary[:regressions].any?
87
- puts " #{PerformanceHelpers::RED}#{PerformanceHelpers::BOLD}❌ PERFORMANCE REGRESSIONS DETECTED#{PerformanceHelpers::CLEAR}"
88
- puts " #{PerformanceHelpers::RED}#{summary[:regressions].length} benchmark(s) regressed beyond threshold#{PerformanceHelpers::CLEAR}"
89
- puts
90
- exit(1)
134
+
135
+ print_summary(rows, threshold)
136
+ end
137
+
138
+ def print_summary(rows, threshold)
139
+ regressions = rows.select { |r| r[:status] == "REGRESSED" }
140
+ new_benchmarks = rows.select { |r| r[:status] == "NEW" }
141
+
142
+ if regressions.empty?
143
+ puts "#{GREEN}#{BOLD}✅ ALL BENCHMARKS PASSED#{CLEAR}"
91
144
  else
92
- puts " #{PerformanceHelpers::GREEN}#{PerformanceHelpers::BOLD} ALL BENCHMARKS PASSED#{PerformanceHelpers::CLEAR}"
93
- puts
145
+ puts "#{RED}#{BOLD} PERFORMANCE REGRESSIONS DETECTED#{CLEAR}"
146
+ puts "#{RED}#{regressions.length} benchmark(s) regressed " \
147
+ "beyond #{(threshold * 100).round(0)}% threshold#{CLEAR}"
94
148
  end
149
+
150
+ return if new_benchmarks.empty?
151
+
152
+ puts "#{YELLOW}🆕 New benchmarks (not in base):#{CLEAR}"
153
+ new_benchmarks.each { |r| puts " • #{r[:benchmark]}" }
154
+ end
155
+
156
+ def base_clone_dir
157
+ @base_clone_dir ||= File.join(TMP_PERF_DIR, "base-#{DEFAULT_BASE}")
95
158
  end
96
159
 
97
160
  def cleanup
98
161
  FileUtils.rm_rf(TMP_PERF_DIR)
99
162
  end
163
+
164
+ def exec(cmd)
165
+ Open3.capture3(cmd)
166
+ end
100
167
  end
@@ -1,13 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "open3"
5
- require "tmpdir"
6
- require "fileutils"
7
- require "table_tennis" unless RUBY_ENGINE == "opal"
8
-
3
+ # Performance benchmark output helpers shared between
4
+ # +benchmark_runner.rb+ (interactive) and +performance_comparator.rb+.
5
+ # Kept as a small module so the rake tasks below can pull in the color
6
+ # constants without pulling in the comparator (which would clone main).
9
7
  module PerformanceHelpers
10
- # ANSI color codes for terminal output
11
8
  CLEAR = "\e[0m"
12
9
  BOLD = "\e[1m"
13
10
  DIM = "\e[2m"
@@ -18,246 +15,4 @@ module PerformanceHelpers
18
15
  GRAY = "\e[90m"
19
16
  WHITE = "\e[37m"
20
17
  MAGENTA = "\e[35m"
21
-
22
- # Terminal formatting helpers
23
- module Term
24
- extend self
25
-
26
- HL = "─"
27
- VL = "│"
28
- TL = "┌"
29
- TR = "┐"
30
- BL = "└"
31
- BR = "┘"
32
-
33
- def header(title, color: PerformanceHelpers::CYAN)
34
- width = 78
35
- line = HL * width
36
- puts
37
- puts "#{color}#{TL}#{line}#{TR}#{CLEAR}"
38
- puts "#{color}#{VL}#{CLEAR} #{BOLD}#{color}#{title}#{CLEAR}#{' ' * (width - title.length - 4)}#{color}#{VL}#{CLEAR}"
39
- puts "#{color}#{BL}#{line}#{BR}#{CLEAR}"
40
- end
41
-
42
- def sep(char: HL, width: 78)
43
- puts "#{DIM}#{char * width}#{CLEAR}"
44
- end
45
- end
46
-
47
- module Base
48
- end
49
-
50
- module Current
51
- end
52
-
53
- class << self
54
- def load_into_namespace(module_obj, file_path)
55
- content = File.read(file_path, encoding: "utf-8")
56
- module_obj.module_eval(content, file_path)
57
- end
58
-
59
- def ruby_exec(cmd, env: {})
60
- Open3.capture3(env, cmd)
61
- end
62
-
63
- def current_branch
64
- stdout, = ruby_exec("git rev-parse --abbrev-ref HEAD")
65
- stdout.strip
66
- end
67
-
68
- # Clone base branch into a temp dir and return its path
69
- def clone_base_repo(base, performance_dir, script)
70
- puts "#{DIM}Cloning base #{base}...#{CLEAR}"
71
- safe_ref = base.gsub(/[^0-9A-Za-z._-]/, "-")
72
- clone_dir = File.join(performance_dir, "base-#{safe_ref}")
73
- FileUtils.rm_rf(clone_dir)
74
-
75
- repo_url, = ruby_exec("git config --get remote.origin.url")
76
- repo_url = repo_url.strip
77
-
78
- stdout, stderr, status = ruby_exec("git clone --branch #{safe_ref} --single-branch #{repo_url} #{clone_dir}")
79
- raise "git clone failed: #{stderr}\n#{stdout}" unless status.success?
80
-
81
- Dir.chdir(clone_dir) do
82
- stdout, stderr, status = ruby_exec("bundle install --quiet")
83
- raise "bundle install failed: #{stderr}\n#{stdout}" unless status.success?
84
-
85
- bench_copy_dir = File.join(clone_dir, "tmp", "performance")
86
- FileUtils.mkdir_p(bench_copy_dir)
87
- bench_copy = File.join(bench_copy_dir, "benchmark_runner.rb")
88
- File.write(bench_copy, File.read(script, encoding: "utf-8"))
89
- load_into_namespace(Base, bench_copy)
90
- end
91
- end
92
-
93
- def run_benchmarks(base_runner, current_runner, threshold, all_base,
94
- all_current)
95
- base_results = base_runner.run_benchmarks
96
- curr_results = current_runner.run_benchmarks
97
-
98
- all_base.merge!(base_results)
99
- all_current.merge!(curr_results)
100
-
101
- # Collect comparison results for TableTennis table
102
- comparison_rows = []
103
-
104
- curr_results.each do |label, result|
105
- base_result = base_results[label]
106
- cmp = compare_metrics(label, result, base_result, threshold)
107
- comparison_rows << cmp
108
- end
109
-
110
- print_comparison_table(comparison_rows, threshold)
111
- end
112
-
113
- def print_comparison_table(comparison_rows, threshold)
114
- rows = comparison_rows.map do |cmp|
115
- {
116
- benchmark: cmp[:label],
117
- base_ips: cmp[:base_ips]&.round(1),
118
- curr_ips: cmp[:curr_ips]&.round(1),
119
- change: cmp[:change] ? "#{(cmp[:change] * 100).round(1)}%" : "N/A",
120
- status: if cmp[:base_ips].nil?
121
- "NEW"
122
- elsif cmp[:change] < -threshold
123
- "REGRESSED"
124
- else
125
- "OK"
126
- end,
127
- }
128
- end
129
-
130
- return if rows.empty?
131
-
132
- table = TableTennis.new(rows,
133
- title: "Performance Comparison",
134
- theme: :dark,
135
- headers: {
136
- benchmark: "Benchmark",
137
- base_ips: "Base IPS",
138
- curr_ips: "Curr IPS",
139
- change: "Change",
140
- status: "Status",
141
- })
142
- table.render
143
- puts
144
- end
145
-
146
- def compare_metrics(label, curr, base, threshold)
147
- # Skip comparison if base result is missing (e.g., new benchmark)
148
- unless base
149
- return { label: label, base_ips: nil, curr_ips: nil, change: nil,
150
- regressed: false }
151
- end
152
-
153
- base_ips = base.fetch(:lower)
154
- curr_ips = curr.fetch(:upper)
155
- change = (curr_ips - base_ips) / base_ips.to_f
156
-
157
- {
158
- label: label,
159
- base_ips: base_ips,
160
- curr_ips: curr_ips,
161
- change: change,
162
- regressed: change < -threshold,
163
- }
164
- end
165
-
166
- def summary_report(current_results, base_results, base, run_time, threshold)
167
- summary = {
168
- run_time: run_time,
169
- threshold: threshold,
170
- branch: current_branch,
171
- base: base,
172
- regressions: [],
173
- new_benchmarks: [],
174
- }
175
-
176
- current_results.each do |label, metrics|
177
- base_result = base_results[label]
178
- cmp = compare_metrics(label, metrics, base_result, threshold)
179
-
180
- # Track new benchmarks that don't exist in base
181
- if base_result.nil?
182
- summary[:new_benchmarks] << label
183
- next
184
- end
185
-
186
- next unless cmp[:regressed]
187
-
188
- summary[:regressions] << {
189
- label: label,
190
- base_ips: cmp[:base_ips],
191
- curr_ips: cmp[:curr_ips],
192
- delta_fraction: cmp[:change],
193
- }
194
- end
195
-
196
- log_regressions(summary[:regressions], threshold)
197
- log_new_benchmarks(summary[:new_benchmarks])
198
- summary
199
- end
200
-
201
- def log_new_benchmarks(new_benchmarks)
202
- return if new_benchmarks.empty?
203
-
204
- puts
205
- puts "#{YELLOW}🆕 New benchmarks (not in base branch):#{CLEAR}"
206
- new_benchmarks.each do |label|
207
- puts " • #{label}"
208
- end
209
- end
210
-
211
- def log_regressions(regressions, threshold)
212
- return if regressions.empty?
213
-
214
- puts
215
- puts "#{RED}⚠️ Performance Regressions Detected#{CLEAR}"
216
- puts "#{RED} (< -#{(threshold * 100).round(2)}% IPS)#{CLEAR}"
217
- puts
218
- regressions.each do |regression|
219
- delta = regression[:delta_fraction]
220
- base_ips = regression[:base_ips]
221
- curr_ips = regression[:curr_ips]
222
-
223
- delta_str = delta ? format("%+0.2f%%", delta * 100) : "N/A"
224
- base_str = base_ips ? format("%.2f", base_ips) : "N/A"
225
- curr_str = curr_ips ? format("%.2f", curr_ips) : "N/A"
226
-
227
- puts " #{BOLD}#{regression[:label]}#{CLEAR}"
228
- puts " #{GRAY}base: #{base_str} IPS#{CLEAR}"
229
- puts " #{RED}curr: #{curr_str} IPS#{CLEAR}"
230
- puts " #{RED}change: #{delta_str}#{CLEAR}"
231
- puts
232
- end
233
- end
234
-
235
- private
236
-
237
- def print_realtime_comparison(label, curr_metrics, base_metrics, threshold)
238
- # Handle new benchmarks that don't exist in base
239
- if base_metrics.nil?
240
- curr_ips = (curr_metrics[:lower] + curr_metrics[:upper]) / 2.0
241
- puts "#{format('%-30s',
242
- label)}: #{GREEN}NEW#{CLEAR} (current: #{format('%.2f',
243
- curr_ips)} IPS) [N/A]\n\n"
244
- return
245
- end
246
-
247
- curr_ips = curr_metrics[:upper]
248
- base_ips = base_metrics[:lower]
249
- return unless curr_ips && base_ips
250
-
251
- change = (curr_ips - base_ips) / base_ips.to_f
252
- color = change < -threshold ? RED : GREEN
253
- status = change < -threshold ? "⚠️ REGRESSED" : "✅ OK"
254
- delta_str = format("%+0.2f%%", change * 100)
255
- base_str = format("%.2f", base_ips)
256
- curr_str = format("%.2f", curr_ips)
257
-
258
- puts "#{format('%-30s',
259
- label)}: #{GRAY}#{base_str}#{CLEAR} → #{color}#{curr_str}#{CLEAR} IPS " \
260
- "(change: #{color}#{delta_str}#{CLEAR}) [#{color}#{status}#{CLEAR}]\n\n"
261
- end
262
- end
263
18
  end