xmi 0.3.21 → 0.5.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/release.yml +13 -6
  3. data/.gitignore +2 -1
  4. data/.rubocop.yml +12 -13
  5. data/.rubocop_todo.yml +265 -12
  6. data/CHANGELOG.md +55 -0
  7. data/CODE_OF_CONDUCT.md +84 -0
  8. data/Gemfile +12 -0
  9. data/README.adoc +319 -6
  10. data/Rakefile +2 -0
  11. data/docs/migration.md +141 -0
  12. data/docs/versioning.md +255 -0
  13. data/lib/tasks/benchmark_runner.rb +274 -0
  14. data/lib/tasks/performance.rake +46 -0
  15. data/lib/tasks/performance_comparator.rb +88 -0
  16. data/lib/tasks/performance_helpers.rb +238 -0
  17. data/lib/xmi/add.rb +14 -38
  18. data/lib/xmi/{the_custom_profile.rb → custom_profile.rb} +25 -25
  19. data/lib/xmi/delete.rb +14 -38
  20. data/lib/xmi/difference.rb +14 -38
  21. data/lib/xmi/documentation.rb +16 -101
  22. data/lib/xmi/ea_root.rb +114 -33
  23. data/lib/xmi/extension.rb +6 -6
  24. data/lib/xmi/namespace/dynamic.rb +28 -0
  25. data/lib/xmi/namespace/omg.rb +81 -0
  26. data/lib/xmi/namespace/sparx.rb +39 -0
  27. data/lib/xmi/namespace.rb +9 -0
  28. data/lib/xmi/namespace_detector.rb +138 -0
  29. data/lib/xmi/namespace_registry.rb +119 -0
  30. data/lib/xmi/parsing.rb +116 -0
  31. data/lib/xmi/replace.rb +14 -38
  32. data/lib/xmi/root.rb +49 -213
  33. data/lib/xmi/sparx/connector.rb +241 -0
  34. data/lib/xmi/sparx/custom_profile.rb +19 -0
  35. data/lib/xmi/sparx/diagram.rb +97 -0
  36. data/lib/xmi/sparx/ea_stub.rb +20 -0
  37. data/lib/xmi/{extensions/eauml.rb → sparx/ea_uml.rb} +3 -2
  38. data/lib/xmi/sparx/element.rb +453 -0
  39. data/lib/xmi/sparx/extension.rb +43 -0
  40. data/lib/xmi/{extensions → sparx}/gml.rb +9 -3
  41. data/lib/xmi/sparx/mappings/base_mapping.rb +182 -0
  42. data/lib/xmi/sparx/mappings.rb +10 -0
  43. data/lib/xmi/sparx/primitive_type.rb +18 -0
  44. data/lib/xmi/sparx/root.rb +60 -0
  45. data/lib/xmi/sparx/sys_ph_s.rb +18 -0
  46. data/lib/xmi/sparx.rb +17 -1376
  47. data/lib/xmi/type.rb +37 -0
  48. data/lib/xmi/uml.rb +191 -469
  49. data/lib/xmi/v20110701.rb +81 -0
  50. data/lib/xmi/v20131001.rb +68 -0
  51. data/lib/xmi/v20161101.rb +61 -0
  52. data/lib/xmi/version.rb +1 -1
  53. data/lib/xmi/version_registry.rb +167 -0
  54. data/lib/xmi/versioned.rb +145 -0
  55. data/lib/xmi.rb +83 -11
  56. data/xmi.gemspec +3 -9
  57. metadata +40 -77
@@ -0,0 +1,255 @@
1
+ # XMI Version Support
2
+
3
+ ## Overview
4
+
5
+ XMI files come in different versions with different namespace URIs. This gem handles version differences through namespace-bound registers that enable version-aware type resolution.
6
+
7
+ ## Version-Specific Models
8
+
9
+ Some XMI elements differ between versions:
10
+
11
+ | Element | XMI 2.1 | XMI 2.5.1 | XMI 2.5.2 |
12
+ |---------|----------|------------|------------|
13
+ | Extension | v1 | v1 (reused) | v2 |
14
+ | Documentation | v1 | v2 | v2 (reused) |
15
+ | Model | v1 | v1 (reused) | v1 (reused) |
16
+
17
+ Where versions differ, the gem uses version-specific model classes through a fallback chain.
18
+
19
+ ## Fallback Chain
20
+
21
+ ```
22
+ xmi_20161101
23
+ ↓ fallback
24
+ xmi_20131001
25
+ ↓ fallback
26
+ xmi_20110701
27
+ ↓ fallback
28
+ xmi_common
29
+ ↓ fallback
30
+ default
31
+ ```
32
+
33
+ Types not found in a version register fall back to older versions.
34
+
35
+ ## Using Version-Aware Parsing
36
+
37
+ ### Automatic Version Detection
38
+
39
+ ```ruby
40
+ require 'xmi'
41
+
42
+ # Parse with automatic version detection
43
+ doc = Xmi.parse(xml_content)
44
+
45
+ # Get version information
46
+ info = Xmi::Parsing.detect_version(xml_content)
47
+ info[:xmi_version] # => "20131001"
48
+ info[:uml_version] # => "20131001"
49
+ info[:uris][:xmi] # => "http://www.omg.org/spec/XMI/20131001"
50
+ ```
51
+
52
+ ### Explicit Version
53
+
54
+ ```ruby
55
+ # Parse with explicit version
56
+ doc = Xmi.parse_with_version(xml_content, "20131001")
57
+
58
+ # Check supported versions
59
+ Xmi::Parsing.supported_versions # => ["20110701", "20131001", "20161101"]
60
+ Xmi::Parsing.version_supported?("20131001") # => true
61
+ ```
62
+
63
+ ### Sparx EA Files
64
+
65
+ ```ruby
66
+ # For Enterprise Architect generated XMI files
67
+ doc = Xmi::Sparx::SparxRoot.parse_xml_with_versioning(xml_content)
68
+ ```
69
+
70
+ ## Version Modules
71
+
72
+ The gem provides version-specific modules:
73
+
74
+ ```ruby
75
+ # XMI 2.1 (20110701)
76
+ Xmi::V20110701.register # Register for this version
77
+ Xmi::V20110701::Extension # Version-specific model
78
+ Xmi::V20110701::Documentation # Version-specific model
79
+
80
+ # XMI 2.5.1 (20131001)
81
+ Xmi::V20131001.register
82
+ Xmi::V20131001::Documentation # Different from V20110701
83
+
84
+ # XMI 2.5.2 (20161101)
85
+ Xmi::V20161101.register
86
+ Xmi::V20161101::Extension # Different from previous versions
87
+ ```
88
+
89
+ ## Register Fallback Resolution
90
+
91
+ You can resolve types through the fallback chain:
92
+
93
+ ```ruby
94
+ # Initialize versioning
95
+ Xmi.init_versioning!
96
+
97
+ # Get register for a version
98
+ register = Xmi::V20131001.register
99
+
100
+ # Resolve type with namespace awareness
101
+ klass = register.resolve_in_namespace(
102
+ :extension,
103
+ "http://www.omg.org/spec/XMI/20131001"
104
+ )
105
+ # Returns Xmi::V20110701::Extension (found via fallback)
106
+ ```
107
+
108
+ ### Mixed namespace documents
109
+
110
+ XMI documents frequently use different namespace versions for XMI, UML, UMLDI, and
111
+ UMLDC. For example, an XMI 2.5.1 file may declare:
112
+
113
+ ```xml
114
+ <xmi:XMI xmlns:xmi="http://www.omg.org/spec/XMI/20131001"
115
+ xmlns:uml="http://www.omg.org/spec/UML/20161101">
116
+ ```
117
+
118
+ The XMI namespace is version 20131001, but the UML namespace is 20161101.
119
+ This gem handles mixed namespace documents automatically through the
120
+ `Xmi::VersionRegistry.detect_register` method.
121
+
122
+ #### How mixed namespace detection works
123
+
124
+ `detect_register` performs these steps:
125
+
126
+ 1. **Detect all namespace versions** from the document's namespace declarations:
127
+ - XMI namespace version (e.g., 20131001)
128
+ - UML namespace version (e.g., 20161101)
129
+ - UMLDI namespace version (if present)
130
+ - UMLDC namespace version (if present)
131
+
132
+ 2. **Get the primary register** for the XMI namespace version (the primary register
133
+ determines the overall model tree structure).
134
+
135
+ 3. **Extend the fallback chain** for additional namespace versions:
136
+ - Bind the additional namespace URIs to the primary register using proper
137
+ `Lutaml::Xml::Namespace` subclasses from the namespace registry.
138
+ - Add the additional register to the primary register's fallback chain, so
139
+ type resolution can find version-specific types.
140
+
141
+ 4. **Prevent cycles**: If the additional register's fallback chain already
142
+ includes the primary register, no extension is made. This prevents infinite
143
+ loops in type resolution.
144
+
145
+ ```ruby
146
+ # Detect mixed namespaces and get configured register
147
+ register = Xmi::VersionRegistry.detect_register(xml_content)
148
+ # The returned register:
149
+ # - Is bound to all namespace URIs present in the document
150
+ # - Has an extended fallback chain for additional version registers
151
+ # - Resolves types correctly regardless of which namespace they appear in
152
+
153
+ doc = ModelClass.from_xml(xml_content, register: register)
154
+ ```
155
+
156
+ #### Fallback chain extension for mixed namespaces
157
+
158
+ Given a document with XMI=20131001 and UML=20161101, the register's fallback chain
159
+ is extended as follows:
160
+
161
+ ```
162
+ Primary: xmi_20131001
163
+ Handles: XMI 20131001, UML 20131001 namespaces
164
+
165
+ Extended fallback: xmi_20161101 (added because UML=20161101 was detected)
166
+ Handles: XMI 20161101, UML 20161101 namespaces
167
+ Own fallback: xmi_20131001 (already has XMI 20131001)
168
+ ```
169
+
170
+ This allows the parser to resolve:
171
+
172
+ - Types specific to the UML 20161101 version (via the extended fallback)
173
+ - Types from the primary XMI 20131001 version (via the primary register)
174
+
175
+ ### Version-specific namespace binding
176
+
177
+ Each version register is bound to its specific namespace URIs:
178
+
179
+ ```ruby
180
+ register = Xmi::V20131001.register
181
+ register.bound_namespace_uris
182
+ # => ["http://www.omg.org/spec/XMI/20131001",
183
+ # "http://www.omg.org/spec/UML/20131001",
184
+ # "http://www.omg.org/spec/UML/20131001/UMLDI",
185
+ # "http://www.omg.org/spec/UML/20131001/UMLDC"]
186
+
187
+ register.handles_namespace?("http://www.omg.org/spec/XMI/20131001")
188
+ # => true
189
+
190
+ register.handles_namespace?("http://www.omg.org/spec/XMI/20161101")
191
+ # => false (different version)
192
+ ```
193
+
194
+ ## Programmatic Version Detection
195
+
196
+ ```ruby
197
+ require 'xmi'
198
+
199
+ # Detect version from XML content
200
+ xml = <<~XML
201
+ <?xml version="1.0"?>
202
+ <xmi:XMI xmlns:xmi="http://www.omg.org/spec/XMI/20110701"
203
+ xmlns:uml="http://www.omg.org/spec/UML/20110701">
204
+ <xmi:Documentation>...</xmi:Documentation>
205
+ </xmi:XMI>
206
+ XML
207
+
208
+ info = Xmi::Parsing.detect_version(xml)
209
+ # => { versions: { xmi: "20110701", uml: "20110701", ... },
210
+ # uris: { xmi: "http://...", uml: "http://...", ... },
211
+ # xmi_version: "20110701", uml_version: "20110701" }
212
+ ```
213
+
214
+ ## Error Handling
215
+
216
+ Unknown versions are handled gracefully:
217
+
218
+ ```ruby
219
+ # Unknown version raises ArgumentError when explicitly specified
220
+ Xmi.parse_with_version(xml, "19990101")
221
+ # => ArgumentError: Unknown version: 19990101
222
+
223
+ # Unknown version falls back to default parsing when auto-detected
224
+ doc = Xmi.parse(unknown_version_xml)
225
+ # Works, but may not resolve version-specific types correctly
226
+ ```
227
+
228
+ ## API Reference
229
+
230
+ ### Module Methods
231
+
232
+ | Method | Description |
233
+ |--------|-------------|
234
+ | `Xmi.parse(xml)` | Parse with auto-detection |
235
+ | `Xmi.parse_with_version(xml, version)` | Parse with explicit version |
236
+ | `Xmi.init_versioning!` | Initialize all version registers |
237
+ | `Xmi.versioning_initialized?` | Check initialization status |
238
+
239
+ ### Parsing Module Methods
240
+
241
+ | Method | Description |
242
+ |--------|-------------|
243
+ | `Xmi::Parsing.parse(xml, **options)` | Parse with options |
244
+ | `Xmi::Parsing.parse_file(path, **options)` | Parse from file |
245
+ | `Xmi::Parsing.detect_version(xml)` | Detect version info |
246
+ | `Xmi::Parsing.supported_versions` | List supported versions |
247
+ | `Xmi::Parsing.version_supported?(version)` | Check if version supported |
248
+
249
+ ### Version Registry Methods
250
+
251
+ | Method | Description |
252
+ |--------|-------------|
253
+ | `Xmi::VersionRegistry.register_for_version(version)` | Get register for version |
254
+ | `Xmi::VersionRegistry.register_for_namespace(uri)` | Get register for namespace |
255
+ | `Xmi::VersionRegistry.detect_register(xml)` | Detect and get register |
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "benchmark/ips"
4
+
5
+ # Ensure lib/ is on the load path regardless of tmp location
6
+ lib_path = File.expand_path(File.join(__dir__, "..", "..", "lib"))
7
+ $LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
8
+
9
+ require "xmi"
10
+
11
+ # Pretty terminal formatting for benchmark output
12
+ module Term
13
+ CLEAR = "\e[0m"
14
+ BOLD = "\e[1m"
15
+ DIM = "\e[2m"
16
+ RED = "\e[31m"
17
+ GREEN = "\e[32m"
18
+ YELLOW = "\e[33m"
19
+ CYAN = "\e[36m"
20
+ MAGENTA = "\e[35m"
21
+
22
+ HL = "─"
23
+ VL = "│"
24
+ TL = "┌"
25
+ TR = "┐"
26
+ BL = "└"
27
+ BR = "┘"
28
+
29
+ def self.header(title, color: CYAN)
30
+ width = 78
31
+ line = HL * width
32
+ puts
33
+ puts "#{color}#{TL}#{line}#{TR}#{CLEAR}"
34
+ puts "#{color}#{VL}#{CLEAR} #{BOLD}#{color}#{title}#{CLEAR}#{' ' * (width - title.length - 4)}#{color}#{VL}#{CLEAR}"
35
+ puts "#{color}#{BL}#{line}#{BR}#{CLEAR}"
36
+ end
37
+
38
+ def self.sep(char: HL, width: 78)
39
+ puts "#{DIM}#{char * width}#{CLEAR}"
40
+ end
41
+
42
+ def self.env_info(ruby_version, platform)
43
+ puts
44
+ puts " #{DIM}Environment:#{CLEAR}"
45
+ puts " #{VL} Ruby #{ruby_version} on #{platform}#{' ' * (60 - ruby_version.length - platform.length)}#{VL}"
46
+ puts " #{DIM}#{BL}#{HL * 76}#{BR}#{CLEAR}"
47
+ puts
48
+ end
49
+
50
+ def self.category(title, icon:, description:, failure_means:,
51
+ compare_against: nil)
52
+ puts
53
+ puts "#{CYAN}#{VL}#{CLEAR} #{BOLD}#{MAGENTA}#{icon} #{title}#{CLEAR}"
54
+ puts
55
+ puts " #{DIM}#{description}#{CLEAR}"
56
+ puts
57
+
58
+ if compare_against
59
+ puts " #{CYAN}Comparing against:#{CLEAR} #{compare_against}"
60
+ puts
61
+ end
62
+
63
+ puts " #{YELLOW}⚠️ Failure means:#{CLEAR} #{failure_means}"
64
+ puts
65
+ sep(width: 76)
66
+ puts
67
+ end
68
+ end
69
+
70
+ class BenchmarkRunner
71
+ REPO_ROOT = File.expand_path(File.join(__dir__, "..", ".."))
72
+
73
+ # Benchmark configuration
74
+ DEFAULT_RUN_TIME = 5
75
+ DEFAULT_WARMUP = 2
76
+
77
+ # Category definitions with descriptions
78
+ CATEGORIES = {
79
+ xmi_parsing: {
80
+ name: "XMI Parsing",
81
+ icon: "📄",
82
+ description: "XMI parsing performance tests. Measures how quickly we can convert XMI files into Ruby objects.",
83
+ failure_means: "Slow XMI parsing impacts all downstream operations. A regression here means users will experience delays when processing XMI documents.",
84
+ compare_against: "Previous branch (main).",
85
+ },
86
+ }.freeze
87
+
88
+ # Test definitions
89
+ BENCHMARKS = {
90
+ xmi_parsing: [
91
+ { name: "XMI 2.4.2 (small)", method: :xmi_parse_242_small,
92
+ desc: "XMI 2.4.2 ~100KB file" },
93
+ { name: "XMI 2.4.2 (medium)", method: :xmi_parse_242_medium,
94
+ desc: "XMI 2.4.2 ~500KB file with extensions" },
95
+ { name: "XMI 2.4.2 (large)", method: :xmi_parse_242_large,
96
+ desc: "XMI 2.4.2 ~3.5MB file" },
97
+ { name: "XMI 2.5.1", method: :xmi_parse_251,
98
+ desc: "XMI 2.5.1 ~100KB file" },
99
+ ],
100
+ }.freeze
101
+
102
+ # Test data - fixture paths
103
+ FIXTURES = {
104
+ xmi_parse_242_small: "spec/fixtures/xmi-v2-4-2-default.xmi",
105
+ xmi_parse_242_medium: "spec/fixtures/xmi-v2-4-2-default-with-citygml.xmi",
106
+ xmi_parse_242_large: "spec/fixtures/full-242.xmi",
107
+ xmi_parse_251: "spec/fixtures/ea-xmi-2.5.1.xmi",
108
+ }.freeze
109
+
110
+ def initialize(run_time: nil, warmup: nil, benchmark: nil)
111
+ @run_time = run_time || DEFAULT_RUN_TIME
112
+ @warmup = warmup || DEFAULT_WARMUP
113
+ @benchmark = benchmark
114
+ @results = {}
115
+ @env_shown = false
116
+ @all_results = []
117
+ end
118
+
119
+ def run_benchmarks
120
+ Term.header("XMI Performance Benchmarks", color: Term::CYAN)
121
+
122
+ unless @env_shown
123
+ Term.env_info(RUBY_VERSION, RUBY_PLATFORM)
124
+ @env_shown = true
125
+ end
126
+
127
+ BENCHMARKS.each do |category, tests|
128
+ run_category(category, tests)
129
+ end
130
+
131
+ print_summary
132
+
133
+ @results
134
+ end
135
+
136
+ private
137
+
138
+ def run_category(category, tests)
139
+ config = CATEGORIES[category]
140
+ Term.category(
141
+ config[:name],
142
+ icon: config[:icon],
143
+ description: config[:description],
144
+ failure_means: config[:failure_means],
145
+ compare_against: config[:compare_against],
146
+ )
147
+
148
+ category_results = []
149
+
150
+ tests.each do |test|
151
+ # Redirect stdout during benchmark
152
+ original_stdout = $stdout
153
+ $stdout = StringIO.new
154
+
155
+ result = run_single_test(test[:method])
156
+ (result[:lower] + result[:upper]) / 2.0
157
+ category_results << { name: test[:name], result: result }
158
+
159
+ # Restore stdout
160
+ $stdout = original_stdout
161
+ end
162
+
163
+ # Print results
164
+ puts " #{'Benchmark'.ljust(40)} #{'IPS'.rjust(12)} #{'Deviation'.rjust(12)}"
165
+ puts " #{Term::DIM}#{Term::HL * 66}#{Term::CLEAR}"
166
+
167
+ category_results.each do |r|
168
+ ips = (r[:result][:lower] + r[:result][:upper]) / 2.0
169
+ deviation = calculate_deviation(r[:result])
170
+ label = "#{config[:name]}: #{r[:name]}"
171
+ @all_results << { label: label, ips: ips }
172
+ @results[label] = r[:result]
173
+
174
+ puts " #{r[:name].ljust(40)} #{format('%.2f',
175
+ ips).rjust(12)} #{format('%.1f%%',
176
+ deviation).rjust(12)}"
177
+ end
178
+
179
+ puts
180
+ end
181
+
182
+ def run_single_test(method)
183
+ fixture_path = FIXTURES[method]
184
+ raise "Unknown fixture: #{method}" unless fixture_path
185
+
186
+ # Try to resolve fixture path relative to REPO_ROOT
187
+ full_path = File.join(REPO_ROOT, fixture_path)
188
+ unless File.exist?(full_path)
189
+ # Fallback: try current directory
190
+ full_path = fixture_path
191
+ end
192
+
193
+ xml_content = File.read(full_path)
194
+
195
+ case method
196
+ when :xmi_parse_242_small, :xmi_parse_242_medium, :xmi_parse_242_large, :xmi_parse_251
197
+ measure_time { Xmi::Sparx::SparxRoot.parse_xml(xml_content) }
198
+ else
199
+ raise "Unknown benchmark: #{method}"
200
+ end
201
+ end
202
+
203
+ def measure(&)
204
+ job = Benchmark::IPS::Job.new
205
+ job.config(time: @run_time, warmup: @warmup)
206
+ job.report("test", &)
207
+ job.run
208
+
209
+ entry = job.full_report.entries.first
210
+ samples = entry.stats.samples
211
+
212
+ return { lower: 0, upper: 0 } if samples.empty?
213
+
214
+ mean = samples.sum.to_f / samples.size
215
+ variance = samples.sum { |x| (x - mean)**2 } / (samples.size - 1)
216
+ std_dev = Math.sqrt(variance)
217
+ error_margin = std_dev / mean
218
+ error_pct = error_margin.round(4)
219
+
220
+ { lower: mean.round(4) * (1 - error_pct),
221
+ upper: mean.round(4) * (1 + error_pct) }
222
+ end
223
+
224
+ def measure_time
225
+ times = []
226
+ iterations = 5
227
+
228
+ iterations.times do
229
+ start_t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
230
+ yield
231
+ finish_t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
232
+ times << (finish_t - start_t)
233
+ end
234
+
235
+ mean = times.sum / times.size
236
+ variance = times.sum { |t| (t - mean)**2 } / (times.size - 1)
237
+ std_dev = Math.sqrt(variance)
238
+
239
+ # Use conservative estimates for time-based measurement
240
+ lower_time = [mean - std_dev, mean * 0.5].max
241
+ lower_ips = (1.0 / (lower_time * 1.5)).round(4)
242
+ upper_ips = (1.0 / mean).round(4)
243
+
244
+ # For fast operations, estimate more conservatively
245
+ if mean < 0.001
246
+ upper_ips = (1.0 / mean).round(4)
247
+ lower_ips = (upper_ips * 0.8).round(4)
248
+ end
249
+
250
+ { lower: lower_ips, upper: upper_ips }
251
+ end
252
+
253
+ def calculate_deviation(metrics)
254
+ return 0 if metrics[:upper].zero?
255
+
256
+ ((metrics[:upper] - metrics[:lower]) / metrics[:upper] * 100).round(1)
257
+ end
258
+
259
+ def print_summary
260
+ puts
261
+ Term.sep(width: 78)
262
+ puts
263
+ puts " #{Term::BOLD}#{Term::MAGENTA}SUMMARY#{Term::CLEAR}"
264
+ puts
265
+
266
+ @all_results.each do |r|
267
+ puts " #{r[:label].ljust(60)} #{format('%.2f', r[:ips]).rjust(10)} IPS"
268
+ end
269
+
270
+ puts
271
+ puts " #{Term::DIM}#{@all_results.length} benchmarks completed#{Term::CLEAR}"
272
+ puts
273
+ end
274
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "performance_comparator"
4
+ require_relative "benchmark_runner"
5
+
6
+ desc "Run performance benchmarks"
7
+ namespace :performance do
8
+ desc "Compare performance of current branch against base branch (default: main)"
9
+ task :compare do
10
+ PerformanceComparator.new.run
11
+ end
12
+
13
+ desc "Run benchmarks on current branch only (for development)"
14
+ task :run do
15
+ runner = BenchmarkRunner.new(run_time: 5)
16
+ runner.run_benchmarks
17
+ end
18
+
19
+ desc "Quick benchmark run (faster, less accurate)"
20
+ task :quick do
21
+ runner = BenchmarkRunner.new(run_time: 2, warmup: 1)
22
+ runner.run_benchmarks
23
+ end
24
+
25
+ desc "Run benchmarks and output as JSON"
26
+ task :json do
27
+ require "json"
28
+ runner = BenchmarkRunner.new(run_time: 5)
29
+
30
+ # Suppress pretty output, just get results
31
+ results = runner.send(:run_benchmarks)
32
+
33
+ output = results.each_with_object({}) do |(label, metrics), h|
34
+ ips = (metrics[:lower] + metrics[:upper]) / 2.0
35
+ deviation = ((metrics[:upper] - metrics[:lower]) / metrics[:upper] * 100).round(1)
36
+ h[label] = {
37
+ ips: ips.round(2),
38
+ lower: metrics[:lower].round(2),
39
+ upper: metrics[:upper].round(2),
40
+ deviation: deviation,
41
+ }
42
+ end
43
+
44
+ puts JSON.pretty_generate(output)
45
+ end
46
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "performance_helpers"
4
+
5
+ class PerformanceComparator
6
+ REPO_ROOT = File.expand_path(File.join(__dir__, "..", ".."))
7
+ DEFAULT_RUN_TIME = 10
8
+ DEFAULT_THRESHOLD = 0.10 # 10% (more lenient for complex operations)
9
+ DEFAULT_BASE = "main"
10
+ TMP_PERF_DIR = File.join(REPO_ROOT, "tmp", "performance")
11
+ BENCH_SCRIPT = File.join(TMP_PERF_DIR, "benchmark_runner.rb")
12
+
13
+ def run
14
+ setup_environment
15
+ run_benchmarks_comparison
16
+ ensure
17
+ cleanup
18
+ end
19
+
20
+ private
21
+
22
+ def setup_environment
23
+ Dir.chdir(REPO_ROOT)
24
+ FileUtils.mkdir_p(TMP_PERF_DIR)
25
+ FileUtils.cp(File.join(REPO_ROOT, "lib", "tasks", "benchmark_runner.rb"),
26
+ BENCH_SCRIPT)
27
+
28
+ PerformanceHelpers.load_into_namespace(PerformanceHelpers::Current,
29
+ BENCH_SCRIPT)
30
+ PerformanceHelpers.clone_base_repo(DEFAULT_BASE, TMP_PERF_DIR, BENCH_SCRIPT)
31
+ end
32
+
33
+ def run_benchmarks_comparison
34
+ all_current = {}
35
+ all_base = {}
36
+
37
+ puts PerformanceHelpers::Term.header("Performance Comparison", color: PerformanceHelpers::CYAN)
38
+ puts
39
+ puts " #{PerformanceHelpers::DIM}Comparing#{PerformanceHelpers::CLEAR}:"
40
+ puts " #{PerformanceHelpers::CYAN} Current#{PerformanceHelpers::CLEAR}: #{PerformanceHelpers.current_branch}"
41
+ puts " #{PerformanceHelpers::CYAN} Base#{PerformanceHelpers::CLEAR}: #{DEFAULT_BASE}"
42
+ puts " #{PerformanceHelpers::CYAN} Threshold#{PerformanceHelpers::CLEAR}: #{(DEFAULT_THRESHOLD * 100).round(0)}% regression allowed"
43
+ puts
44
+
45
+ # Run all benchmarks
46
+ base_runner = PerformanceHelpers::Base::BenchmarkRunner.new(
47
+ run_time: DEFAULT_RUN_TIME,
48
+ )
49
+ current_runner = PerformanceHelpers::Current::BenchmarkRunner.new(
50
+ run_time: DEFAULT_RUN_TIME,
51
+ )
52
+
53
+ PerformanceHelpers.run_benchmarks(
54
+ base_runner,
55
+ current_runner,
56
+ DEFAULT_THRESHOLD,
57
+ all_base,
58
+ all_current,
59
+ )
60
+
61
+ summary = PerformanceHelpers.summary_report(
62
+ all_current,
63
+ all_base,
64
+ DEFAULT_BASE,
65
+ DEFAULT_RUN_TIME,
66
+ DEFAULT_THRESHOLD,
67
+ )
68
+
69
+ handle_results(summary)
70
+ end
71
+
72
+ def handle_results(summary)
73
+ puts
74
+ if summary[:regressions].any?
75
+ puts " #{PerformanceHelpers::RED}#{PerformanceHelpers::BOLD}❌ PERFORMANCE REGRESSIONS DETECTED#{PerformanceHelpers::CLEAR}"
76
+ puts " #{PerformanceHelpers::RED}#{summary[:regressions].length} benchmark(s) regressed beyond threshold#{PerformanceHelpers::CLEAR}"
77
+ puts
78
+ exit(1)
79
+ else
80
+ puts " #{PerformanceHelpers::GREEN}#{PerformanceHelpers::BOLD}✅ ALL BENCHMARKS PASSED#{PerformanceHelpers::CLEAR}"
81
+ puts
82
+ end
83
+ end
84
+
85
+ def cleanup
86
+ FileUtils.rm_rf(TMP_PERF_DIR)
87
+ end
88
+ end