rails_code_health 0.1.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 +30 -0
- data/LICENSE.txt +21 -0
- data/README.md +237 -0
- data/bin/rails-health +5 -0
- data/config/tresholds.json +80 -0
- data/lib/rails_code_health/cli.rb +164 -0
- data/lib/rails_code_health/configuration.rb +89 -0
- data/lib/rails_code_health/file_analyzer.rb +117 -0
- data/lib/rails_code_health/health_calculator.rb +370 -0
- data/lib/rails_code_health/project_detector.rb +74 -0
- data/lib/rails_code_health/rails_analyzer.rb +391 -0
- data/lib/rails_code_health/report_generator.rb +335 -0
- data/lib/rails_code_health/ruby_analyzer.rb +319 -0
- data/lib/rails_code_health/version.rb +3 -0
- data/lib/rails_code_health.rb +46 -0
- metadata +186 -0
@@ -0,0 +1,391 @@
|
|
1
|
+
module RailsCodeHealth
|
2
|
+
class RailsAnalyzer
|
3
|
+
def initialize(file_path, file_type)
|
4
|
+
@file_path = file_path
|
5
|
+
@file_type = file_type
|
6
|
+
@source = File.read(file_path)
|
7
|
+
@ast = Parser::CurrentRuby.parse(@source) if file_path.extname == '.rb'
|
8
|
+
rescue Parser::SyntaxError
|
9
|
+
@ast = nil
|
10
|
+
end
|
11
|
+
|
12
|
+
def analyze
|
13
|
+
case @file_type
|
14
|
+
when :controller
|
15
|
+
analyze_controller
|
16
|
+
when :model
|
17
|
+
analyze_model
|
18
|
+
when :view
|
19
|
+
analyze_view
|
20
|
+
when :helper
|
21
|
+
analyze_helper
|
22
|
+
when :migration
|
23
|
+
analyze_migration
|
24
|
+
else
|
25
|
+
{}
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def analyze_controller
|
32
|
+
return {} unless @ast
|
33
|
+
|
34
|
+
{
|
35
|
+
rails_type: :controller,
|
36
|
+
action_count: count_controller_actions,
|
37
|
+
has_before_actions: has_before_actions?,
|
38
|
+
uses_strong_parameters: uses_strong_parameters?,
|
39
|
+
has_direct_model_access: has_direct_model_access?,
|
40
|
+
response_formats: detect_response_formats,
|
41
|
+
rails_smells: detect_controller_smells
|
42
|
+
}
|
43
|
+
end
|
44
|
+
|
45
|
+
def analyze_model
|
46
|
+
return {} unless @ast
|
47
|
+
|
48
|
+
{
|
49
|
+
rails_type: :model,
|
50
|
+
association_count: count_associations,
|
51
|
+
validation_count: count_validations,
|
52
|
+
callback_count: count_callbacks,
|
53
|
+
scope_count: count_scopes,
|
54
|
+
has_fat_model_smell: has_fat_model_smell?,
|
55
|
+
rails_smells: detect_model_smells
|
56
|
+
}
|
57
|
+
end
|
58
|
+
|
59
|
+
def analyze_view
|
60
|
+
lines = @source.lines
|
61
|
+
logic_lines = count_view_logic_lines(lines)
|
62
|
+
|
63
|
+
{
|
64
|
+
rails_type: :view,
|
65
|
+
total_lines: lines.count,
|
66
|
+
logic_lines: logic_lines,
|
67
|
+
has_inline_styles: has_inline_styles?,
|
68
|
+
has_inline_javascript: has_inline_javascript?,
|
69
|
+
rails_smells: detect_view_smells(lines, logic_lines)
|
70
|
+
}
|
71
|
+
end
|
72
|
+
|
73
|
+
def analyze_helper
|
74
|
+
return {} unless @ast
|
75
|
+
|
76
|
+
{
|
77
|
+
rails_type: :helper,
|
78
|
+
method_count: count_helper_methods,
|
79
|
+
rails_smells: detect_helper_smells
|
80
|
+
}
|
81
|
+
end
|
82
|
+
|
83
|
+
def analyze_migration
|
84
|
+
return {} unless @ast
|
85
|
+
|
86
|
+
{
|
87
|
+
rails_type: :migration,
|
88
|
+
has_data_changes: has_data_changes?,
|
89
|
+
has_index_changes: has_index_changes?,
|
90
|
+
complexity_score: calculate_migration_complexity,
|
91
|
+
rails_smells: detect_migration_smells
|
92
|
+
}
|
93
|
+
end
|
94
|
+
|
95
|
+
# Controller analysis methods
|
96
|
+
def count_controller_actions
|
97
|
+
return 0 unless @ast
|
98
|
+
|
99
|
+
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
|
105
|
+
end
|
106
|
+
end
|
107
|
+
action_count
|
108
|
+
end
|
109
|
+
|
110
|
+
def has_before_actions?
|
111
|
+
@source.include?('before_action') || @source.include?('before_filter')
|
112
|
+
end
|
113
|
+
|
114
|
+
def uses_strong_parameters?
|
115
|
+
@source.include?('params.require') || @source.include?('params.permit')
|
116
|
+
end
|
117
|
+
|
118
|
+
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
|
+
end
|
130
|
+
|
131
|
+
def detect_response_formats
|
132
|
+
formats = []
|
133
|
+
formats << :html if @source.include?('format.html')
|
134
|
+
formats << :json if @source.include?('format.json')
|
135
|
+
formats << :xml if @source.include?('format.xml')
|
136
|
+
formats << :js if @source.include?('format.js')
|
137
|
+
formats
|
138
|
+
end
|
139
|
+
|
140
|
+
def detect_controller_smells
|
141
|
+
smells = []
|
142
|
+
|
143
|
+
action_count = count_controller_actions
|
144
|
+
if action_count > 10
|
145
|
+
smells << {
|
146
|
+
type: :too_many_actions,
|
147
|
+
count: action_count,
|
148
|
+
severity: :high
|
149
|
+
}
|
150
|
+
end
|
151
|
+
|
152
|
+
unless uses_strong_parameters?
|
153
|
+
smells << {
|
154
|
+
type: :missing_strong_parameters,
|
155
|
+
severity: :medium
|
156
|
+
}
|
157
|
+
end
|
158
|
+
|
159
|
+
if has_direct_model_access?
|
160
|
+
smells << {
|
161
|
+
type: :direct_model_access,
|
162
|
+
severity: :medium
|
163
|
+
}
|
164
|
+
end
|
165
|
+
|
166
|
+
smells
|
167
|
+
end
|
168
|
+
|
169
|
+
# Model analysis methods
|
170
|
+
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
|
176
|
+
end
|
177
|
+
|
178
|
+
associations
|
179
|
+
end
|
180
|
+
|
181
|
+
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
|
187
|
+
end
|
188
|
+
|
189
|
+
validations
|
190
|
+
end
|
191
|
+
|
192
|
+
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
|
198
|
+
end
|
199
|
+
|
200
|
+
callbacks
|
201
|
+
end
|
202
|
+
|
203
|
+
def count_scopes
|
204
|
+
@source.scan(/scope\s+:/).count
|
205
|
+
end
|
206
|
+
|
207
|
+
def has_fat_model_smell?
|
208
|
+
return false unless @ast
|
209
|
+
|
210
|
+
line_count = @source.lines.count
|
211
|
+
method_count = 0
|
212
|
+
find_nodes(@ast, :def) { method_count += 1 }
|
213
|
+
|
214
|
+
line_count > 200 && method_count > 15
|
215
|
+
end
|
216
|
+
|
217
|
+
def detect_model_smells
|
218
|
+
smells = []
|
219
|
+
|
220
|
+
if has_fat_model_smell?
|
221
|
+
smells << {
|
222
|
+
type: :fat_model,
|
223
|
+
severity: :high
|
224
|
+
}
|
225
|
+
end
|
226
|
+
|
227
|
+
callback_count = count_callbacks
|
228
|
+
if callback_count > 5
|
229
|
+
smells << {
|
230
|
+
type: :callback_hell,
|
231
|
+
count: callback_count,
|
232
|
+
severity: :medium
|
233
|
+
}
|
234
|
+
end
|
235
|
+
|
236
|
+
validation_count = count_validations
|
237
|
+
if validation_count == 0
|
238
|
+
smells << {
|
239
|
+
type: :missing_validations,
|
240
|
+
severity: :low
|
241
|
+
}
|
242
|
+
end
|
243
|
+
|
244
|
+
smells
|
245
|
+
end
|
246
|
+
|
247
|
+
# 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/)
|
254
|
+
end
|
255
|
+
|
256
|
+
logic_count
|
257
|
+
end
|
258
|
+
|
259
|
+
def has_inline_styles?
|
260
|
+
@source.include?('style=') || @source.include?('<style>')
|
261
|
+
end
|
262
|
+
|
263
|
+
def has_inline_javascript?
|
264
|
+
@source.include?('<script>') || @source.include?('onclick=') || @source.include?('onload=')
|
265
|
+
end
|
266
|
+
|
267
|
+
def detect_view_smells(lines, logic_lines)
|
268
|
+
smells = []
|
269
|
+
|
270
|
+
if lines.count > 50
|
271
|
+
smells << {
|
272
|
+
type: :long_view,
|
273
|
+
line_count: lines.count,
|
274
|
+
severity: :medium
|
275
|
+
}
|
276
|
+
end
|
277
|
+
|
278
|
+
if logic_lines > 10
|
279
|
+
smells << {
|
280
|
+
type: :logic_in_view,
|
281
|
+
logic_lines: logic_lines,
|
282
|
+
severity: :high
|
283
|
+
}
|
284
|
+
end
|
285
|
+
|
286
|
+
if has_inline_styles?
|
287
|
+
smells << {
|
288
|
+
type: :inline_styles,
|
289
|
+
severity: :low
|
290
|
+
}
|
291
|
+
end
|
292
|
+
|
293
|
+
if has_inline_javascript?
|
294
|
+
smells << {
|
295
|
+
type: :inline_javascript,
|
296
|
+
severity: :medium
|
297
|
+
}
|
298
|
+
end
|
299
|
+
|
300
|
+
smells
|
301
|
+
end
|
302
|
+
|
303
|
+
# Helper analysis methods
|
304
|
+
def count_helper_methods
|
305
|
+
return 0 unless @ast
|
306
|
+
|
307
|
+
method_count = 0
|
308
|
+
find_nodes(@ast, :def) { method_count += 1 }
|
309
|
+
method_count
|
310
|
+
end
|
311
|
+
|
312
|
+
def detect_helper_smells
|
313
|
+
smells = []
|
314
|
+
|
315
|
+
method_count = count_helper_methods
|
316
|
+
if method_count > 15
|
317
|
+
smells << {
|
318
|
+
type: :fat_helper,
|
319
|
+
method_count: method_count,
|
320
|
+
severity: :medium
|
321
|
+
}
|
322
|
+
end
|
323
|
+
|
324
|
+
smells
|
325
|
+
end
|
326
|
+
|
327
|
+
# Migration analysis methods
|
328
|
+
def has_data_changes?
|
329
|
+
data_methods = %w[execute update_all delete_all]
|
330
|
+
data_methods.any? { |method| @source.include?(method) }
|
331
|
+
end
|
332
|
+
|
333
|
+
def has_index_changes?
|
334
|
+
@source.include?('add_index') || @source.include?('remove_index')
|
335
|
+
end
|
336
|
+
|
337
|
+
def calculate_migration_complexity
|
338
|
+
complexity = 0
|
339
|
+
|
340
|
+
# Count different types of operations
|
341
|
+
complexity += @source.scan(/create_table/).count * 2
|
342
|
+
complexity += @source.scan(/drop_table/).count * 2
|
343
|
+
complexity += @source.scan(/add_column/).count * 1
|
344
|
+
complexity += @source.scan(/remove_column/).count * 1
|
345
|
+
complexity += @source.scan(/change_column/).count * 2
|
346
|
+
complexity += @source.scan(/add_index/).count * 1
|
347
|
+
complexity += @source.scan(/remove_index/).count * 1
|
348
|
+
complexity += @source.scan(/execute/).count * 3
|
349
|
+
|
350
|
+
complexity
|
351
|
+
end
|
352
|
+
|
353
|
+
def detect_migration_smells
|
354
|
+
smells = []
|
355
|
+
|
356
|
+
if has_data_changes?
|
357
|
+
smells << {
|
358
|
+
type: :data_changes_in_migration,
|
359
|
+
severity: :high
|
360
|
+
}
|
361
|
+
end
|
362
|
+
|
363
|
+
complexity = calculate_migration_complexity
|
364
|
+
if complexity > 20
|
365
|
+
smells << {
|
366
|
+
type: :complex_migration,
|
367
|
+
complexity: complexity,
|
368
|
+
severity: :medium
|
369
|
+
}
|
370
|
+
end
|
371
|
+
|
372
|
+
smells
|
373
|
+
end
|
374
|
+
|
375
|
+
# Helper methods
|
376
|
+
def find_nodes(node, type, &block)
|
377
|
+
return unless node.is_a?(Parser::AST::Node)
|
378
|
+
|
379
|
+
yield(node) if node.type == type
|
380
|
+
|
381
|
+
node.children.each do |child|
|
382
|
+
find_nodes(child, type, &block)
|
383
|
+
end
|
384
|
+
end
|
385
|
+
|
386
|
+
def private_controller_method?(method_name)
|
387
|
+
%w[show new edit create update destroy].include?(method_name) ||
|
388
|
+
method_name.end_with?('_params')
|
389
|
+
end
|
390
|
+
end
|
391
|
+
end
|