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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -2
- data/README.md +6 -1
- data/lib/rails_code_health/ast_helpers.rb +126 -0
- data/lib/rails_code_health/configuration.rb +21 -1
- data/lib/rails_code_health/file_analyzer.rb +7 -2
- data/lib/rails_code_health/health_calculator.rb +129 -0
- data/lib/rails_code_health/project_detector.rb +76 -1
- data/lib/rails_code_health/rails_analyzer.rb +456 -60
- data/lib/rails_code_health/report_generator.rb +138 -3
- data/lib/rails_code_health/ruby_analyzer.rb +19 -22
- data/lib/rails_code_health/version.rb +1 -1
- data/lib/rails_code_health.rb +1 -0
- metadata +4 -6
|
@@ -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, :
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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(
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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
|
-
#
|
|
376
|
-
def
|
|
377
|
-
return unless
|
|
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
|
-
|
|
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
|
-
|
|
382
|
-
|
|
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
|
|
387
|
-
|
|
388
|
-
|
|
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
|