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
data/Rakefile
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
require "pathname"
|
|
2
|
+
require "rails_proof/model_inspector"
|
|
3
|
+
require "rails_proof/model_test_plan"
|
|
4
|
+
require "rails_proof/test_inspector"
|
|
5
|
+
require "rails_proof/test_coverage_plan"
|
|
6
|
+
require "rails_proof/test_writer"
|
|
7
|
+
require "rails_proof/target_discovery"
|
|
8
|
+
require "rails_proof/controller_inspector"
|
|
9
|
+
require "rails_proof/controller_test_plan"
|
|
10
|
+
require "rails_proof/controller_test_coverage_plan"
|
|
11
|
+
require "rails_proof/controller_test_writer"
|
|
12
|
+
require "rails_proof/ai_test_planner"
|
|
13
|
+
require "rails_proof/ai_test_executor"
|
|
14
|
+
|
|
15
|
+
module RailsProof
|
|
16
|
+
class TestGenerator < Rails::Generators::Base
|
|
17
|
+
ASSOCIATION_MACROS = %w[
|
|
18
|
+
belongs_to
|
|
19
|
+
has_many
|
|
20
|
+
has_one
|
|
21
|
+
has_and_belongs_to_many
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
argument :scope,
|
|
25
|
+
type: :string,
|
|
26
|
+
required: false,
|
|
27
|
+
banner: "[app/models/post.rb|app/models|app/controllers]"
|
|
28
|
+
|
|
29
|
+
def run_rails_proof
|
|
30
|
+
say "RailsProof targets: #{targets.count}"
|
|
31
|
+
|
|
32
|
+
if targets.empty?
|
|
33
|
+
say "No supported targets found."
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
targets.each_with_index do |target, index|
|
|
38
|
+
say "" if index.positive?
|
|
39
|
+
process_target(target)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def targets
|
|
46
|
+
@targets ||= RailsProof::TargetDiscovery.new(
|
|
47
|
+
root: destination_root,
|
|
48
|
+
scope: scope
|
|
49
|
+
).targets
|
|
50
|
+
rescue ArgumentError => error
|
|
51
|
+
raise Thor::Error, error.message
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def process_target(target)
|
|
55
|
+
case target.type
|
|
56
|
+
when :model
|
|
57
|
+
process_model(target)
|
|
58
|
+
when :controller
|
|
59
|
+
process_controller(target)
|
|
60
|
+
else
|
|
61
|
+
say "Unsupported target type: #{target.type}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def process_model(target)
|
|
66
|
+
prepare_model_target(target)
|
|
67
|
+
|
|
68
|
+
say "RailsProof inspection"
|
|
69
|
+
say "Model file: #{@current_target.path}"
|
|
70
|
+
say "Model class: #{@current_target.class_name}"
|
|
71
|
+
say "Test file: #{@test_file_path}"
|
|
72
|
+
say "Test status: #{test_file_status}"
|
|
73
|
+
say "Test cases: #{@test_inspector.count}" if @absolute_test_file_path.file?
|
|
74
|
+
|
|
75
|
+
report_source_analysis
|
|
76
|
+
report_runtime_analysis
|
|
77
|
+
|
|
78
|
+
if @model_class
|
|
79
|
+
concerns = report_ai_analysis(
|
|
80
|
+
target_type: :model,
|
|
81
|
+
class_name: @current_target.class_name,
|
|
82
|
+
source: @model_source,
|
|
83
|
+
deterministic_concerns: @model_test_plan.concerns
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
execute_ai_tests(
|
|
87
|
+
concerns: concerns,
|
|
88
|
+
test_class_name: "#{@current_target.class_name}Test",
|
|
89
|
+
superclass: "ActiveSupport::TestCase"
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
write_missing_tests
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def prepare_model_target(target)
|
|
97
|
+
@current_target = target
|
|
98
|
+
@model_class, @model_runtime_error =
|
|
99
|
+
constantize_target(target.class_name)
|
|
100
|
+
@absolute_model_path = Pathname.new(destination_root).join(target.path)
|
|
101
|
+
@model_source = @absolute_model_path.read
|
|
102
|
+
|
|
103
|
+
model_name = target.path
|
|
104
|
+
.delete_prefix("app/models/")
|
|
105
|
+
.delete_suffix(".rb")
|
|
106
|
+
|
|
107
|
+
prepare_test_target("test/models/#{model_name}_test.rb")
|
|
108
|
+
|
|
109
|
+
if @model_class
|
|
110
|
+
@runtime_inspector = RailsProof::ModelInspector.new(@model_class)
|
|
111
|
+
@model_test_plan = RailsProof::ModelTestPlan.new(@runtime_inspector)
|
|
112
|
+
@coverage_plan = RailsProof::TestCoveragePlan.new(
|
|
113
|
+
@model_test_plan,
|
|
114
|
+
@test_inspector
|
|
115
|
+
)
|
|
116
|
+
else
|
|
117
|
+
@runtime_inspector = nil
|
|
118
|
+
@model_test_plan = nil
|
|
119
|
+
@coverage_plan = nil
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def process_controller(target)
|
|
124
|
+
prepare_controller_target(target)
|
|
125
|
+
|
|
126
|
+
say "RailsProof inspection"
|
|
127
|
+
say "Controller file: #{@current_target.path}"
|
|
128
|
+
say "Controller class: #{@current_target.class_name}"
|
|
129
|
+
say "Test file: #{@test_file_path}"
|
|
130
|
+
say "Test status: #{test_file_status}"
|
|
131
|
+
say "Test cases: #{@test_inspector.count}" if @absolute_test_file_path.file?
|
|
132
|
+
|
|
133
|
+
report_controller_analysis
|
|
134
|
+
|
|
135
|
+
if @controller_class
|
|
136
|
+
concerns = report_ai_analysis(
|
|
137
|
+
target_type: :controller,
|
|
138
|
+
class_name: @current_target.class_name,
|
|
139
|
+
source: @controller_source,
|
|
140
|
+
deterministic_concerns: @controller_test_plan.concerns
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
execute_ai_tests(
|
|
144
|
+
concerns: concerns,
|
|
145
|
+
test_class_name: "#{@current_target.class_name}Test",
|
|
146
|
+
superclass: "ActionDispatch::IntegrationTest"
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
write_missing_controller_tests
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def prepare_controller_target(target)
|
|
154
|
+
@current_target = target
|
|
155
|
+
@controller_class, @controller_runtime_error =
|
|
156
|
+
constantize_target(target.class_name)
|
|
157
|
+
@absolute_controller_path =
|
|
158
|
+
Pathname.new(destination_root).join(target.path)
|
|
159
|
+
@controller_source = @absolute_controller_path.read
|
|
160
|
+
|
|
161
|
+
controller_name = target.path
|
|
162
|
+
.delete_prefix("app/controllers/")
|
|
163
|
+
.delete_suffix(".rb")
|
|
164
|
+
|
|
165
|
+
prepare_test_target(
|
|
166
|
+
"test/controllers/#{controller_name}_test.rb"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if @controller_class
|
|
170
|
+
@controller_inspector = RailsProof::ControllerInspector.new(
|
|
171
|
+
@controller_class
|
|
172
|
+
)
|
|
173
|
+
@controller_test_plan = RailsProof::ControllerTestPlan.new(
|
|
174
|
+
@controller_inspector
|
|
175
|
+
)
|
|
176
|
+
@controller_coverage_plan =
|
|
177
|
+
RailsProof::ControllerTestCoveragePlan.new(
|
|
178
|
+
@controller_test_plan,
|
|
179
|
+
@test_inspector
|
|
180
|
+
)
|
|
181
|
+
else
|
|
182
|
+
@controller_inspector = nil
|
|
183
|
+
@controller_test_plan = nil
|
|
184
|
+
@controller_coverage_plan = nil
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def prepare_test_target(test_file_path)
|
|
189
|
+
@test_file_path = test_file_path
|
|
190
|
+
@absolute_test_file_path =
|
|
191
|
+
Pathname.new(destination_root).join(@test_file_path)
|
|
192
|
+
|
|
193
|
+
@test_source = if @absolute_test_file_path.file?
|
|
194
|
+
@absolute_test_file_path.read
|
|
195
|
+
else
|
|
196
|
+
""
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
@test_inspector = RailsProof::TestInspector.new(@test_source)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def test_file_status
|
|
203
|
+
@absolute_test_file_path.file? ? "exists" : "missing"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def association_declarations
|
|
207
|
+
@model_source.each_line.filter_map do |line|
|
|
208
|
+
declaration = line.strip
|
|
209
|
+
|
|
210
|
+
declaration if ASSOCIATION_MACROS.any? do |macro|
|
|
211
|
+
declaration.match?(/\A#{macro}\s+/)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def validation_declarations
|
|
217
|
+
@model_source.each_line.filter_map do |line|
|
|
218
|
+
declaration = line.strip
|
|
219
|
+
|
|
220
|
+
declaration if declaration.match?(/\Avalidates\s+/)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def report_source_analysis
|
|
225
|
+
associations = association_declarations
|
|
226
|
+
validations = validation_declarations
|
|
227
|
+
|
|
228
|
+
say "Source associations: #{associations.count}"
|
|
229
|
+
associations.each do |declaration|
|
|
230
|
+
say " #{declaration}"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
say "Source validations: #{validations.count}"
|
|
234
|
+
validations.each do |declaration|
|
|
235
|
+
say " #{declaration}"
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def report_runtime_analysis
|
|
240
|
+
unless @model_class
|
|
241
|
+
say "Runtime inspection: unavailable"
|
|
242
|
+
report_runtime_error(@model_runtime_error)
|
|
243
|
+
return
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
say "Runtime inspection: available"
|
|
247
|
+
say "Table: #{@runtime_inspector.table_name}"
|
|
248
|
+
|
|
249
|
+
report_runtime_columns
|
|
250
|
+
report_runtime_associations
|
|
251
|
+
report_runtime_validators
|
|
252
|
+
report_test_plan
|
|
253
|
+
report_coverage
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def report_runtime_columns
|
|
257
|
+
columns = @runtime_inspector.columns
|
|
258
|
+
|
|
259
|
+
say "Columns: #{columns.count}"
|
|
260
|
+
columns.each do |column|
|
|
261
|
+
say(
|
|
262
|
+
" #{column[:name]} #{column[:type]} " \
|
|
263
|
+
"null=#{column[:null]} default=#{column[:default].inspect}"
|
|
264
|
+
)
|
|
265
|
+
end
|
|
266
|
+
rescue ActiveRecord::StatementInvalid => error
|
|
267
|
+
say "Columns: unavailable (#{error.message.lines.first.strip})"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def report_runtime_associations
|
|
271
|
+
associations = @runtime_inspector.associations
|
|
272
|
+
|
|
273
|
+
say "Runtime associations: #{associations.count}"
|
|
274
|
+
associations.each do |association|
|
|
275
|
+
say(
|
|
276
|
+
" #{association[:macro]} :#{association[:name]} " \
|
|
277
|
+
"class=#{association[:class_name]} " \
|
|
278
|
+
"foreign_key=#{association[:foreign_key]}"
|
|
279
|
+
)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def report_runtime_validators
|
|
284
|
+
validators = @runtime_inspector.validators
|
|
285
|
+
|
|
286
|
+
say "Runtime validators: #{validators.count}"
|
|
287
|
+
validators.each do |validator|
|
|
288
|
+
say(
|
|
289
|
+
" #{validator[:class_name]} " \
|
|
290
|
+
"attributes=#{validator[:attributes].inspect}"
|
|
291
|
+
)
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def report_test_plan
|
|
296
|
+
say "Suggested tests: #{@model_test_plan.count}"
|
|
297
|
+
@model_test_plan.concerns.each do |concern|
|
|
298
|
+
say " #{concern[:description]}"
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def report_coverage
|
|
303
|
+
say "Coverage:"
|
|
304
|
+
say " Covered: #{@coverage_plan.covered_count}"
|
|
305
|
+
say " Missing: #{@coverage_plan.missing_count}"
|
|
306
|
+
|
|
307
|
+
@coverage_plan.missing_concerns.each do |concern|
|
|
308
|
+
say " #{concern[:description]}"
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def report_controller_analysis
|
|
313
|
+
unless @controller_class
|
|
314
|
+
say "Controller inspection: unavailable"
|
|
315
|
+
report_runtime_error(@controller_runtime_error)
|
|
316
|
+
return
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
say "Controller inspection: available"
|
|
320
|
+
|
|
321
|
+
say "Actions: #{@controller_inspector.actions.count}"
|
|
322
|
+
@controller_inspector.actions.each do |action|
|
|
323
|
+
say " #{action}"
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
say "Routes: #{@controller_inspector.routes.count}"
|
|
327
|
+
@controller_inspector.routes.each do |route|
|
|
328
|
+
say " #{route[:verb]} #{route[:path]} -> #{route[:action]}"
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
say "Suggested tests: #{@controller_test_plan.count}"
|
|
332
|
+
@controller_test_plan.concerns.each do |concern|
|
|
333
|
+
say " #{concern[:description]}"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
say "Coverage:"
|
|
337
|
+
say " Covered: #{@controller_coverage_plan.covered_count}"
|
|
338
|
+
say " Missing: #{@controller_coverage_plan.missing_count}"
|
|
339
|
+
|
|
340
|
+
@controller_coverage_plan.missing_concerns.each do |concern|
|
|
341
|
+
say " #{concern[:description]}"
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def constantize_target(class_name)
|
|
346
|
+
[class_name.safe_constantize, nil]
|
|
347
|
+
rescue StandardError => error
|
|
348
|
+
[nil, error]
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def report_runtime_error(error)
|
|
352
|
+
return unless error
|
|
353
|
+
|
|
354
|
+
message = error.message.to_s.lines.first.to_s.strip
|
|
355
|
+
|
|
356
|
+
say " Load error: #{error.class}: #{message}"
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def report_ai_analysis(
|
|
360
|
+
target_type:,
|
|
361
|
+
class_name:,
|
|
362
|
+
source:,
|
|
363
|
+
deterministic_concerns:
|
|
364
|
+
)
|
|
365
|
+
planner = RailsProof::AiTestPlanner.new(
|
|
366
|
+
target_type: target_type,
|
|
367
|
+
class_name: class_name,
|
|
368
|
+
source: source,
|
|
369
|
+
existing_tests: @test_source,
|
|
370
|
+
deterministic_concerns: deterministic_concerns,
|
|
371
|
+
client: RailsProof.ai_client
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
concerns = planner.concerns
|
|
375
|
+
|
|
376
|
+
say "AI suggested tests: #{concerns.count}"
|
|
377
|
+
|
|
378
|
+
concerns.each do |concern|
|
|
379
|
+
say " #{concern[:name]}"
|
|
380
|
+
say " Reason: #{concern[:reason]}"
|
|
381
|
+
|
|
382
|
+
next unless concern[:test_code]
|
|
383
|
+
|
|
384
|
+
say " Candidate test:"
|
|
385
|
+
|
|
386
|
+
concern[:test_code].each_line do |line|
|
|
387
|
+
say " #{line.chomp}"
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
concerns
|
|
392
|
+
rescue RailsProof::AiTestPlanner::InvalidResponse,
|
|
393
|
+
RailsProof::OpenAiClient::Error => error
|
|
394
|
+
raise Thor::Error,
|
|
395
|
+
"RailsProof AI analysis failed: #{error.message}"
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def execute_ai_tests(
|
|
399
|
+
concerns:,
|
|
400
|
+
test_class_name:,
|
|
401
|
+
superclass:
|
|
402
|
+
)
|
|
403
|
+
if concerns.empty?
|
|
404
|
+
say "AI test results: 0"
|
|
405
|
+
return
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
executor = RailsProof::AiTestExecutor.new(
|
|
409
|
+
root: destination_root,
|
|
410
|
+
target_path: @current_target.path,
|
|
411
|
+
test_file_path: @test_file_path,
|
|
412
|
+
test_class_name: test_class_name,
|
|
413
|
+
superclass: superclass,
|
|
414
|
+
concerns: concerns
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
results = executor.execute
|
|
418
|
+
|
|
419
|
+
say "AI test results: #{results.count}"
|
|
420
|
+
|
|
421
|
+
results.each do |result|
|
|
422
|
+
report_ai_test_result(result)
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def report_ai_test_result(result)
|
|
427
|
+
if result.kept?
|
|
428
|
+
say " KEPT: #{result.concern[:name]}"
|
|
429
|
+
elsif result.needs_review?
|
|
430
|
+
report_needs_review(result)
|
|
431
|
+
elsif result.skipped?
|
|
432
|
+
report_skipped(result)
|
|
433
|
+
else
|
|
434
|
+
report_rejected(result)
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def report_needs_review(result)
|
|
439
|
+
say " NEEDS REVIEW: #{result.concern[:name]}"
|
|
440
|
+
|
|
441
|
+
result.errors.each do |error|
|
|
442
|
+
say " #{error}"
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
if result.review_path
|
|
446
|
+
say " Review saved: #{display_review_path(result.review_path)}"
|
|
447
|
+
else
|
|
448
|
+
say " Review could not be saved."
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
report_failed_test_output(result.test_output)
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def report_skipped(result)
|
|
455
|
+
say " SKIPPED: #{result.concern[:name]}"
|
|
456
|
+
say " #{skip_reason_message(result.skip_reason)}"
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def skip_reason_message(reason)
|
|
460
|
+
case reason
|
|
461
|
+
when :existing_test
|
|
462
|
+
"already exists in test suite"
|
|
463
|
+
when :needs_review
|
|
464
|
+
"already awaiting human review"
|
|
465
|
+
when :duplicate_suggestion
|
|
466
|
+
"duplicate AI suggestion"
|
|
467
|
+
else
|
|
468
|
+
"duplicate or previously handled suggestion"
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def report_rejected(result)
|
|
473
|
+
say " REJECTED: #{result.concern[:name]}"
|
|
474
|
+
|
|
475
|
+
result.errors.each do |error|
|
|
476
|
+
say " #{error}"
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
report_failed_test_output(result.test_output)
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def display_review_path(path)
|
|
483
|
+
pathname = Pathname.new(path)
|
|
484
|
+
|
|
485
|
+
pathname.relative_path_from(
|
|
486
|
+
Pathname.new(destination_root)
|
|
487
|
+
).to_s
|
|
488
|
+
rescue ArgumentError
|
|
489
|
+
pathname.to_s
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def report_failed_test_output(output)
|
|
493
|
+
return if output.blank?
|
|
494
|
+
|
|
495
|
+
say " Test output:"
|
|
496
|
+
|
|
497
|
+
output.each_line do |line|
|
|
498
|
+
say " #{line.chomp}"
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def write_missing_tests
|
|
503
|
+
return unless @model_class
|
|
504
|
+
return if @coverage_plan.missing_count.zero?
|
|
505
|
+
|
|
506
|
+
writer = RailsProof::TestWriter.new(
|
|
507
|
+
model_class_name: @current_target.class_name,
|
|
508
|
+
concerns: @coverage_plan.missing_concerns
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
if @absolute_test_file_path.file?
|
|
512
|
+
inject_into_file(
|
|
513
|
+
@test_file_path,
|
|
514
|
+
"\n#{writer.render}\n",
|
|
515
|
+
before: /^end\s*\z/
|
|
516
|
+
)
|
|
517
|
+
else
|
|
518
|
+
create_file @test_file_path, writer.render_test_file
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def write_missing_controller_tests
|
|
523
|
+
return unless @controller_class
|
|
524
|
+
return if @controller_coverage_plan.missing_count.zero?
|
|
525
|
+
|
|
526
|
+
writer = RailsProof::ControllerTestWriter.new(
|
|
527
|
+
controller_class_name: @current_target.class_name,
|
|
528
|
+
concerns: @controller_coverage_plan.missing_concerns
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
if @absolute_test_file_path.file?
|
|
532
|
+
inject_into_file(
|
|
533
|
+
@test_file_path,
|
|
534
|
+
"\n#{writer.render}\n",
|
|
535
|
+
before: /^end\s*\z/
|
|
536
|
+
)
|
|
537
|
+
else
|
|
538
|
+
create_file @test_file_path, writer.render_test_file
|
|
539
|
+
end
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
require "rails_proof/test_inspector"
|
|
2
|
+
require "rails_proof/ai_test_validator"
|
|
3
|
+
require "rails_proof/ai_test_identity"
|
|
4
|
+
|
|
5
|
+
module RailsProof
|
|
6
|
+
class AiConcernFilter
|
|
7
|
+
SkippedConcern = Struct.new(
|
|
8
|
+
:concern,
|
|
9
|
+
:reason,
|
|
10
|
+
keyword_init: true
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
attr_reader :input_concerns,
|
|
14
|
+
:existing_tests,
|
|
15
|
+
:review_findings
|
|
16
|
+
|
|
17
|
+
def initialize(
|
|
18
|
+
concerns:,
|
|
19
|
+
existing_tests:,
|
|
20
|
+
review_findings: []
|
|
21
|
+
)
|
|
22
|
+
@input_concerns = concerns
|
|
23
|
+
@existing_tests = existing_tests.to_s
|
|
24
|
+
@review_findings = review_findings
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def concerns
|
|
28
|
+
filter_result[:concerns]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def skipped
|
|
32
|
+
filter_result[:skipped]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def filter_result
|
|
38
|
+
@filter_result ||= build_filter_result
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def build_filter_result
|
|
42
|
+
accepted = []
|
|
43
|
+
skipped = []
|
|
44
|
+
|
|
45
|
+
input_concerns.each do |concern|
|
|
46
|
+
reason = duplicate_reason(
|
|
47
|
+
concern: concern,
|
|
48
|
+
accepted: accepted
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if reason
|
|
52
|
+
skipped << SkippedConcern.new(
|
|
53
|
+
concern: concern,
|
|
54
|
+
reason: reason
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
next
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
accepted << concern
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
{
|
|
64
|
+
concerns: accepted,
|
|
65
|
+
skipped: skipped
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def duplicate_reason(concern:, accepted:)
|
|
70
|
+
return :existing_test if matches_existing_test?(concern)
|
|
71
|
+
return :needs_review if matches_review_finding?(concern)
|
|
72
|
+
|
|
73
|
+
if structurally_valid?(concern) &&
|
|
74
|
+
matches_accepted_concern?(concern, accepted)
|
|
75
|
+
return :duplicate_suggestion
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def matches_existing_test?(concern)
|
|
82
|
+
concern_keys = RailsProof::AiTestIdentity.name_keys(
|
|
83
|
+
name: concern_name(concern),
|
|
84
|
+
test_code: concern_test_code(concern)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
(concern_keys & existing_test_keys).any?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def matches_review_finding?(concern)
|
|
91
|
+
review_findings.any? do |finding|
|
|
92
|
+
RailsProof::AiTestIdentity.same?(
|
|
93
|
+
first_name: concern_name(concern),
|
|
94
|
+
first_test_code: concern_test_code(concern),
|
|
95
|
+
second_name: finding["name"] || finding[:name],
|
|
96
|
+
second_test_code:
|
|
97
|
+
finding["test_code"] || finding[:test_code]
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def matches_accepted_concern?(concern, accepted)
|
|
103
|
+
accepted.any? do |existing|
|
|
104
|
+
next false unless structurally_valid?(existing)
|
|
105
|
+
|
|
106
|
+
RailsProof::AiTestIdentity.same_candidate?(
|
|
107
|
+
first_name: concern_name(concern),
|
|
108
|
+
first_test_code: concern_test_code(concern),
|
|
109
|
+
second_name: concern_name(existing),
|
|
110
|
+
second_test_code: concern_test_code(existing)
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def existing_test_keys
|
|
116
|
+
@existing_test_keys ||= RailsProof::TestInspector
|
|
117
|
+
.new(existing_tests)
|
|
118
|
+
.test_cases
|
|
119
|
+
.filter_map do |test_case|
|
|
120
|
+
RailsProof::AiTestIdentity.normalize_name(
|
|
121
|
+
test_case[:name]
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
.uniq
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def concern_name(concern)
|
|
128
|
+
concern[:name] || concern["name"]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def concern_test_code(concern)
|
|
132
|
+
concern[:test_code] || concern["test_code"]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def structurally_valid?(concern)
|
|
136
|
+
RailsProof::AiTestValidator
|
|
137
|
+
.new(concern_test_code(concern))
|
|
138
|
+
.validate
|
|
139
|
+
.valid?
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|