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.
- checksums.yaml +7 -0
- data/.idea/.gitignore +10 -0
- data/.idea/Projects.iml +41 -0
- data/.idea/copilot.data.migration.ask2agent.xml +6 -0
- data/.idea/modules.xml +8 -0
- data/.idea/vcs.xml +6 -0
- data/.rspec_status +236 -0
- data/Gemfile +11 -0
- data/Gemfile.lock +72 -0
- data/LICENSE +21 -0
- data/README.md +433 -0
- data/Rakefile +12 -0
- data/examples/README.md +321 -0
- data/examples/best_practices.md +401 -0
- data/examples/configurations/basic_config.rb +24 -0
- data/examples/configurations/ci_config.rb +35 -0
- data/examples/configurations/conservative_config.rb +32 -0
- data/examples/configurations/development_config.rb +37 -0
- data/examples/configurations/performance_focused_config.rb +38 -0
- data/examples/output_formatter_demo.rb +67 -0
- data/examples/sample_outputs/console_output_high_confidence.txt +27 -0
- data/examples/sample_outputs/console_output_medium_confidence.txt +27 -0
- data/examples/sample_outputs/console_output_no_action.txt +27 -0
- data/examples/sample_outputs/console_output_risk_detected.txt +27 -0
- data/examples/sample_outputs/json_output_high_confidence.json +108 -0
- data/examples/sample_outputs/json_output_no_action.json +108 -0
- data/examples/workflows/basic_workflow.md +159 -0
- data/examples/workflows/ci_integration.md +372 -0
- data/exe/spec_scout +7 -0
- data/lib/spec_scout/agent_result.rb +44 -0
- data/lib/spec_scout/agents/database_agent.rb +113 -0
- data/lib/spec_scout/agents/factory_agent.rb +179 -0
- data/lib/spec_scout/agents/intent_agent.rb +223 -0
- data/lib/spec_scout/agents/risk_agent.rb +290 -0
- data/lib/spec_scout/base_agent.rb +72 -0
- data/lib/spec_scout/cli.rb +158 -0
- data/lib/spec_scout/configuration.rb +162 -0
- data/lib/spec_scout/consensus_engine.rb +535 -0
- data/lib/spec_scout/enforcement_handler.rb +182 -0
- data/lib/spec_scout/output_formatter.rb +307 -0
- data/lib/spec_scout/profile_data.rb +37 -0
- data/lib/spec_scout/profile_normalizer.rb +238 -0
- data/lib/spec_scout/recommendation.rb +62 -0
- data/lib/spec_scout/safety_validator.rb +127 -0
- data/lib/spec_scout/spec_scout.rb +519 -0
- data/lib/spec_scout/testprof_integration.rb +206 -0
- data/lib/spec_scout/version.rb +5 -0
- data/lib/spec_scout.rb +43 -0
- 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
|