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.
data/Rakefile ADDED
@@ -0,0 +1,3 @@
1
+ require "bundler/setup"
2
+
3
+ require "bundler/gem_tasks"
@@ -0,0 +1,8 @@
1
+ Description:
2
+ Explain the generator
3
+
4
+ Example:
5
+ bin/rails generate test Thing
6
+
7
+ This will create:
8
+ what/will/it/create
@@ -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