rails_proof 1.0.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/CHANGELOG.md +54 -0
- data/MIT-LICENSE +20 -0
- data/README.md +1210 -0
- data/Rakefile +3 -0
- data/lib/generators/rails_proof/test/USAGE +8 -0
- data/lib/generators/rails_proof/test/test_generator.rb +542 -0
- data/lib/rails_proof/ai_concern_filter.rb +142 -0
- data/lib/rails_proof/ai_test_executor.rb +263 -0
- data/lib/rails_proof/ai_test_identity.rb +261 -0
- data/lib/rails_proof/ai_test_planner.rb +118 -0
- data/lib/rails_proof/ai_test_validator.rb +65 -0
- data/lib/rails_proof/ai_test_writer.rb +51 -0
- data/lib/rails_proof/controller_inspector.rb +44 -0
- data/lib/rails_proof/controller_test_coverage_plan.rb +79 -0
- data/lib/rails_proof/controller_test_plan.rb +28 -0
- data/lib/rails_proof/controller_test_writer.rb +66 -0
- data/lib/rails_proof/model_inspector.rb +50 -0
- data/lib/rails_proof/model_test_plan.rb +61 -0
- data/lib/rails_proof/open_ai_client.rb +189 -0
- data/lib/rails_proof/railtie.rb +4 -0
- data/lib/rails_proof/review_store.rb +241 -0
- data/lib/rails_proof/target_discovery.rb +145 -0
- data/lib/rails_proof/test_coverage_plan.rb +84 -0
- data/lib/rails_proof/test_inspector.rb +54 -0
- data/lib/rails_proof/test_runner.rb +136 -0
- data/lib/rails_proof/test_writer.rb +87 -0
- data/lib/rails_proof/version.rb +3 -0
- data/lib/rails_proof.rb +13 -0
- data/lib/tasks/rails_proof_tasks.rake +4 -0
- metadata +96 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
require "pathname"
|
|
2
|
+
require "rails_proof/ai_test_validator"
|
|
3
|
+
require "rails_proof/ai_test_writer"
|
|
4
|
+
require "rails_proof/ai_concern_filter"
|
|
5
|
+
require "rails_proof/test_runner"
|
|
6
|
+
require "rails_proof/review_store"
|
|
7
|
+
|
|
8
|
+
module RailsProof
|
|
9
|
+
class AiTestExecutor
|
|
10
|
+
Result = Struct.new(
|
|
11
|
+
:concern,
|
|
12
|
+
:status,
|
|
13
|
+
:errors,
|
|
14
|
+
:test_output,
|
|
15
|
+
:review_path,
|
|
16
|
+
:skip_reason,
|
|
17
|
+
keyword_init: true
|
|
18
|
+
) do
|
|
19
|
+
def kept?
|
|
20
|
+
status == :kept
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def rejected?
|
|
24
|
+
status == :rejected
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def needs_review?
|
|
28
|
+
status == :needs_review
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def skipped?
|
|
32
|
+
status == :skipped
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
attr_reader :root,
|
|
37
|
+
:target_path,
|
|
38
|
+
:test_file_path,
|
|
39
|
+
:test_class_name,
|
|
40
|
+
:superclass,
|
|
41
|
+
:concerns,
|
|
42
|
+
:runner_class,
|
|
43
|
+
:review_store
|
|
44
|
+
|
|
45
|
+
def initialize(
|
|
46
|
+
root:,
|
|
47
|
+
test_file_path:,
|
|
48
|
+
test_class_name:,
|
|
49
|
+
superclass:,
|
|
50
|
+
concerns:,
|
|
51
|
+
target_path: nil,
|
|
52
|
+
runner_class: RailsProof::TestRunner,
|
|
53
|
+
review_store: nil
|
|
54
|
+
)
|
|
55
|
+
@root = Pathname.new(root)
|
|
56
|
+
@target_path = target_path
|
|
57
|
+
@test_file_path = test_file_path
|
|
58
|
+
@test_class_name = test_class_name
|
|
59
|
+
@superclass = superclass
|
|
60
|
+
@concerns = concerns
|
|
61
|
+
@runner_class = runner_class
|
|
62
|
+
@review_store = review_store ||
|
|
63
|
+
RailsProof::ReviewStore.new(root: @root)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def execute
|
|
67
|
+
filter = RailsProof::AiConcernFilter.new(
|
|
68
|
+
concerns: concerns,
|
|
69
|
+
existing_tests: existing_test_source,
|
|
70
|
+
review_findings: outstanding_review_findings
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
results = filter.concerns.map do |concern|
|
|
74
|
+
execute_concern(concern)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
results.concat(
|
|
78
|
+
filter.skipped.map do |skipped|
|
|
79
|
+
skipped_result(skipped)
|
|
80
|
+
end
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
results
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def execute_concern(concern)
|
|
89
|
+
test_code = concern[:test_code] || concern["test_code"]
|
|
90
|
+
validation = RailsProof::AiTestValidator.new(test_code).validate
|
|
91
|
+
|
|
92
|
+
unless validation.valid?
|
|
93
|
+
return Result.new(
|
|
94
|
+
concern: concern,
|
|
95
|
+
status: :rejected,
|
|
96
|
+
errors: validation.errors,
|
|
97
|
+
test_output: nil,
|
|
98
|
+
review_path: nil,
|
|
99
|
+
skip_reason: nil
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
previous_state = capture_file_state
|
|
104
|
+
|
|
105
|
+
write_candidate(concern)
|
|
106
|
+
|
|
107
|
+
test_result = runner.run
|
|
108
|
+
|
|
109
|
+
if test_result.passed?
|
|
110
|
+
Result.new(
|
|
111
|
+
concern: concern,
|
|
112
|
+
status: :kept,
|
|
113
|
+
errors: [],
|
|
114
|
+
test_output: test_result.output,
|
|
115
|
+
review_path: nil,
|
|
116
|
+
skip_reason: nil
|
|
117
|
+
)
|
|
118
|
+
else
|
|
119
|
+
restore_file_state(previous_state)
|
|
120
|
+
|
|
121
|
+
review_path, persistence_error =
|
|
122
|
+
persist_review(
|
|
123
|
+
concern: concern,
|
|
124
|
+
test_output: test_result.output
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
errors = ["candidate test failed against application"]
|
|
128
|
+
errors << persistence_error if persistence_error
|
|
129
|
+
|
|
130
|
+
Result.new(
|
|
131
|
+
concern: concern,
|
|
132
|
+
status: :needs_review,
|
|
133
|
+
errors: errors,
|
|
134
|
+
test_output: test_result.output,
|
|
135
|
+
review_path: review_path,
|
|
136
|
+
skip_reason: nil
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
rescue StandardError => error
|
|
140
|
+
restore_file_state(previous_state) if defined?(previous_state)
|
|
141
|
+
|
|
142
|
+
Result.new(
|
|
143
|
+
concern: concern,
|
|
144
|
+
status: :rejected,
|
|
145
|
+
errors: [error.message],
|
|
146
|
+
test_output: nil,
|
|
147
|
+
review_path: nil,
|
|
148
|
+
skip_reason: nil
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def skipped_result(skipped)
|
|
153
|
+
Result.new(
|
|
154
|
+
concern: skipped.concern,
|
|
155
|
+
status: :skipped,
|
|
156
|
+
errors: [],
|
|
157
|
+
test_output: nil,
|
|
158
|
+
review_path: nil,
|
|
159
|
+
skip_reason: skipped.reason
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def existing_test_source
|
|
164
|
+
return "" unless absolute_test_file_path.file?
|
|
165
|
+
|
|
166
|
+
absolute_test_file_path.read
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def outstanding_review_findings
|
|
170
|
+
return [] unless target_path
|
|
171
|
+
return [] unless review_store.respond_to?(:outstanding_findings)
|
|
172
|
+
|
|
173
|
+
review_store.outstanding_findings(
|
|
174
|
+
target_path: target_path,
|
|
175
|
+
test_file_path: test_file_path
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def absolute_test_file_path
|
|
180
|
+
@absolute_test_file_path ||=
|
|
181
|
+
root.join(test_file_path)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def capture_file_state
|
|
185
|
+
if absolute_test_file_path.file?
|
|
186
|
+
{
|
|
187
|
+
existed: true,
|
|
188
|
+
content: absolute_test_file_path.read
|
|
189
|
+
}
|
|
190
|
+
else
|
|
191
|
+
{
|
|
192
|
+
existed: false,
|
|
193
|
+
content: nil
|
|
194
|
+
}
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def restore_file_state(state)
|
|
199
|
+
return unless state
|
|
200
|
+
|
|
201
|
+
if state[:existed]
|
|
202
|
+
absolute_test_file_path.write(state[:content])
|
|
203
|
+
elsif absolute_test_file_path.exist?
|
|
204
|
+
absolute_test_file_path.delete
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def write_candidate(concern)
|
|
209
|
+
writer = RailsProof::AiTestWriter.new(
|
|
210
|
+
test_class_name: test_class_name,
|
|
211
|
+
superclass: superclass,
|
|
212
|
+
concerns: [concern]
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
if absolute_test_file_path.file?
|
|
216
|
+
insert_into_existing_file(writer.render)
|
|
217
|
+
else
|
|
218
|
+
absolute_test_file_path.dirname.mkpath
|
|
219
|
+
absolute_test_file_path.write(writer.render_test_file)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def insert_into_existing_file(rendered_test)
|
|
224
|
+
source = absolute_test_file_path.read
|
|
225
|
+
|
|
226
|
+
unless source.match?(/^end\s*\z/)
|
|
227
|
+
raise ArgumentError,
|
|
228
|
+
"RailsProof could not find the end of #{test_file_path}"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
updated = source.sub(
|
|
232
|
+
/^end\s*\z/,
|
|
233
|
+
"\n#{rendered_test}\nend"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
absolute_test_file_path.write(updated)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def persist_review(concern:, test_output:)
|
|
240
|
+
path = review_store.save(
|
|
241
|
+
concern: concern,
|
|
242
|
+
test_output: test_output,
|
|
243
|
+
target_path: target_path,
|
|
244
|
+
test_file_path: test_file_path,
|
|
245
|
+
test_class_name: test_class_name
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
[path, nil]
|
|
249
|
+
rescue StandardError => error
|
|
250
|
+
[
|
|
251
|
+
nil,
|
|
252
|
+
"RailsProof could not persist review: #{error.message}"
|
|
253
|
+
]
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def runner
|
|
257
|
+
runner_class.new(
|
|
258
|
+
root: root,
|
|
259
|
+
test_file_path: test_file_path
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "set"
|
|
3
|
+
require "rails_proof/test_inspector"
|
|
4
|
+
|
|
5
|
+
module RailsProof
|
|
6
|
+
class AiTestIdentity
|
|
7
|
+
class << self
|
|
8
|
+
def name_keys(name:, test_code:)
|
|
9
|
+
names = [name]
|
|
10
|
+
|
|
11
|
+
if test_code.is_a?(String)
|
|
12
|
+
inspector = RailsProof::TestInspector.new(test_code)
|
|
13
|
+
|
|
14
|
+
names.concat(
|
|
15
|
+
inspector.test_cases.map do |test_case|
|
|
16
|
+
test_case[:name]
|
|
17
|
+
end
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
names
|
|
22
|
+
.filter_map { |value| normalize_name(value) }
|
|
23
|
+
.uniq
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def test_fingerprint(test_code)
|
|
27
|
+
canonical = canonical_test_code(test_code)
|
|
28
|
+
|
|
29
|
+
return nil unless canonical
|
|
30
|
+
|
|
31
|
+
Digest::SHA256.hexdigest(canonical)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def assertion_keys(test_code)
|
|
35
|
+
return [] unless test_code.is_a?(String)
|
|
36
|
+
return [] if test_code.strip.empty?
|
|
37
|
+
|
|
38
|
+
test_code.each_line.filter_map do |line|
|
|
39
|
+
assertion = normalize_assertion(line)
|
|
40
|
+
|
|
41
|
+
next unless assertion
|
|
42
|
+
next if trivial_assertion?(assertion)
|
|
43
|
+
|
|
44
|
+
assertion
|
|
45
|
+
end.uniq
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def behavior_keys(test_code)
|
|
49
|
+
return [] unless test_code.is_a?(String)
|
|
50
|
+
return [] if test_code.strip.empty?
|
|
51
|
+
|
|
52
|
+
context = []
|
|
53
|
+
keys = []
|
|
54
|
+
|
|
55
|
+
test_code.each_line do |line|
|
|
56
|
+
normalized = normalize_code_line(line)
|
|
57
|
+
|
|
58
|
+
next if normalized.empty?
|
|
59
|
+
next if normalized.start_with?("#")
|
|
60
|
+
next if test_declaration?(normalized)
|
|
61
|
+
next if normalized == "end"
|
|
62
|
+
|
|
63
|
+
assertion = normalize_assertion(normalized)
|
|
64
|
+
|
|
65
|
+
if assertion
|
|
66
|
+
unless trivial_assertion?(assertion)
|
|
67
|
+
keys << behavior_key(
|
|
68
|
+
context: context,
|
|
69
|
+
assertion: assertion
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
next
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
context << normalized
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
keys.uniq
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def same?(
|
|
83
|
+
first_name:,
|
|
84
|
+
first_test_code:,
|
|
85
|
+
second_name:,
|
|
86
|
+
second_test_code:
|
|
87
|
+
)
|
|
88
|
+
return true if same_name?(
|
|
89
|
+
first_name: first_name,
|
|
90
|
+
first_test_code: first_test_code,
|
|
91
|
+
second_name: second_name,
|
|
92
|
+
second_test_code: second_test_code
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
same_meaningful_behavior?(
|
|
96
|
+
first_test_code: first_test_code,
|
|
97
|
+
second_test_code: second_test_code
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def same_candidate?(
|
|
102
|
+
first_name:,
|
|
103
|
+
first_test_code:,
|
|
104
|
+
second_name:,
|
|
105
|
+
second_test_code:
|
|
106
|
+
)
|
|
107
|
+
return true if same?(
|
|
108
|
+
first_name: first_name,
|
|
109
|
+
first_test_code: first_test_code,
|
|
110
|
+
second_name: second_name,
|
|
111
|
+
second_test_code: second_test_code
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
same_test_body?(
|
|
115
|
+
first_test_code,
|
|
116
|
+
second_test_code
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def normalize_name(value)
|
|
121
|
+
normalized = value
|
|
122
|
+
.to_s
|
|
123
|
+
.downcase
|
|
124
|
+
.gsub(/[^a-z0-9]+/, " ")
|
|
125
|
+
.strip
|
|
126
|
+
.squeeze(" ")
|
|
127
|
+
|
|
128
|
+
normalized.empty? ? nil : normalized
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def same_name?(
|
|
134
|
+
first_name:,
|
|
135
|
+
first_test_code:,
|
|
136
|
+
second_name:,
|
|
137
|
+
second_test_code:
|
|
138
|
+
)
|
|
139
|
+
first_names = name_keys(
|
|
140
|
+
name: first_name,
|
|
141
|
+
test_code: first_test_code
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
second_names = name_keys(
|
|
145
|
+
name: second_name,
|
|
146
|
+
test_code: second_test_code
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
(first_names & second_names).any?
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def same_meaningful_behavior?(
|
|
153
|
+
first_test_code:,
|
|
154
|
+
second_test_code:
|
|
155
|
+
)
|
|
156
|
+
first_behaviors = behavior_keys(first_test_code)
|
|
157
|
+
second_behaviors = behavior_keys(second_test_code)
|
|
158
|
+
|
|
159
|
+
return false if first_behaviors.empty?
|
|
160
|
+
return false if second_behaviors.empty?
|
|
161
|
+
|
|
162
|
+
first_set = first_behaviors.to_set
|
|
163
|
+
second_set = second_behaviors.to_set
|
|
164
|
+
|
|
165
|
+
first_set.subset?(second_set) ||
|
|
166
|
+
second_set.subset?(first_set)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def same_test_body?(first_test_code, second_test_code)
|
|
170
|
+
first_fingerprint = test_fingerprint(first_test_code)
|
|
171
|
+
second_fingerprint = test_fingerprint(second_test_code)
|
|
172
|
+
|
|
173
|
+
return false unless first_fingerprint
|
|
174
|
+
return false unless second_fingerprint
|
|
175
|
+
|
|
176
|
+
first_fingerprint == second_fingerprint
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def canonical_test_code(test_code)
|
|
180
|
+
return nil unless test_code.is_a?(String)
|
|
181
|
+
return nil if test_code.strip.empty?
|
|
182
|
+
|
|
183
|
+
source = test_code.dup
|
|
184
|
+
|
|
185
|
+
source.sub!(
|
|
186
|
+
/^\s*test\s*(?:\(\s*)?(["']).+?\1\s*\)?\s+do\b/,
|
|
187
|
+
"test do"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
source
|
|
191
|
+
.each_line
|
|
192
|
+
.map(&:strip)
|
|
193
|
+
.reject(&:empty?)
|
|
194
|
+
.join(" ")
|
|
195
|
+
.gsub(/\s+/, " ")
|
|
196
|
+
.strip
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def normalize_code_line(line)
|
|
200
|
+
line
|
|
201
|
+
.strip
|
|
202
|
+
.gsub(/\s+/, " ")
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def test_declaration?(line)
|
|
206
|
+
line.match?(
|
|
207
|
+
/\Atest\s*(?:\(\s*)?(["']).+?\1\s*\)?\s+do\b/
|
|
208
|
+
) ||
|
|
209
|
+
line.match?(/\Adef\s+test_[a-zA-Z0-9_!?]+/)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def normalize_assertion(line)
|
|
213
|
+
normalized = line
|
|
214
|
+
.strip
|
|
215
|
+
.gsub(/\s+/, " ")
|
|
216
|
+
|
|
217
|
+
return nil unless assertion_line?(normalized)
|
|
218
|
+
|
|
219
|
+
normalized = normalized.sub(
|
|
220
|
+
/\Arefute_/,
|
|
221
|
+
"assert_not_"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
normalized = normalized.sub(
|
|
225
|
+
/\Arefute\b/,
|
|
226
|
+
"assert_not"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
normalized
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def assertion_line?(line)
|
|
233
|
+
line.match?(
|
|
234
|
+
/\A(?:assert|assert_not|refute)(?:_[a-zA-Z0-9_!?]+)?(?:\s|\()/
|
|
235
|
+
)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def trivial_assertion?(assertion)
|
|
239
|
+
normalized = assertion
|
|
240
|
+
.delete("()")
|
|
241
|
+
.strip
|
|
242
|
+
.gsub(/\s+/, " ")
|
|
243
|
+
|
|
244
|
+
[
|
|
245
|
+
"assert true",
|
|
246
|
+
"assert false",
|
|
247
|
+
"assert_not true",
|
|
248
|
+
"assert_not false"
|
|
249
|
+
].include?(normalized)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def behavior_key(context:, assertion:)
|
|
253
|
+
normalized_context = context
|
|
254
|
+
.map { |line| normalize_code_line(line) }
|
|
255
|
+
.join(" | ")
|
|
256
|
+
|
|
257
|
+
"#{normalized_context} => #{assertion}"
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
module RailsProof
|
|
2
|
+
class AiTestPlanner
|
|
3
|
+
class InvalidResponse < StandardError; end
|
|
4
|
+
|
|
5
|
+
VALID_KINDS = %i[
|
|
6
|
+
coverage
|
|
7
|
+
contract_check
|
|
8
|
+
].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :target_type,
|
|
11
|
+
:class_name,
|
|
12
|
+
:source,
|
|
13
|
+
:existing_tests,
|
|
14
|
+
:deterministic_concerns,
|
|
15
|
+
:client
|
|
16
|
+
|
|
17
|
+
def initialize(
|
|
18
|
+
target_type:,
|
|
19
|
+
class_name:,
|
|
20
|
+
source:,
|
|
21
|
+
existing_tests:,
|
|
22
|
+
deterministic_concerns:,
|
|
23
|
+
client:
|
|
24
|
+
)
|
|
25
|
+
@target_type = target_type
|
|
26
|
+
@class_name = class_name
|
|
27
|
+
@source = source
|
|
28
|
+
@existing_tests = existing_tests
|
|
29
|
+
@deterministic_concerns = deterministic_concerns
|
|
30
|
+
@client = client
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def concerns
|
|
34
|
+
@concerns ||= suggestions.map do |suggestion|
|
|
35
|
+
normalize_suggestion(suggestion)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def context
|
|
40
|
+
{
|
|
41
|
+
target_type: target_type,
|
|
42
|
+
class_name: class_name,
|
|
43
|
+
source: source,
|
|
44
|
+
existing_tests: existing_tests,
|
|
45
|
+
deterministic_concerns: deterministic_concerns
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def suggestions
|
|
52
|
+
response = client.suggest_tests(context: context)
|
|
53
|
+
|
|
54
|
+
unless response.is_a?(Array)
|
|
55
|
+
raise InvalidResponse,
|
|
56
|
+
"AI client must return an array of test suggestions"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
response
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def normalize_suggestion(suggestion)
|
|
63
|
+
unless suggestion.respond_to?(:[])
|
|
64
|
+
raise InvalidResponse,
|
|
65
|
+
"AI test suggestion must be a hash-like object"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
kind = normalize_kind(
|
|
69
|
+
suggestion[:kind] || suggestion["kind"]
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
name = suggestion[:name] || suggestion["name"]
|
|
73
|
+
reason = suggestion[:reason] || suggestion["reason"]
|
|
74
|
+
test_code = suggestion[:test_code] || suggestion["test_code"]
|
|
75
|
+
|
|
76
|
+
unless name.is_a?(String) && name.strip.present?
|
|
77
|
+
raise InvalidResponse,
|
|
78
|
+
"AI test suggestion must include a name"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
unless reason.is_a?(String) && reason.strip.present?
|
|
82
|
+
raise InvalidResponse,
|
|
83
|
+
"AI test suggestion must include a reason"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
if !test_code.nil? &&
|
|
87
|
+
(!test_code.is_a?(String) || test_code.strip.blank?)
|
|
88
|
+
raise InvalidResponse,
|
|
89
|
+
"AI test suggestion test_code must be a nonblank string"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
concern = {
|
|
93
|
+
type: :ai,
|
|
94
|
+
kind: kind,
|
|
95
|
+
name: name.strip,
|
|
96
|
+
reason: reason.strip,
|
|
97
|
+
description: name.strip
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
concern[:test_code] = test_code.strip if test_code.present?
|
|
101
|
+
|
|
102
|
+
concern
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def normalize_kind(value)
|
|
106
|
+
return :coverage if value.nil?
|
|
107
|
+
|
|
108
|
+
kind = value.to_s.strip.to_sym
|
|
109
|
+
|
|
110
|
+
unless VALID_KINDS.include?(kind)
|
|
111
|
+
raise InvalidResponse,
|
|
112
|
+
"AI test suggestion kind must be coverage or contract_check"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
kind
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
require "ripper"
|
|
2
|
+
|
|
3
|
+
module RailsProof
|
|
4
|
+
class AiTestValidator
|
|
5
|
+
Result = Struct.new(
|
|
6
|
+
:valid,
|
|
7
|
+
:errors,
|
|
8
|
+
keyword_init: true
|
|
9
|
+
) do
|
|
10
|
+
def valid?
|
|
11
|
+
valid
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_reader :test_code
|
|
16
|
+
|
|
17
|
+
def initialize(test_code)
|
|
18
|
+
@test_code = test_code
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def validate
|
|
22
|
+
errors = []
|
|
23
|
+
|
|
24
|
+
errors << "test code must be a nonblank string" unless nonblank?
|
|
25
|
+
return Result.new(valid: false, errors: errors) unless errors.empty?
|
|
26
|
+
|
|
27
|
+
errors << "test code is not valid Ruby" unless valid_ruby?
|
|
28
|
+
errors << "test code must contain exactly one test declaration" unless one_test?
|
|
29
|
+
errors << "test code must not contain a class declaration" if class_declaration?
|
|
30
|
+
errors << "test code must not contain a require statement" if require_statement?
|
|
31
|
+
errors << "test code must not contain Markdown code fences" if markdown_fence?
|
|
32
|
+
|
|
33
|
+
Result.new(
|
|
34
|
+
valid: errors.empty?,
|
|
35
|
+
errors: errors
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def nonblank?
|
|
42
|
+
test_code.is_a?(String) && !test_code.strip.empty?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def valid_ruby?
|
|
46
|
+
!Ripper.sexp(test_code).nil?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def one_test?
|
|
50
|
+
test_code.scan(/^\s*test\s*(?:\(\s*)?["']/).count == 1
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def class_declaration?
|
|
54
|
+
test_code.match?(/^\s*class\s+/)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def require_statement?
|
|
58
|
+
test_code.match?(/^\s*require(?:_relative)?\s+/)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def markdown_fence?
|
|
62
|
+
test_code.include?("```")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|