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.
@@ -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