spec_scout 0.1.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 (49) hide show
  1. checksums.yaml +7 -0
  2. data/.idea/.gitignore +10 -0
  3. data/.idea/Projects.iml +41 -0
  4. data/.idea/copilot.data.migration.ask2agent.xml +6 -0
  5. data/.idea/modules.xml +8 -0
  6. data/.idea/vcs.xml +6 -0
  7. data/.rspec_status +236 -0
  8. data/Gemfile +11 -0
  9. data/Gemfile.lock +72 -0
  10. data/LICENSE +21 -0
  11. data/README.md +433 -0
  12. data/Rakefile +12 -0
  13. data/examples/README.md +321 -0
  14. data/examples/best_practices.md +401 -0
  15. data/examples/configurations/basic_config.rb +24 -0
  16. data/examples/configurations/ci_config.rb +35 -0
  17. data/examples/configurations/conservative_config.rb +32 -0
  18. data/examples/configurations/development_config.rb +37 -0
  19. data/examples/configurations/performance_focused_config.rb +38 -0
  20. data/examples/output_formatter_demo.rb +67 -0
  21. data/examples/sample_outputs/console_output_high_confidence.txt +27 -0
  22. data/examples/sample_outputs/console_output_medium_confidence.txt +27 -0
  23. data/examples/sample_outputs/console_output_no_action.txt +27 -0
  24. data/examples/sample_outputs/console_output_risk_detected.txt +27 -0
  25. data/examples/sample_outputs/json_output_high_confidence.json +108 -0
  26. data/examples/sample_outputs/json_output_no_action.json +108 -0
  27. data/examples/workflows/basic_workflow.md +159 -0
  28. data/examples/workflows/ci_integration.md +372 -0
  29. data/exe/spec_scout +7 -0
  30. data/lib/spec_scout/agent_result.rb +44 -0
  31. data/lib/spec_scout/agents/database_agent.rb +113 -0
  32. data/lib/spec_scout/agents/factory_agent.rb +179 -0
  33. data/lib/spec_scout/agents/intent_agent.rb +223 -0
  34. data/lib/spec_scout/agents/risk_agent.rb +290 -0
  35. data/lib/spec_scout/base_agent.rb +72 -0
  36. data/lib/spec_scout/cli.rb +158 -0
  37. data/lib/spec_scout/configuration.rb +162 -0
  38. data/lib/spec_scout/consensus_engine.rb +535 -0
  39. data/lib/spec_scout/enforcement_handler.rb +182 -0
  40. data/lib/spec_scout/output_formatter.rb +307 -0
  41. data/lib/spec_scout/profile_data.rb +37 -0
  42. data/lib/spec_scout/profile_normalizer.rb +238 -0
  43. data/lib/spec_scout/recommendation.rb +62 -0
  44. data/lib/spec_scout/safety_validator.rb +127 -0
  45. data/lib/spec_scout/spec_scout.rb +519 -0
  46. data/lib/spec_scout/testprof_integration.rb +206 -0
  47. data/lib/spec_scout/version.rb +5 -0
  48. data/lib/spec_scout.rb +43 -0
  49. metadata +166 -0
@@ -0,0 +1,307 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+
6
+ module SpecScout
7
+ # Formats recommendations and analysis results into human-readable console output
8
+ # and structured JSON format
9
+ class OutputFormatter
10
+ CONFIDENCE_SYMBOLS = {
11
+ high: '✔',
12
+ medium: '⚠',
13
+ low: '?'
14
+ }.freeze
15
+
16
+ ACTION_SYMBOLS = {
17
+ replace_factory_strategy: '✔',
18
+ avoid_db_persistence: '✔',
19
+ optimize_queries: '✔',
20
+ no_action: '—',
21
+ review_test_intent: '⚠',
22
+ assess_risk_factors: '⚠'
23
+ }.freeze
24
+
25
+ def initialize(recommendation, profile_data)
26
+ @recommendation = recommendation
27
+ @profile_data = profile_data
28
+ validate_inputs!
29
+ end
30
+
31
+ # Generate human-readable console output
32
+ def format_recommendation
33
+ output = []
34
+
35
+ output << format_header
36
+ output << format_spec_location
37
+ output << ''
38
+ output << format_profiling_summary
39
+ output << ''
40
+ output << format_agent_opinions
41
+ output << ''
42
+ output << format_final_recommendation
43
+
44
+ output.join("\n")
45
+ end
46
+
47
+ # Generate structured JSON output
48
+ def format_json
49
+ json_data = {
50
+ spec_location: recommendation.spec_location,
51
+ action: recommendation.action.to_s,
52
+ from_value: recommendation.from_value,
53
+ to_value: recommendation.to_value,
54
+ confidence: recommendation.confidence.to_s,
55
+ explanation: recommendation.explanation,
56
+ agent_results: format_agent_results_json,
57
+ profile_data: format_profile_data_json,
58
+ metadata: {
59
+ timestamp: Time.now.iso8601,
60
+ spec_scout_version: VERSION
61
+ }
62
+ }
63
+
64
+ JSON.pretty_generate(json_data)
65
+ end
66
+
67
+ private
68
+
69
+ attr_reader :recommendation, :profile_data
70
+
71
+ def validate_inputs!
72
+ unless recommendation.is_a?(Recommendation) && recommendation.valid?
73
+ raise ArgumentError, 'Invalid recommendation provided'
74
+ end
75
+
76
+ return if profile_data.is_a?(ProfileData) && profile_data.valid?
77
+
78
+ raise ArgumentError, 'Invalid profile data provided'
79
+ end
80
+
81
+ def format_header
82
+ confidence_symbol = CONFIDENCE_SYMBOLS[recommendation.confidence]
83
+ "#{confidence_symbol} Spec Scout Recommendation"
84
+ end
85
+
86
+ def format_spec_location
87
+ recommendation.spec_location
88
+ end
89
+
90
+ def format_profiling_summary
91
+ lines = ['Summary:']
92
+
93
+ # Factory usage summary
94
+ if profile_data.factories.any?
95
+ factory_summary = format_factory_summary
96
+ lines << "- #{factory_summary}" if factory_summary
97
+ end
98
+
99
+ # Database usage summary
100
+ if profile_data.db.any?
101
+ db_summary = format_db_summary
102
+ lines << "- #{db_summary}" if db_summary
103
+ end
104
+
105
+ # Runtime summary
106
+ lines << "- Runtime: #{profile_data.runtime_ms}ms" if profile_data.runtime_ms.positive?
107
+
108
+ # Spec type
109
+ lines << "- Type: #{profile_data.spec_type} spec" if profile_data.spec_type != :unknown
110
+
111
+ lines.join("\n")
112
+ end
113
+
114
+ def format_factory_summary
115
+ return nil unless profile_data.factories.is_a?(Hash) && profile_data.factories.any?
116
+
117
+ factory_parts = []
118
+ profile_data.factories.each do |factory_name, factory_info|
119
+ next unless factory_info.is_a?(Hash)
120
+
121
+ strategy = factory_info[:strategy] || 'unknown'
122
+ count = factory_info[:count] || 1
123
+ count_text = count > 1 ? " (#{count}x)" : ''
124
+ factory_parts << "Factory :#{factory_name} used `#{strategy}`#{count_text}"
125
+ end
126
+
127
+ factory_parts.join(', ')
128
+ end
129
+
130
+ def format_db_summary
131
+ return nil unless profile_data.db.is_a?(Hash) && profile_data.db.any?
132
+
133
+ db_parts = []
134
+
135
+ if profile_data.db[:inserts]
136
+ inserts = profile_data.db[:inserts]
137
+ db_parts << "DB inserts: #{inserts}"
138
+ end
139
+
140
+ if profile_data.db[:selects]
141
+ selects = profile_data.db[:selects]
142
+ db_parts << "selects: #{selects}"
143
+ end
144
+
145
+ if profile_data.db[:total_queries]
146
+ total = profile_data.db[:total_queries]
147
+ db_parts << "Total queries: #{total}"
148
+ end
149
+
150
+ db_parts.join(', ')
151
+ end
152
+
153
+ def format_agent_opinions
154
+ return 'Agent Signals:\n- No agent results available' if recommendation.agent_results.empty?
155
+
156
+ lines = ['Agent Signals:']
157
+
158
+ recommendation.agent_results.each do |agent_result|
159
+ agent_line = format_agent_opinion(agent_result)
160
+ lines << "- #{agent_line}"
161
+ end
162
+
163
+ lines.join("\n")
164
+ end
165
+
166
+ def format_agent_opinion(agent_result)
167
+ agent_name = humanize_agent_name(agent_result.agent_name)
168
+ verdict = humanize_verdict(agent_result.verdict)
169
+ confidence = agent_result.confidence.to_s.upcase
170
+ confidence_symbol = CONFIDENCE_SYMBOLS[agent_result.confidence]
171
+
172
+ "#{agent_name}: #{verdict} (#{confidence_symbol} #{confidence})"
173
+ end
174
+
175
+ def humanize_agent_name(agent_name)
176
+ case agent_name
177
+ when :database
178
+ 'Database Agent'
179
+ when :factory
180
+ 'Factory Agent'
181
+ when :intent
182
+ 'Intent Agent'
183
+ when :risk
184
+ 'Risk Agent'
185
+ else
186
+ agent_name.to_s.split('_').map(&:capitalize).join(' ')
187
+ end
188
+ end
189
+
190
+ def humanize_verdict(verdict)
191
+ case verdict
192
+ when :db_unnecessary
193
+ 'DB unnecessary'
194
+ when :db_required
195
+ 'DB required'
196
+ when :db_unclear
197
+ 'DB usage unclear'
198
+ when :prefer_build_stubbed
199
+ 'prefer build_stubbed'
200
+ when :create_required
201
+ 'create required'
202
+ when :strategy_optimal
203
+ 'strategy optimal'
204
+ when :unit_test_behavior
205
+ 'unit test behavior'
206
+ when :integration_test_behavior
207
+ 'integration test behavior'
208
+ when :intent_unclear
209
+ 'intent unclear'
210
+ when :safe_to_optimize
211
+ 'safe to optimize'
212
+ when :potential_side_effects
213
+ 'potential side effects'
214
+ when :high_risk
215
+ 'high risk detected'
216
+ when :no_verdict
217
+ 'no verdict'
218
+ else
219
+ verdict.to_s.gsub('_', ' ')
220
+ end
221
+ end
222
+
223
+ def format_final_recommendation
224
+ lines = ['Final Recommendation:']
225
+
226
+ action_symbol = ACTION_SYMBOLS[recommendation.action]
227
+ action_text = format_action_text
228
+ confidence_text = format_confidence_text
229
+
230
+ lines << "#{action_symbol} #{action_text}"
231
+ lines << "Confidence: #{confidence_text}"
232
+
233
+ # Add explanation if available
234
+ if recommendation.explanation.any?
235
+ lines << ''
236
+ lines << 'Reasoning:'
237
+ recommendation.explanation.each do |explanation_line|
238
+ lines << "- #{explanation_line}"
239
+ end
240
+ end
241
+
242
+ lines.join("\n")
243
+ end
244
+
245
+ def format_action_text
246
+ case recommendation.action
247
+ when :replace_factory_strategy
248
+ if !recommendation.from_value.empty? && !recommendation.to_value.empty?
249
+ "Replace `#{recommendation.from_value}` with `#{recommendation.to_value}`"
250
+ else
251
+ 'Replace factory strategy with build_stubbed'
252
+ end
253
+ when :avoid_db_persistence
254
+ if !recommendation.from_value.empty? && !recommendation.to_value.empty?
255
+ "Change from #{recommendation.from_value} to #{recommendation.to_value}"
256
+ else
257
+ 'Avoid database persistence - use build_stubbed instead of create'
258
+ end
259
+ when :optimize_queries
260
+ 'Optimize database queries'
261
+ when :review_test_intent
262
+ 'Review test intent and boundaries'
263
+ when :assess_risk_factors
264
+ 'Assess risk factors before optimizing'
265
+ when :no_action
266
+ 'No optimization recommended'
267
+ else
268
+ humanize_action(recommendation.action)
269
+ end
270
+ end
271
+
272
+ def humanize_action(action)
273
+ action.to_s.gsub('_', ' ').split.map(&:capitalize).join(' ')
274
+ end
275
+
276
+ def format_confidence_text
277
+ confidence_symbol = CONFIDENCE_SYMBOLS[recommendation.confidence]
278
+ confidence_name = recommendation.confidence.to_s.upcase
279
+
280
+ "#{confidence_symbol} #{confidence_name}"
281
+ end
282
+
283
+ def format_agent_results_json
284
+ recommendation.agent_results.map do |agent_result|
285
+ {
286
+ agent_name: agent_result.agent_name.to_s,
287
+ verdict: agent_result.verdict.to_s,
288
+ confidence: agent_result.confidence.to_s,
289
+ reasoning: agent_result.reasoning,
290
+ metadata: agent_result.metadata
291
+ }
292
+ end
293
+ end
294
+
295
+ def format_profile_data_json
296
+ {
297
+ example_location: profile_data.example_location,
298
+ spec_type: profile_data.spec_type.to_s,
299
+ runtime_ms: profile_data.runtime_ms,
300
+ factories: profile_data.factories,
301
+ db: profile_data.db,
302
+ events: profile_data.events,
303
+ metadata: profile_data.metadata
304
+ }
305
+ end
306
+ end
307
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecScout
4
+ # Normalized profile data structure containing test execution metrics,
5
+ # factory usage, and database interactions extracted from TestProf
6
+ ProfileData = Struct.new(
7
+ :example_location, # String: "spec/models/user_spec.rb:42"
8
+ :spec_type, # Symbol: :model, :controller, :integration
9
+ :runtime_ms, # Numeric: 38
10
+ :factories, # Hash: { user: { strategy: :create, count: 1 } }
11
+ :db, # Hash: { total_queries: 6, inserts: 1, selects: 5 }
12
+ :events, # Hash: EventProf data
13
+ :metadata, # Hash: Additional context
14
+ keyword_init: true
15
+ ) do
16
+ def initialize(**args)
17
+ super
18
+ self.example_location ||= ''
19
+ self.spec_type ||= :unknown
20
+ self.runtime_ms ||= 0
21
+ self.factories ||= {}
22
+ self.db ||= {}
23
+ self.events ||= {}
24
+ self.metadata ||= {}
25
+ end
26
+
27
+ def valid?
28
+ example_location.is_a?(String) &&
29
+ spec_type.is_a?(Symbol) &&
30
+ runtime_ms.is_a?(Numeric) &&
31
+ factories.is_a?(Hash) &&
32
+ db.is_a?(Hash) &&
33
+ events.is_a?(Hash) &&
34
+ metadata.is_a?(Hash)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecScout
4
+ # Converts TestProf output formats to ProfileData schema
5
+ class ProfileNormalizer
6
+ class NormalizationError < StandardError; end
7
+
8
+ def initialize
9
+ @current_example_location = nil
10
+ end
11
+
12
+ # Convert TestProf output to normalized ProfileData
13
+ def normalize(testprof_data, example_context = {})
14
+ validate_input(testprof_data)
15
+
16
+ ProfileData.new(
17
+ example_location: extract_example_location(example_context),
18
+ spec_type: infer_spec_type(example_context),
19
+ runtime_ms: extract_runtime(testprof_data, example_context),
20
+ factories: normalize_factory_data(testprof_data),
21
+ db: normalize_db_data(testprof_data),
22
+ events: normalize_event_data(testprof_data),
23
+ metadata: extract_metadata(testprof_data, example_context)
24
+ )
25
+ rescue StandardError => e
26
+ raise NormalizationError, "Failed to normalize TestProf data: #{e.message}"
27
+ end
28
+
29
+ # Set current example context for normalization
30
+ def set_example_context(location)
31
+ @current_example_location = location
32
+ end
33
+
34
+ private
35
+
36
+ def validate_input(testprof_data)
37
+ return if testprof_data.is_a?(Hash)
38
+
39
+ raise NormalizationError, "TestProf data must be a Hash, got #{testprof_data.class}"
40
+ end
41
+
42
+ def extract_example_location(example_context)
43
+ # Try multiple sources for example location
44
+ location = example_context[:location] ||
45
+ example_context[:file_path] ||
46
+ @current_example_location ||
47
+ ''
48
+
49
+ # Ensure location is a string
50
+ location.to_s
51
+ end
52
+
53
+ def infer_spec_type(example_context)
54
+ location = extract_example_location(example_context)
55
+
56
+ case location
57
+ when %r{spec/models/}
58
+ :model
59
+ when %r{spec/controllers/}
60
+ :controller
61
+ when %r{spec/requests/}
62
+ :request
63
+ when %r{spec/features/}
64
+ :feature
65
+ when %r{spec/integration/}
66
+ :integration
67
+ when %r{spec/system/}
68
+ :system
69
+ when %r{spec/lib/}
70
+ :lib
71
+ when %r{spec/helpers/}
72
+ :helper
73
+ when %r{spec/views/}
74
+ :view
75
+ else
76
+ :unknown
77
+ end
78
+ end
79
+
80
+ def extract_runtime(testprof_data, example_context)
81
+ # Try to extract runtime from various sources
82
+ runtime = example_context[:runtime] ||
83
+ example_context[:duration] ||
84
+ testprof_data.dig(:metadata, :runtime) ||
85
+ 0
86
+
87
+ # Convert to milliseconds if needed
88
+ case runtime
89
+ when Numeric
90
+ runtime < 1 ? (runtime * 1000).round(2) : runtime.round(2)
91
+ else
92
+ 0
93
+ end
94
+ end
95
+
96
+ def normalize_factory_data(testprof_data)
97
+ factory_data = testprof_data[:factory_prof] || {}
98
+ return {} if factory_data.empty?
99
+
100
+ normalized = {}
101
+
102
+ # Handle FactoryProf stats format
103
+ factory_data[:stats]&.each do |factory_name, stats|
104
+ normalized[factory_name] = {
105
+ strategy: stats[:strategy] || :unknown,
106
+ count: stats[:count] || 0,
107
+ time: stats[:time] || 0.0
108
+ }
109
+ end
110
+
111
+ # Handle alternative factory data formats
112
+ factory_data[:factories]&.each do |factory_name, factory_info|
113
+ normalized[factory_name] = normalize_single_factory(factory_info)
114
+ end
115
+
116
+ normalized
117
+ end
118
+
119
+ def normalize_single_factory(factory_info)
120
+ case factory_info
121
+ when Hash
122
+ {
123
+ strategy: factory_info[:strategy] || detect_strategy_from_info(factory_info),
124
+ count: factory_info[:count] || factory_info[:total] || 1,
125
+ time: factory_info[:time] || factory_info[:duration] || 0.0
126
+ }
127
+ when Numeric
128
+ {
129
+ strategy: :unknown,
130
+ count: factory_info,
131
+ time: 0.0
132
+ }
133
+ else
134
+ {
135
+ strategy: :unknown,
136
+ count: 1,
137
+ time: 0.0
138
+ }
139
+ end
140
+ end
141
+
142
+ def detect_strategy_from_info(factory_info)
143
+ # Try to detect strategy from various indicators
144
+ if factory_info[:create_count]&.positive?
145
+ :create
146
+ elsif factory_info[:build_count]&.positive?
147
+ :build
148
+ elsif factory_info[:build_stubbed_count]&.positive?
149
+ :build_stubbed
150
+ elsif factory_info[:method]
151
+ factory_info[:method].to_sym
152
+ else
153
+ :unknown
154
+ end
155
+ end
156
+
157
+ def normalize_db_data(testprof_data)
158
+ db_data = testprof_data[:db_queries] || {}
159
+
160
+ # Provide default structure
161
+ normalized = {
162
+ total_queries: 0,
163
+ inserts: 0,
164
+ selects: 0,
165
+ updates: 0,
166
+ deletes: 0
167
+ }
168
+
169
+ # Merge with actual data if available
170
+ normalized.merge!(db_data.slice(:total_queries, :inserts, :selects, :updates, :deletes)) if db_data.is_a?(Hash)
171
+
172
+ # Ensure all values are numeric
173
+ normalized.transform_values { |v| v.is_a?(Numeric) ? v : 0 }
174
+ end
175
+
176
+ def normalize_event_data(testprof_data)
177
+ event_data = testprof_data[:event_prof] || {}
178
+ return {} unless event_data[:events]
179
+
180
+ normalized = {}
181
+
182
+ event_data[:events].each do |event_name, event_info|
183
+ normalized[event_name] = {
184
+ count: event_info[:count] || 0,
185
+ time: event_info[:time] || 0.0,
186
+ examples: normalize_event_examples(event_info[:examples])
187
+ }
188
+ end
189
+
190
+ normalized
191
+ end
192
+
193
+ def normalize_event_examples(examples)
194
+ return [] unless examples.is_a?(Array)
195
+
196
+ examples.map do |example|
197
+ case example
198
+ when Hash
199
+ example.slice(:sql, :time, :location, :backtrace)
200
+ when String
201
+ { sql: example }
202
+ else
203
+ {}
204
+ end
205
+ end.compact
206
+ end
207
+
208
+ def extract_metadata(testprof_data, example_context)
209
+ metadata = {}
210
+
211
+ # Add TestProf metadata
212
+ metadata.merge!(testprof_data[:metadata]) if testprof_data[:metadata]
213
+
214
+ # Add example context metadata
215
+ metadata[:example_group] = example_context[:example_group] if example_context[:example_group]
216
+ metadata[:tags] = example_context[:tags] if example_context[:tags]
217
+ metadata[:description] = example_context[:description] if example_context[:description]
218
+
219
+ # Add normalization timestamp
220
+ metadata[:normalized_at] = Time.now.strftime('%Y-%m-%dT%H:%M:%S%z')
221
+
222
+ # Add any error information
223
+ if testprof_data[:factory_prof] && testprof_data[:factory_prof][:error]
224
+ metadata[:factory_prof_error] = testprof_data[:factory_prof][:error]
225
+ end
226
+
227
+ if testprof_data[:event_prof] && testprof_data[:event_prof][:error]
228
+ metadata[:event_prof_error] = testprof_data[:event_prof][:error]
229
+ end
230
+
231
+ if testprof_data[:db_queries] && testprof_data[:db_queries][:error]
232
+ metadata[:db_queries_error] = testprof_data[:db_queries][:error]
233
+ end
234
+
235
+ metadata
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecScout
4
+ # Final recommendation containing action, confidence, and explanation
5
+ Recommendation = Struct.new(
6
+ :spec_location, # String: Location of the spec file
7
+ :action, # Symbol: :replace_factory_strategy, :avoid_db_persistence, etc.
8
+ :from_value, # String: Current value (e.g., "create(:user)")
9
+ :to_value, # String: Recommended value (e.g., "build_stubbed(:user)")
10
+ :confidence, # Symbol: :high, :medium, :low
11
+ :explanation, # Array: Array of explanation strings
12
+ :agent_results, # Array: Array of AgentResult objects
13
+ keyword_init: true
14
+ ) do
15
+ VALID_ACTIONS = %i[
16
+ replace_factory_strategy
17
+ avoid_db_persistence
18
+ optimize_queries
19
+ no_action
20
+ review_test_intent
21
+ assess_risk_factors
22
+ ].freeze
23
+
24
+ def initialize(**args)
25
+ super
26
+ self.spec_location ||= ''
27
+ self.action ||= :no_action
28
+ self.from_value ||= ''
29
+ self.to_value ||= ''
30
+ self.confidence ||= :low
31
+ self.explanation ||= []
32
+ self.agent_results ||= []
33
+ end
34
+
35
+ def valid?
36
+ spec_location.is_a?(String) &&
37
+ VALID_ACTIONS.include?(action) &&
38
+ from_value.is_a?(String) &&
39
+ to_value.is_a?(String) &&
40
+ %i[high medium low].include?(confidence) &&
41
+ explanation.is_a?(Array) &&
42
+ agent_results.is_a?(Array) &&
43
+ agent_results.all? { |result| result.is_a?(AgentResult) }
44
+ end
45
+
46
+ def actionable?
47
+ action != :no_action
48
+ end
49
+
50
+ def high_confidence?
51
+ confidence == :high
52
+ end
53
+
54
+ def medium_confidence?
55
+ confidence == :medium
56
+ end
57
+
58
+ def low_confidence?
59
+ confidence == :low
60
+ end
61
+ end
62
+ end