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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +12 -22
- data/Rakefile +5 -2
- data/lib/canon/cache.rb +3 -1
- data/lib/canon/cli.rb +0 -3
- data/lib/canon/commands/diff_command.rb +0 -6
- data/lib/canon/commands/format_command.rb +0 -4
- data/lib/canon/commands.rb +9 -0
- data/lib/canon/comparison/child_realignment.rb +0 -2
- data/lib/canon/comparison/compare_profile.rb +30 -36
- data/lib/canon/comparison/comparison_result.rb +0 -2
- data/lib/canon/comparison/diff_node_builder.rb +353 -0
- data/lib/canon/comparison/dimensions/dimension.rb +51 -0
- data/lib/canon/comparison/dimensions/dimension_set.rb +49 -0
- data/lib/canon/comparison/dimensions/registry.rb +101 -60
- data/lib/canon/comparison/dimensions.rb +15 -46
- data/lib/canon/comparison/html_comparator.rb +18 -141
- data/lib/canon/comparison/html_compare_profile.rb +15 -18
- data/lib/canon/comparison/json_comparator.rb +4 -165
- data/lib/canon/comparison/json_parser.rb +0 -2
- data/lib/canon/comparison/markup_comparator.rb +14 -210
- data/lib/canon/comparison/match_options/base_resolver.rb +18 -29
- data/lib/canon/comparison/match_options/json_resolver.rb +4 -28
- data/lib/canon/comparison/match_options/xml_resolver.rb +4 -45
- data/lib/canon/comparison/match_options/yaml_resolver.rb +4 -30
- data/lib/canon/comparison/match_options.rb +13 -88
- data/lib/canon/comparison/pipeline.rb +269 -0
- data/lib/canon/comparison/profile_definition.rb +0 -2
- data/lib/canon/comparison/ruby_object_comparator.rb +1 -1
- data/lib/canon/comparison/strategies/match_strategy_factory.rb +9 -58
- data/lib/canon/comparison/strategies/semantic_tree_match_strategy.rb +4 -11
- data/lib/canon/comparison/strategies.rb +16 -0
- data/lib/canon/comparison/xml_comparator/attribute_comparator.rb +0 -3
- data/lib/canon/comparison/xml_comparator/attribute_filter.rb +0 -3
- data/lib/canon/comparison/xml_comparator/child_comparison.rb +0 -6
- data/lib/canon/comparison/xml_comparator/namespace_comparator.rb +1 -6
- data/lib/canon/comparison/xml_comparator/node_parser.rb +0 -4
- data/lib/canon/comparison/xml_comparator.rb +4 -492
- data/lib/canon/comparison/xml_comparator_helpers.rb +21 -0
- data/lib/canon/comparison/xml_node_comparison.rb +4 -119
- data/lib/canon/comparison/yaml_comparator.rb +0 -3
- data/lib/canon/comparison.rb +143 -266
- data/lib/canon/config/config_dsl.rb +159 -0
- data/lib/canon/config/env_provider.rb +0 -3
- data/lib/canon/config/env_schema.rb +48 -58
- data/lib/canon/config/profile_loader.rb +0 -1
- data/lib/canon/config.rb +116 -468
- data/lib/canon/diff/diff_block_builder.rb +0 -2
- data/lib/canon/diff/diff_classifier.rb +0 -5
- data/lib/canon/diff/diff_context.rb +0 -2
- data/lib/canon/diff/diff_context_builder.rb +0 -2
- data/lib/canon/diff/diff_line_builder.rb +0 -3
- data/lib/canon/diff/diff_node_enricher.rb +0 -4
- data/lib/canon/diff/diff_node_mapper.rb +0 -4
- data/lib/canon/diff/diff_report_builder.rb +0 -4
- data/lib/canon/diff/formatting_detector.rb +0 -1
- data/lib/canon/diff/node_serializer.rb +0 -7
- data/lib/canon/diff.rb +39 -0
- data/lib/canon/diff_formatter/by_line/base_formatter.rb +4 -17
- data/lib/canon/diff_formatter/by_line/html_formatter.rb +7 -19
- data/lib/canon/diff_formatter/by_line/json_formatter.rb +0 -3
- data/lib/canon/diff_formatter/by_line/simple_formatter.rb +0 -3
- data/lib/canon/diff_formatter/by_line/xml_formatter.rb +7 -26
- data/lib/canon/diff_formatter/by_line/yaml_formatter.rb +0 -3
- data/lib/canon/diff_formatter/by_object/base_formatter.rb +8 -15
- data/lib/canon/diff_formatter/by_object/json_formatter.rb +0 -2
- data/lib/canon/diff_formatter/by_object/xml_formatter.rb +0 -2
- data/lib/canon/diff_formatter/by_object/yaml_formatter.rb +0 -2
- data/lib/canon/diff_formatter/debug_output.rb +0 -2
- data/lib/canon/diff_formatter/diff_detail_formatter/dimension_formatter.rb +24 -58
- data/lib/canon/diff_formatter/diff_detail_formatter/location_extractor.rb +0 -2
- data/lib/canon/diff_formatter/diff_detail_formatter/node_utils.rb +1 -2
- data/lib/canon/diff_formatter/diff_detail_formatter/text_utils.rb +1 -7
- data/lib/canon/diff_formatter/diff_detail_formatter.rb +0 -7
- data/lib/canon/diff_formatter/diff_detail_formatter_helpers.rb +23 -0
- data/lib/canon/diff_formatter.rb +11 -9
- data/lib/canon/formatters/html4_formatter.rb +0 -2
- data/lib/canon/formatters/html5_formatter.rb +0 -2
- data/lib/canon/formatters/html_formatter.rb +0 -3
- data/lib/canon/formatters/json_formatter.rb +0 -1
- data/lib/canon/formatters/xml_formatter.rb +0 -4
- data/lib/canon/formatters/yaml_formatter.rb +0 -1
- data/lib/canon/formatters.rb +16 -0
- data/lib/canon/html/data_model.rb +0 -10
- data/lib/canon/html.rb +4 -3
- data/lib/canon/options/cli_generator.rb +0 -2
- data/lib/canon/options/registry.rb +0 -2
- data/lib/canon/options.rb +9 -0
- data/lib/canon/pretty_printer/html.rb +0 -1
- data/lib/canon/pretty_printer/xml_normalized.rb +0 -2
- data/lib/canon/pretty_printer.rb +12 -0
- data/lib/canon/tree_diff/adapters/html_adapter.rb +1 -1
- data/lib/canon/tree_diff/adapters.rb +14 -0
- data/lib/canon/tree_diff/core/attribute_comparator.rb +0 -6
- data/lib/canon/tree_diff/core/node_signature.rb +1 -1
- data/lib/canon/tree_diff/core/tree_node.rb +12 -5
- data/lib/canon/tree_diff/core.rb +17 -0
- data/lib/canon/tree_diff/matchers/hash_matcher.rb +0 -7
- data/lib/canon/tree_diff/matchers/similarity_matcher.rb +1 -5
- data/lib/canon/tree_diff/matchers/structural_propagator.rb +1 -5
- data/lib/canon/tree_diff/matchers.rb +15 -0
- data/lib/canon/tree_diff/operation_converter.rb +0 -8
- data/lib/canon/tree_diff/operation_converter_helpers/metadata_enricher.rb +2 -12
- data/lib/canon/tree_diff/operation_converter_helpers/post_processor.rb +13 -7
- data/lib/canon/tree_diff/operation_converter_helpers/reason_builder.rb +2 -2
- data/lib/canon/tree_diff/operation_converter_helpers/update_change_handler.rb +4 -6
- data/lib/canon/tree_diff/operation_converter_helpers.rb +18 -0
- data/lib/canon/tree_diff/operations/operation_detector.rb +2 -5
- data/lib/canon/tree_diff/operations.rb +13 -0
- data/lib/canon/tree_diff.rb +26 -27
- data/lib/canon/validators/base_validator.rb +0 -2
- data/lib/canon/validators/html_validator.rb +0 -1
- data/lib/canon/validators/json_validator.rb +0 -1
- data/lib/canon/validators/xml_validator.rb +0 -1
- data/lib/canon/validators/yaml_validator.rb +0 -1
- data/lib/canon/validators.rb +12 -0
- data/lib/canon/version.rb +1 -1
- data/lib/canon/xml/c14n.rb +0 -4
- data/lib/canon/xml/data_model.rb +0 -10
- data/lib/canon/xml/line_range_mapper.rb +0 -2
- data/lib/canon/xml/nodes/attribute_node.rb +0 -2
- data/lib/canon/xml/nodes/comment_node.rb +0 -2
- data/lib/canon/xml/nodes/element_node.rb +0 -2
- data/lib/canon/xml/nodes/namespace_node.rb +0 -2
- data/lib/canon/xml/nodes/processing_instruction_node.rb +0 -2
- data/lib/canon/xml/nodes/root_node.rb +0 -2
- data/lib/canon/xml/nodes/text_node.rb +0 -2
- data/lib/canon/xml/nodes.rb +19 -0
- data/lib/canon/xml/processor.rb +0 -5
- data/lib/canon/xml/sax_builder.rb +0 -7
- data/lib/canon/xml.rb +33 -0
- data/lib/canon/xml_backend.rb +50 -14
- data/lib/canon/xml_parsing.rb +4 -2
- data/lib/canon.rb +25 -15
- data/lib/tasks/performance.rake +0 -58
- data/lib/tasks/performance_comparator.rb +132 -65
- data/lib/tasks/performance_helpers.rb +4 -249
- data/lib/tasks/performance_report.rb +309 -0
- metadata +24 -11
- data/lib/canon/comparison/dimensions/attribute_order_dimension.rb +0 -64
- data/lib/canon/comparison/dimensions/attribute_presence_dimension.rb +0 -64
- data/lib/canon/comparison/dimensions/attribute_values_dimension.rb +0 -167
- data/lib/canon/comparison/dimensions/base_dimension.rb +0 -107
- data/lib/canon/comparison/dimensions/comments_dimension.rb +0 -117
- data/lib/canon/comparison/dimensions/element_position_dimension.rb +0 -86
- data/lib/canon/comparison/dimensions/structural_whitespace_dimension.rb +0 -115
- data/lib/canon/comparison/dimensions/text_content_dimension.rb +0 -102
- 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
|
-
|
|
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
|
|
8
|
-
DEFAULT_THRESHOLD = 0.10 # 10%
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
|
35
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
98
|
+
(curr_ips - base_ips) / base_ips
|
|
82
99
|
end
|
|
83
100
|
|
|
84
|
-
def
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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 "
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|