rails_code_health 0.1.0 → 0.3.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.
@@ -1,5 +1,7 @@
1
1
  module RailsCodeHealth
2
2
  class RailsAnalyzer
3
+ include RailsCodeHealth::ASTHelpers
4
+
3
5
  def initialize(file_path, file_type)
4
6
  @file_path = file_path
5
7
  @file_type = file_type
@@ -21,6 +23,12 @@ module RailsCodeHealth
21
23
  analyze_helper
22
24
  when :migration
23
25
  analyze_migration
26
+ when :service
27
+ analyze_service
28
+ when :interactor
29
+ analyze_interactor
30
+ when :serializer
31
+ analyze_serializer
24
32
  else
25
33
  {}
26
34
  end
@@ -37,6 +45,7 @@ module RailsCodeHealth
37
45
  has_before_actions: has_before_actions?,
38
46
  uses_strong_parameters: uses_strong_parameters?,
39
47
  has_direct_model_access: has_direct_model_access?,
48
+ has_business_logic: has_business_logic?,
40
49
  response_formats: detect_response_formats,
41
50
  rails_smells: detect_controller_smells
42
51
  }
@@ -97,11 +106,10 @@ module RailsCodeHealth
97
106
  return 0 unless @ast
98
107
 
99
108
  action_count = 0
100
- find_nodes(@ast, :def) do |node|
101
- method_name = node.children[0].to_s
102
- # Skip private methods and Rails internal methods
103
- unless method_name.start_with?('_') || private_controller_method?(method_name)
104
- action_count += 1
109
+ find_nodes(@ast, :class) do |class_node|
110
+ defs_by_visibility(class_node)[:public].each do |def_node|
111
+ name = def_node.children[0].to_s
112
+ action_count += 1 unless name.start_with?('_')
105
113
  end
106
114
  end
107
115
  action_count
@@ -115,17 +123,25 @@ module RailsCodeHealth
115
123
  @source.include?('params.require') || @source.include?('params.permit')
116
124
  end
117
125
 
126
+ DIRECT_MODEL_METHODS = %i[find find_by where create create! update update! all first last destroy_all].freeze
127
+
118
128
  def has_direct_model_access?
119
- # Look for direct ActiveRecord calls in controller actions
120
- model_patterns = [
121
- /\w+\.find\(/,
122
- /\w+\.where\(/,
123
- /\w+\.create\(/,
124
- /\w+\.update\(/,
125
- /\w+\.all/
126
- ]
127
-
128
- model_patterns.any? { |pattern| @source.match?(pattern) }
129
+ return false unless @ast
130
+
131
+ found = false
132
+ find_nodes(@ast, :class) do |class_node|
133
+ defs_by_visibility(class_node)[:public].each do |def_node|
134
+ find_nodes(def_node, :send) do |send_node|
135
+ receiver = send_node.children[0]
136
+ method_name = send_node.children[1]
137
+ # Receiver must be a constant (a likely model class) and the method must be an AR method.
138
+ next unless receiver.is_a?(Parser::AST::Node) && receiver.type == :const
139
+ next unless DIRECT_MODEL_METHODS.include?(method_name)
140
+ found = true
141
+ end
142
+ end
143
+ end
144
+ found
129
145
  end
130
146
 
131
147
  def detect_response_formats
@@ -137,6 +153,24 @@ module RailsCodeHealth
137
153
  formats
138
154
  end
139
155
 
156
+ BUSINESS_VERBS = %i[calculate compute process charge refund transition].freeze
157
+ # Methods with these prefixes likely indicate business logic.
158
+ # `process_` was previously included but removed because of false positives
159
+ # on common AR attributes like `process_id`.
160
+ BUSINESS_VERB_PREFIXES = %w[calculate_ compute_].freeze
161
+
162
+ def has_business_logic?
163
+ return false unless @ast
164
+
165
+ found = false
166
+ find_nodes(@ast, :class) do |class_node|
167
+ defs_by_visibility(class_node)[:public].each do |def_node|
168
+ found = true if action_has_business_logic?(def_node)
169
+ end
170
+ end
171
+ found
172
+ end
173
+
140
174
  def detect_controller_smells
141
175
  smells = []
142
176
 
@@ -163,55 +197,125 @@ module RailsCodeHealth
163
197
  }
164
198
  end
165
199
 
200
+ if has_business_logic?
201
+ smells << {
202
+ type: :business_logic_in_controller,
203
+ severity: :high
204
+ }
205
+ end
206
+
166
207
  smells
167
208
  end
168
209
 
210
+ def action_has_business_logic?(def_node)
211
+ # NOTE: arithmetic signal (plan A2 item 2) intentionally not implemented —
212
+ # accuracy of detecting "two non-literal operands" was deemed not worth
213
+ # the false positive risk in v0.3.0.
214
+ return true if business_verb_call?(def_node)
215
+ return true if transaction_block?(def_node)
216
+ return true if loop_with_conditional?(def_node)
217
+ false
218
+ end
219
+
220
+ def business_verb_call?(def_node)
221
+ found = false
222
+ find_nodes(def_node, :send) do |send_node|
223
+ method_name = send_node.children[1].to_s
224
+ if BUSINESS_VERBS.include?(method_name.to_sym) ||
225
+ BUSINESS_VERB_PREFIXES.any? { |p| method_name.start_with?(p) }
226
+ found = true
227
+ end
228
+ end
229
+ found
230
+ end
231
+
232
+ def transaction_block?(def_node)
233
+ found = false
234
+ find_nodes(def_node, :block) do |block_node|
235
+ send_node = block_node.children[0]
236
+ next unless send_node.is_a?(Parser::AST::Node) && send_node.type == :send
237
+ found = true if send_node.children[1] == :transaction
238
+ end
239
+ found
240
+ end
241
+
242
+ def loop_with_conditional?(def_node)
243
+ found = false
244
+ find_nodes(def_node, :block) do |block_node|
245
+ send_node = block_node.children[0]
246
+ next unless send_node.is_a?(Parser::AST::Node) && send_node.type == :send
247
+ next unless %i[each map select reject].include?(send_node.children[1])
248
+ # Look for conditionals inside the block body.
249
+ find_nodes(block_node.children[2], :if) { found = true }
250
+ find_nodes(block_node.children[2], :case) { found = true }
251
+ end
252
+ found
253
+ end
254
+
255
+ ASSOCIATION_MACROS = %i[belongs_to has_one has_many has_and_belongs_to_many].freeze
256
+ VALIDATION_MACROS = %i[validates validates_presence_of validates_uniqueness_of validates_format_of validates_length_of validates_numericality_of validates_inclusion_of validates_exclusion_of validates_acceptance_of validates_confirmation_of].freeze
257
+ CALLBACK_MACROS = %i[before_save after_save before_create after_create before_update after_update before_destroy after_destroy after_commit after_rollback before_validation after_validation].freeze
258
+
169
259
  # Model analysis methods
170
260
  def count_associations
171
- associations = 0
172
- association_methods = %w[belongs_to has_one has_many has_and_belongs_to_many]
173
-
174
- association_methods.each do |method|
175
- associations += @source.scan(/#{method}\s+:/).count
261
+ total = 0
262
+ find_nodes(@ast, :class) do |class_node|
263
+ ASSOCIATION_MACROS.each do |macro|
264
+ total += class_body_sends(class_node, macro).size
265
+ end
176
266
  end
177
-
178
- associations
267
+ total
179
268
  end
180
269
 
181
270
  def count_validations
182
- validations = 0
183
- validation_methods = %w[validates validates_presence_of validates_uniqueness_of validates_format_of]
184
-
185
- validation_methods.each do |method|
186
- validations += @source.scan(/#{method}\s+/).count
271
+ total = 0
272
+ find_nodes(@ast, :class) do |class_node|
273
+ VALIDATION_MACROS.each do |macro|
274
+ total += class_body_sends(class_node, macro).size
275
+ end
187
276
  end
188
-
189
- validations
277
+ total
190
278
  end
191
279
 
192
280
  def count_callbacks
193
- callbacks = 0
194
- callback_methods = %w[before_save after_save before_create after_create before_update after_update before_destroy after_destroy]
195
-
196
- callback_methods.each do |method|
197
- callbacks += @source.scan(/#{method}\s+/).count
281
+ total = 0
282
+ find_nodes(@ast, :class) do |class_node|
283
+ CALLBACK_MACROS.each do |macro|
284
+ total += class_body_sends(class_node, macro).size
285
+ end
198
286
  end
199
-
200
- callbacks
287
+ total
201
288
  end
202
289
 
203
290
  def count_scopes
204
- @source.scan(/scope\s+:/).count
291
+ total = 0
292
+ find_nodes(@ast, :class) do |class_node|
293
+ total += class_body_sends(class_node, :scope).size
294
+ end
295
+ total
205
296
  end
206
297
 
207
298
  def has_fat_model_smell?
208
299
  return false unless @ast
209
300
 
210
- line_count = @source.lines.count
301
+ class_node = nil
302
+ find_nodes(@ast, :class) { |n| class_node ||= n }
303
+ return false unless class_node
304
+
305
+ code_lines = code_line_count(class_node)
211
306
  method_count = 0
212
- find_nodes(@ast, :def) { method_count += 1 }
213
-
214
- line_count > 200 && method_count > 15
307
+ find_nodes_in_scope(class_node, :def) { method_count += 1 }
308
+
309
+ code_lines > 200 && method_count > 15
310
+ end
311
+
312
+ def code_line_count(node)
313
+ return 0 unless node.respond_to?(:loc) && node.loc.respond_to?(:expression)
314
+ expr = node.loc.expression
315
+ return 0 unless expr
316
+
317
+ lines = expr.source.lines
318
+ lines.count { |line| line.strip != '' && !line.strip.start_with?('#') }
215
319
  end
216
320
 
217
321
  def detect_model_smells
@@ -244,16 +348,16 @@ module RailsCodeHealth
244
348
  smells
245
349
  end
246
350
 
351
+ VIEW_CONTROL_FLOW_KEYWORDS = %w[if unless elsif else case for while end].freeze
352
+
247
353
  # View analysis methods
248
- def count_view_logic_lines(lines)
249
- logic_count = 0
250
-
251
- lines.each do |line|
252
- # Count Ruby code blocks in ERB
253
- logic_count += 1 if line.match?(/<%((?!%>).)*%>/) || line.match?(/<%((?!%>).)*if|unless|case|for|while/)
354
+ def count_view_logic_lines(_lines)
355
+ count = 0
356
+ erb_ruby_fragments(@source).each do |ruby|
357
+ tokens = ruby.scan(/\b\w+\b/)
358
+ count += 1 if (tokens & VIEW_CONTROL_FLOW_KEYWORDS).any?
254
359
  end
255
-
256
- logic_count
360
+ count
257
361
  end
258
362
 
259
363
  def has_inline_styles?
@@ -324,10 +428,22 @@ module RailsCodeHealth
324
428
  smells
325
429
  end
326
430
 
431
+ DATA_CHANGE_METHODS = %i[
432
+ execute update_all delete_all
433
+ find_each update update_columns update_column
434
+ update! save save!
435
+ ].freeze
436
+
327
437
  # Migration analysis methods
328
438
  def has_data_changes?
329
- data_methods = %w[execute update_all delete_all]
330
- data_methods.any? { |method| @source.include?(method) }
439
+ return false unless @ast
440
+
441
+ found = false
442
+ find_nodes(@ast, :send) do |send_node|
443
+ method_name = send_node.children[1]
444
+ found = true if DATA_CHANGE_METHODS.include?(method_name)
445
+ end
446
+ found
331
447
  end
332
448
 
333
449
  def has_index_changes?
@@ -372,20 +488,300 @@ module RailsCodeHealth
372
488
  smells
373
489
  end
374
490
 
375
- # Helper methods
376
- def find_nodes(node, type, &block)
377
- return unless node.is_a?(Parser::AST::Node)
491
+ # Service analysis methods
492
+ def analyze_service
493
+ return {} unless @ast
494
+
495
+ {
496
+ rails_type: :service,
497
+ has_call_method: has_call_method?,
498
+ dependencies: detect_service_dependencies,
499
+ error_handling: detect_error_handling,
500
+ complexity_score: calculate_service_complexity,
501
+ rails_smells: detect_service_smells
502
+ }
503
+ end
504
+
505
+ def has_call_method?
506
+ return false unless @ast
507
+
508
+ has_instance_call = false
509
+ has_class_call = false
510
+
511
+ find_nodes(@ast, :def) do |node|
512
+ method_name = node.children[0].to_s
513
+ has_instance_call = true if method_name == 'call'
514
+ end
515
+
516
+ find_nodes(@ast, :defs) do |node|
517
+ method_name = node.children[1].to_s
518
+ has_class_call = true if method_name == 'call'
519
+ end
520
+
521
+ has_instance_call || has_class_call
522
+ end
523
+
524
+ def detect_service_dependencies
525
+ deps = []
526
+
527
+ if @source.match?(/\b(?:[A-Z]\w*)\.(?:find|find_by|where|create|create!|update|update!|all|first|last)\b/)
528
+ deps << :active_record
529
+ end
530
+
531
+ if @source.match?(/\b(?:Net::HTTP|HTTParty|Faraday|RestClient|Typhoeus)\b/)
532
+ deps << :external_api
533
+ end
534
+
535
+ if @source.match?(/\b(?:File|Dir|FileUtils|Pathname)\.\w+/)
536
+ deps << :file_system
537
+ end
538
+
539
+ if @source.match?(/\b(?:Mailer|ActionMailer)\b/) || @source.match?(/\.deliver(?:_now|_later)?\b/)
540
+ deps << :email
541
+ end
542
+
543
+ if @source.match?(/\bRails\.cache\b/) || @source.match?(/\bRedis\b/) || @source.match?(/\bcache_store\b/)
544
+ deps << :cache
545
+ end
546
+
547
+ deps.uniq
548
+ end
549
+
550
+ def detect_error_handling
551
+ error_handling = {
552
+ rescue_blocks: @source.scan(/rescue/).count,
553
+ raise_statements: @source.scan(/raise/).count
554
+ }
555
+
556
+ error_handling[:has_error_handling] = error_handling[:rescue_blocks] > 0 || error_handling[:raise_statements] > 0
557
+ error_handling
558
+ end
559
+
560
+ def calculate_service_complexity
561
+ complexity = 0
562
+
563
+ # Base complexity from dependencies
564
+ complexity += detect_service_dependencies.count * 2
565
+
566
+ # Conditional complexity
567
+ complexity += @source.scan(/\bif\b/).count
568
+ complexity += @source.scan(/\bunless\b/).count
569
+ complexity += @source.scan(/\bcase\b/).count
570
+
571
+ # Error handling complexity
572
+ error_handling = detect_error_handling
573
+ complexity += error_handling[:rescue_blocks] * 2
574
+ complexity += error_handling[:raise_statements]
575
+
576
+ complexity
577
+ end
578
+
579
+ def detect_service_smells
580
+ smells = []
581
+
582
+ unless has_call_method?
583
+ smells << {
584
+ type: :missing_call_method,
585
+ severity: :high
586
+ }
587
+ end
588
+
589
+ complexity = calculate_service_complexity
590
+ if complexity >= 13
591
+ smells << {
592
+ type: :fat_service,
593
+ complexity: complexity,
594
+ severity: :high
595
+ }
596
+ end
597
+
598
+ error_handling = detect_error_handling
599
+ unless error_handling[:has_error_handling]
600
+ smells << {
601
+ type: :missing_error_handling,
602
+ severity: :medium
603
+ }
604
+ end
605
+
606
+ smells
607
+ end
608
+
609
+ # Interactor analysis methods
610
+ def analyze_interactor
611
+ return {} unless @ast
612
+
613
+ {
614
+ rails_type: :interactor,
615
+ has_call_method: has_call_method?,
616
+ context_usage: detect_context_usage,
617
+ fail_usage: detect_fail_usage,
618
+ is_organizer: is_organizer?,
619
+ complexity_score: calculate_interactor_complexity,
620
+ rails_smells: detect_interactor_smells
621
+ }
622
+ end
623
+
624
+ def detect_context_usage
625
+ {
626
+ context_references: @source.scan(/(?<!@)\bcontext\./).count,
627
+ instance_context_references: @source.scan(/@context/).count
628
+ }
629
+ end
630
+
631
+ def detect_fail_usage
632
+ {
633
+ context_fail: @source.scan(/context\.fail/).count,
634
+ # `fail!` only — NOT `context.fail!` (that's counted as context_fail).
635
+ fail_bang: @source.scan(/(?<!\.)\bfail!/).count
636
+ }
637
+ end
638
+
639
+ def is_organizer?
640
+ @source.include?('organize') || @source.include?('Interactor::Organizer')
641
+ end
642
+
643
+ def calculate_interactor_complexity
644
+ complexity = 0
645
+
646
+ # Base complexity for organizers
647
+ complexity += 5 if is_organizer?
648
+
649
+ # Context usage complexity
650
+ context_usage = detect_context_usage
651
+ complexity += context_usage[:context_references]
652
+ complexity += context_usage[:instance_context_references]
653
+
654
+ # Conditional complexity
655
+ complexity += @source.scan(/\bif\b/).count
656
+ complexity += @source.scan(/\bunless\b/).count
657
+
658
+ # Fail usage adds complexity
659
+ fail_usage = detect_fail_usage
660
+ complexity += fail_usage[:context_fail] * 2
661
+ complexity += fail_usage[:fail_bang] * 2
662
+
663
+ complexity
664
+ end
665
+
666
+ def detect_interactor_smells
667
+ smells = []
668
+
669
+ unless has_call_method?
670
+ smells << {
671
+ type: :missing_call_method,
672
+ severity: :high
673
+ }
674
+ end
675
+
676
+ if is_organizer?
677
+ complexity = calculate_interactor_complexity
678
+ if complexity > 20
679
+ smells << {
680
+ type: :complex_organizer,
681
+ complexity: complexity,
682
+ severity: :high
683
+ }
684
+ end
685
+ end
686
+
687
+ fail_usage = detect_fail_usage
688
+ if fail_usage[:context_fail] == 0 && fail_usage[:fail_bang] == 0
689
+ smells << {
690
+ type: :missing_failure_handling,
691
+ severity: :medium
692
+ }
693
+ end
694
+
695
+ smells
696
+ end
697
+
698
+ # Serializer analysis methods
699
+ def analyze_serializer
700
+ return {} unless @ast
378
701
 
379
- yield(node) if node.type == type
702
+ {
703
+ rails_type: :serializer,
704
+ attribute_count: count_serializer_attributes,
705
+ association_count: count_serializer_associations,
706
+ custom_method_count: count_custom_serializer_methods,
707
+ has_conditional_attributes: has_conditional_attributes?,
708
+ rails_smells: detect_serializer_smells
709
+ }
710
+ end
380
711
 
381
- node.children.each do |child|
382
- find_nodes(child, type, &block)
712
+ def count_serializer_attributes
713
+ # Count each symbol passed to attributes/attribute calls.
714
+ # `attributes :id, :name, :email` counts as 3; `attribute :full_name` as 1.
715
+ count = 0
716
+ @source.scan(/^\s*attributes?\s+(.+)$/).each do |(args)|
717
+ count += args.scan(/:\w+/).size
383
718
  end
719
+ count
384
720
  end
385
721
 
386
- def private_controller_method?(method_name)
387
- %w[show new edit create update destroy].include?(method_name) ||
388
- method_name.end_with?('_params')
722
+ def count_serializer_associations
723
+ count = 0
724
+ %w[has_one has_many belongs_to].each do |method|
725
+ @source.scan(/^\s*#{method}\s+(.+)$/).each do |(args)|
726
+ count += args.scan(/:\w+/).size
727
+ end
728
+ end
729
+ count
389
730
  end
731
+
732
+ def count_custom_serializer_methods
733
+ return 0 unless @ast
734
+
735
+ method_count = 0
736
+ standard_methods = %w[initialize attributes serialize serializable_hash]
737
+
738
+ find_nodes(@ast, :def) do |node|
739
+ method_name = node.children[0].to_s
740
+ unless standard_methods.include?(method_name) || method_name.start_with?('_')
741
+ method_count += 1
742
+ end
743
+ end
744
+
745
+ method_count
746
+ end
747
+
748
+ def has_conditional_attributes?
749
+ @source.include?('if:') || @source.include?('unless:') || @source.include?('condition:')
750
+ end
751
+
752
+ def detect_serializer_smells
753
+ smells = []
754
+
755
+ attribute_count = count_serializer_attributes
756
+ association_count = count_serializer_associations
757
+ total_fields = attribute_count + association_count
758
+
759
+ if total_fields > 20
760
+ smells << {
761
+ type: :fat_serializer,
762
+ field_count: total_fields,
763
+ severity: :high
764
+ }
765
+ end
766
+
767
+ custom_method_count = count_custom_serializer_methods
768
+ if custom_method_count > 10
769
+ smells << {
770
+ type: :complex_serializer,
771
+ method_count: custom_method_count,
772
+ severity: :medium
773
+ }
774
+ end
775
+
776
+ if attribute_count == 0 && association_count == 0
777
+ smells << {
778
+ type: :empty_serializer,
779
+ severity: :low
780
+ }
781
+ end
782
+
783
+ smells
784
+ end
785
+
390
786
  end
391
787
  end