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,335 @@
|
|
1
|
+
module RailsCodeHealth
|
2
|
+
class ReportGenerator
|
3
|
+
def initialize(scored_results)
|
4
|
+
@results = scored_results
|
5
|
+
end
|
6
|
+
|
7
|
+
def generate
|
8
|
+
puts generate_summary_report
|
9
|
+
puts "\n" + "="*80 + "\n"
|
10
|
+
puts generate_detailed_report
|
11
|
+
puts "\n" + "="*80 + "\n"
|
12
|
+
puts generate_recommendations_report
|
13
|
+
end
|
14
|
+
|
15
|
+
def generate_json_report
|
16
|
+
{
|
17
|
+
summary: generate_summary_data,
|
18
|
+
files: @results.map { |result| format_file_result(result) },
|
19
|
+
recommendations: collect_all_recommendations,
|
20
|
+
generated_at: Time.now.iso8601
|
21
|
+
}.to_json
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def generate_summary_report
|
27
|
+
summary = []
|
28
|
+
summary << "Rails Code Health Report"
|
29
|
+
summary << "=" * 50
|
30
|
+
summary << ""
|
31
|
+
|
32
|
+
total_files = @results.count
|
33
|
+
healthy_files = @results.count { |r| r[:health_category] == :healthy }
|
34
|
+
warning_files = @results.count { |r| r[:health_category] == :warning }
|
35
|
+
alert_files = @results.count { |r| r[:health_category] == :alert }
|
36
|
+
critical_files = @results.count { |r| r[:health_category] == :critical }
|
37
|
+
|
38
|
+
summary << "📊 Overall Health Summary:"
|
39
|
+
summary << " Total files analyzed: #{total_files}"
|
40
|
+
summary << " 🟢 Healthy files (8.0-10.0): #{healthy_files} (#{percentage(healthy_files, total_files)}%)"
|
41
|
+
summary << " 🟡 Warning files (4.0-7.9): #{warning_files} (#{percentage(warning_files, total_files)}%)"
|
42
|
+
summary << " 🔴 Alert files (1.0-3.9): #{alert_files} (#{percentage(alert_files, total_files)}%)"
|
43
|
+
summary << " ⚫ Critical files (<1.0): #{critical_files} (#{percentage(critical_files, total_files)}%)" if critical_files > 0
|
44
|
+
summary << ""
|
45
|
+
|
46
|
+
average_score = (@results.sum { |r| r[:health_score] || 0 } / total_files.to_f).round(1)
|
47
|
+
summary << "📈 Average Health Score: #{average_score}/10.0"
|
48
|
+
|
49
|
+
summary << ""
|
50
|
+
summary << generate_file_type_breakdown
|
51
|
+
|
52
|
+
summary.join("\n")
|
53
|
+
end
|
54
|
+
|
55
|
+
def generate_file_type_breakdown
|
56
|
+
breakdown = []
|
57
|
+
breakdown << "📂 Breakdown by File Type:"
|
58
|
+
|
59
|
+
file_types = @results.group_by { |r| r[:file_type] }
|
60
|
+
|
61
|
+
file_types.each do |type, files|
|
62
|
+
next if files.empty?
|
63
|
+
|
64
|
+
avg_score = (files.sum { |f| f[:health_score] || 0 } / files.count.to_f).round(1)
|
65
|
+
healthy_count = files.count { |f| f[:health_category] == :healthy }
|
66
|
+
|
67
|
+
breakdown << " #{type.to_s.capitalize}: #{files.count} files, avg score: #{avg_score}, #{healthy_count} healthy"
|
68
|
+
end
|
69
|
+
|
70
|
+
breakdown.join("\n")
|
71
|
+
end
|
72
|
+
|
73
|
+
def generate_detailed_report
|
74
|
+
detailed = []
|
75
|
+
detailed << "📋 Detailed File Analysis"
|
76
|
+
detailed << "=" * 50
|
77
|
+
detailed << ""
|
78
|
+
|
79
|
+
# Sort by health score (worst first)
|
80
|
+
sorted_results = @results.sort_by { |r| r[:health_score] || 0 }
|
81
|
+
|
82
|
+
# Show worst 10 files
|
83
|
+
worst_files = sorted_results.first(10)
|
84
|
+
|
85
|
+
detailed << "🚨 Files Needing Most Attention (Bottom 10):"
|
86
|
+
detailed << ""
|
87
|
+
|
88
|
+
worst_files.each_with_index do |result, index|
|
89
|
+
detailed << format_file_summary(result, index + 1)
|
90
|
+
detailed << ""
|
91
|
+
end
|
92
|
+
|
93
|
+
# Show best files if we have healthy ones
|
94
|
+
healthy_files = @results.select { |r| r[:health_category] == :healthy }
|
95
|
+
if healthy_files.any?
|
96
|
+
detailed << "✅ Top Performing Files:"
|
97
|
+
detailed << ""
|
98
|
+
|
99
|
+
best_files = healthy_files.sort_by { |r| -(r[:health_score] || 0) }.first(5)
|
100
|
+
best_files.each_with_index do |result, index|
|
101
|
+
detailed << format_file_summary(result, index + 1, prefix: "👍")
|
102
|
+
detailed << ""
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
detailed.join("\n")
|
107
|
+
end
|
108
|
+
|
109
|
+
def generate_recommendations_report
|
110
|
+
recommendations = []
|
111
|
+
recommendations << "💡 Key Recommendations"
|
112
|
+
recommendations << "=" * 50
|
113
|
+
recommendations << ""
|
114
|
+
|
115
|
+
# Collect and categorize all recommendations
|
116
|
+
all_recommendations = collect_all_recommendations
|
117
|
+
|
118
|
+
if all_recommendations.empty?
|
119
|
+
recommendations << "🎉 Great job! No major issues found."
|
120
|
+
return recommendations.join("\n")
|
121
|
+
end
|
122
|
+
|
123
|
+
# Group recommendations by frequency
|
124
|
+
recommendation_counts = Hash.new(0)
|
125
|
+
all_recommendations.each { |rec| recommendation_counts[rec] += 1 }
|
126
|
+
|
127
|
+
# Sort by frequency (most common first)
|
128
|
+
sorted_recommendations = recommendation_counts.sort_by { |_, count| -count }
|
129
|
+
|
130
|
+
recommendations << "📈 Most Common Issues (by frequency):"
|
131
|
+
recommendations << ""
|
132
|
+
|
133
|
+
sorted_recommendations.first(10).each_with_index do |(rec, count), index|
|
134
|
+
recommendations << "#{index + 1}. #{rec} (#{count} occurrence#{'s' if count > 1})"
|
135
|
+
end
|
136
|
+
|
137
|
+
recommendations << ""
|
138
|
+
recommendations << "🎯 Priority Actions:"
|
139
|
+
recommendations << ""
|
140
|
+
|
141
|
+
# Find files with lowest scores and their recommendations
|
142
|
+
critical_files = @results.select { |r| r[:health_score] && r[:health_score] < 4.0 }
|
143
|
+
if critical_files.any?
|
144
|
+
recommendations << "1. 🚨 Address critical files immediately:"
|
145
|
+
critical_files.first(3).each do |file|
|
146
|
+
recommendations << " - #{file[:relative_path]} (score: #{file[:health_score]})"
|
147
|
+
if file[:recommendations] && file[:recommendations].any?
|
148
|
+
file[:recommendations].first(2).each do |rec|
|
149
|
+
recommendations << " • #{rec}"
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
recommendations << ""
|
154
|
+
end
|
155
|
+
|
156
|
+
recommendations << "2. 🔧 Focus on these improvement areas:"
|
157
|
+
recommendations << " - Reduce method and class lengths"
|
158
|
+
recommendations << " - Lower cyclomatic complexity"
|
159
|
+
recommendations << " - Follow Rails conventions"
|
160
|
+
recommendations << " - Extract business logic from controllers and views"
|
161
|
+
|
162
|
+
recommendations.join("\n")
|
163
|
+
end
|
164
|
+
|
165
|
+
def format_file_summary(result, rank, prefix: "🔍")
|
166
|
+
summary = []
|
167
|
+
|
168
|
+
health_emoji = case result[:health_category]
|
169
|
+
when :healthy then "🟢"
|
170
|
+
when :warning then "🟡"
|
171
|
+
when :alert then "🔴"
|
172
|
+
when :critical then "⚫"
|
173
|
+
else "❓"
|
174
|
+
end
|
175
|
+
|
176
|
+
summary << "#{rank}. #{prefix} #{health_emoji} #{result[:relative_path]}"
|
177
|
+
summary << " Score: #{result[:health_score]}/10.0 | Type: #{result[:file_type]} | Size: #{format_file_size(result[:file_size])}"
|
178
|
+
|
179
|
+
# Add key metrics if available
|
180
|
+
if result[:ruby_analysis]
|
181
|
+
metrics = []
|
182
|
+
if result[:ruby_analysis][:method_metrics]
|
183
|
+
method_count = result[:ruby_analysis][:method_metrics].count
|
184
|
+
avg_complexity = result[:ruby_analysis][:method_metrics].map { |m| m[:cyclomatic_complexity] }.sum.to_f / method_count
|
185
|
+
metrics << "#{method_count} methods"
|
186
|
+
metrics << "avg complexity: #{avg_complexity.round(1)}"
|
187
|
+
end
|
188
|
+
|
189
|
+
if result[:ruby_analysis][:class_metrics]
|
190
|
+
class_count = result[:ruby_analysis][:class_metrics].count
|
191
|
+
metrics << "#{class_count} class#{'es' if class_count != 1}"
|
192
|
+
end
|
193
|
+
|
194
|
+
summary << " Metrics: #{metrics.join(', ')}" if metrics.any?
|
195
|
+
end
|
196
|
+
|
197
|
+
# Add Rails-specific info
|
198
|
+
if result[:rails_analysis]
|
199
|
+
rails_info = []
|
200
|
+
case result[:rails_analysis][:rails_type]
|
201
|
+
when :controller
|
202
|
+
rails_info << "#{result[:rails_analysis][:action_count]} actions"
|
203
|
+
when :model
|
204
|
+
rails_info << "#{result[:rails_analysis][:association_count]} associations" if result[:rails_analysis][:association_count]
|
205
|
+
rails_info << "#{result[:rails_analysis][:validation_count]} validations" if result[:rails_analysis][:validation_count]
|
206
|
+
when :view
|
207
|
+
rails_info << "#{result[:rails_analysis][:logic_lines]} logic lines" if result[:rails_analysis][:logic_lines]
|
208
|
+
end
|
209
|
+
|
210
|
+
summary << " Rails: #{rails_info.join(', ')}" if rails_info.any?
|
211
|
+
end
|
212
|
+
|
213
|
+
# Add top recommendations
|
214
|
+
if result[:recommendations] && result[:recommendations].any?
|
215
|
+
summary << " Top issues:"
|
216
|
+
result[:recommendations].first(2).each do |rec|
|
217
|
+
summary << " • #{rec}"
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
summary.join("\n")
|
222
|
+
end
|
223
|
+
|
224
|
+
def collect_all_recommendations
|
225
|
+
@results.flat_map { |r| r[:recommendations] || [] }.uniq
|
226
|
+
end
|
227
|
+
|
228
|
+
def generate_summary_data
|
229
|
+
total_files = @results.count
|
230
|
+
{
|
231
|
+
total_files: total_files,
|
232
|
+
healthy_files: @results.count { |r| r[:health_category] == :healthy },
|
233
|
+
warning_files: @results.count { |r| r[:health_category] == :warning },
|
234
|
+
alert_files: @results.count { |r| r[:health_category] == :alert },
|
235
|
+
critical_files: @results.count { |r| r[:health_category] == :critical },
|
236
|
+
average_score: (@results.sum { |r| r[:health_score] || 0 } / total_files.to_f).round(1),
|
237
|
+
file_types: generate_file_type_summary
|
238
|
+
}
|
239
|
+
end
|
240
|
+
|
241
|
+
def generate_file_type_summary
|
242
|
+
file_types = @results.group_by { |r| r[:file_type] }
|
243
|
+
|
244
|
+
file_types.transform_values do |files|
|
245
|
+
{
|
246
|
+
count: files.count,
|
247
|
+
average_score: (files.sum { |f| f[:health_score] || 0 } / files.count.to_f).round(1),
|
248
|
+
healthy_count: files.count { |f| f[:health_category] == :healthy }
|
249
|
+
}
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
def format_file_result(result)
|
254
|
+
{
|
255
|
+
file_path: result[:relative_path],
|
256
|
+
file_type: result[:file_type],
|
257
|
+
health_score: result[:health_score],
|
258
|
+
health_category: result[:health_category],
|
259
|
+
file_size: result[:file_size],
|
260
|
+
last_modified: result[:last_modified],
|
261
|
+
recommendations: result[:recommendations] || [],
|
262
|
+
metrics: extract_key_metrics(result)
|
263
|
+
}
|
264
|
+
end
|
265
|
+
|
266
|
+
def extract_key_metrics(result)
|
267
|
+
metrics = {}
|
268
|
+
|
269
|
+
if result[:ruby_analysis]
|
270
|
+
ruby_metrics = result[:ruby_analysis]
|
271
|
+
|
272
|
+
if ruby_metrics[:file_metrics]
|
273
|
+
metrics[:lines_of_code] = ruby_metrics[:file_metrics][:code_lines]
|
274
|
+
metrics[:total_lines] = ruby_metrics[:file_metrics][:total_lines]
|
275
|
+
end
|
276
|
+
|
277
|
+
if ruby_metrics[:method_metrics]
|
278
|
+
methods = ruby_metrics[:method_metrics]
|
279
|
+
metrics[:method_count] = methods.count
|
280
|
+
metrics[:average_method_length] = methods.map { |m| m[:line_count] }.sum.to_f / methods.count if methods.any?
|
281
|
+
metrics[:average_complexity] = methods.map { |m| m[:cyclomatic_complexity] }.sum.to_f / methods.count if methods.any?
|
282
|
+
metrics[:max_complexity] = methods.map { |m| m[:cyclomatic_complexity] }.max
|
283
|
+
end
|
284
|
+
|
285
|
+
if ruby_metrics[:class_metrics]
|
286
|
+
classes = ruby_metrics[:class_metrics]
|
287
|
+
metrics[:class_count] = classes.count
|
288
|
+
metrics[:average_class_length] = classes.map { |c| c[:line_count] }.sum.to_f / classes.count if classes.any?
|
289
|
+
end
|
290
|
+
end
|
291
|
+
|
292
|
+
if result[:rails_analysis]
|
293
|
+
rails_metrics = result[:rails_analysis]
|
294
|
+
|
295
|
+
case rails_metrics[:rails_type]
|
296
|
+
when :controller
|
297
|
+
metrics[:controller_actions] = rails_metrics[:action_count]
|
298
|
+
metrics[:uses_strong_parameters] = rails_metrics[:uses_strong_parameters]
|
299
|
+
when :model
|
300
|
+
metrics[:associations] = rails_metrics[:association_count]
|
301
|
+
metrics[:validations] = rails_metrics[:validation_count]
|
302
|
+
metrics[:callbacks] = rails_metrics[:callback_count]
|
303
|
+
when :view
|
304
|
+
metrics[:view_logic_lines] = rails_metrics[:logic_lines]
|
305
|
+
metrics[:has_inline_styles] = rails_metrics[:has_inline_styles]
|
306
|
+
end
|
307
|
+
end
|
308
|
+
|
309
|
+
# Round float values
|
310
|
+
metrics.transform_values do |value|
|
311
|
+
value.is_a?(Float) ? value.round(2) : value
|
312
|
+
end
|
313
|
+
end
|
314
|
+
|
315
|
+
def percentage(part, total)
|
316
|
+
return 0 if total.zero?
|
317
|
+
((part.to_f / total) * 100).round(1)
|
318
|
+
end
|
319
|
+
|
320
|
+
def format_file_size(size_in_bytes)
|
321
|
+
return "0 B" if size_in_bytes.nil? || size_in_bytes.zero?
|
322
|
+
|
323
|
+
units = %w[B KB MB GB]
|
324
|
+
size = size_in_bytes.to_f
|
325
|
+
unit_index = 0
|
326
|
+
|
327
|
+
while size >= 1024 && unit_index < units.length - 1
|
328
|
+
size /= 1024
|
329
|
+
unit_index += 1
|
330
|
+
end
|
331
|
+
|
332
|
+
"#{size.round(1)} #{units[unit_index]}"
|
333
|
+
end
|
334
|
+
end
|
335
|
+
end
|
@@ -0,0 +1,319 @@
|
|
1
|
+
module RailsCodeHealth
|
2
|
+
class RubyAnalyzer
|
3
|
+
def initialize(file_path)
|
4
|
+
@file_path = file_path
|
5
|
+
@source = File.read(file_path)
|
6
|
+
@ast = Parser::CurrentRuby.parse(@source)
|
7
|
+
rescue Parser::SyntaxError => e
|
8
|
+
@parse_error = e
|
9
|
+
@ast = nil
|
10
|
+
end
|
11
|
+
|
12
|
+
def analyze
|
13
|
+
return { parse_error: @parse_error.message } if @parse_error
|
14
|
+
|
15
|
+
{
|
16
|
+
file_metrics: analyze_file,
|
17
|
+
class_metrics: analyze_classes,
|
18
|
+
method_metrics: analyze_methods,
|
19
|
+
complexity_metrics: analyze_complexity,
|
20
|
+
code_smells: detect_code_smells
|
21
|
+
}
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def analyze_file
|
27
|
+
lines = @source.lines
|
28
|
+
{
|
29
|
+
total_lines: lines.count,
|
30
|
+
code_lines: lines.reject { |line| line.strip.empty? || line.strip.start_with?('#') }.count,
|
31
|
+
comment_lines: lines.count { |line| line.strip.start_with?('#') },
|
32
|
+
blank_lines: lines.count { |line| line.strip.empty? }
|
33
|
+
}
|
34
|
+
end
|
35
|
+
|
36
|
+
def analyze_classes
|
37
|
+
return [] unless @ast
|
38
|
+
|
39
|
+
classes = []
|
40
|
+
find_nodes(@ast, :class) do |node|
|
41
|
+
classes << {
|
42
|
+
name: extract_class_name(node),
|
43
|
+
line_count: count_lines_in_node(node),
|
44
|
+
method_count: count_methods_in_class(node),
|
45
|
+
public_method_count: count_public_methods_in_class(node),
|
46
|
+
inheritance: extract_inheritance(node),
|
47
|
+
complexity_score: calculate_class_complexity(node)
|
48
|
+
}
|
49
|
+
end
|
50
|
+
classes
|
51
|
+
end
|
52
|
+
|
53
|
+
def analyze_methods
|
54
|
+
return [] unless @ast
|
55
|
+
|
56
|
+
methods = []
|
57
|
+
find_nodes(@ast, :def) do |node|
|
58
|
+
methods << {
|
59
|
+
name: node.children[0],
|
60
|
+
line_count: count_lines_in_node(node),
|
61
|
+
parameter_count: count_parameters(node),
|
62
|
+
cyclomatic_complexity: calculate_cyclomatic_complexity(node),
|
63
|
+
nesting_depth: calculate_max_nesting_depth(node),
|
64
|
+
abc_score: calculate_abc_score(node),
|
65
|
+
has_rescue: has_rescue_block?(node)
|
66
|
+
}
|
67
|
+
end
|
68
|
+
methods
|
69
|
+
end
|
70
|
+
|
71
|
+
def analyze_complexity
|
72
|
+
return {} unless @ast
|
73
|
+
|
74
|
+
{
|
75
|
+
overall_complexity: calculate_overall_complexity,
|
76
|
+
max_nesting_depth: find_max_nesting_depth(@ast),
|
77
|
+
conditional_complexity: count_conditionals(@ast),
|
78
|
+
loop_complexity: count_loops(@ast)
|
79
|
+
}
|
80
|
+
end
|
81
|
+
|
82
|
+
def detect_code_smells
|
83
|
+
smells = []
|
84
|
+
smells.concat(detect_long_methods)
|
85
|
+
smells.concat(detect_god_classes)
|
86
|
+
smells.concat(detect_high_complexity_methods)
|
87
|
+
smells.concat(detect_too_many_parameters)
|
88
|
+
smells.concat(detect_nested_conditionals)
|
89
|
+
smells
|
90
|
+
end
|
91
|
+
|
92
|
+
# AST traversal helper
|
93
|
+
def find_nodes(node, type, &block)
|
94
|
+
return unless node.is_a?(Parser::AST::Node)
|
95
|
+
|
96
|
+
yield(node) if node.type == type
|
97
|
+
|
98
|
+
node.children.each do |child|
|
99
|
+
find_nodes(child, type, &block)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
# Complexity calculations
|
104
|
+
def calculate_cyclomatic_complexity(node)
|
105
|
+
complexity = 1 # Base complexity
|
106
|
+
|
107
|
+
find_nodes(node, :if) { complexity += 1 }
|
108
|
+
find_nodes(node, :case) { complexity += 1 }
|
109
|
+
find_nodes(node, :while) { complexity += 1 }
|
110
|
+
find_nodes(node, :until) { complexity += 1 }
|
111
|
+
find_nodes(node, :for) { complexity += 1 }
|
112
|
+
find_nodes(node, :rescue) { complexity += 1 }
|
113
|
+
find_nodes(node, :when) { complexity += 1 }
|
114
|
+
|
115
|
+
complexity
|
116
|
+
end
|
117
|
+
|
118
|
+
def calculate_max_nesting_depth(node, depth = 0)
|
119
|
+
return depth unless node.is_a?(Parser::AST::Node)
|
120
|
+
|
121
|
+
max_depth = depth
|
122
|
+
|
123
|
+
if nesting_node?(node)
|
124
|
+
depth += 1
|
125
|
+
max_depth = depth
|
126
|
+
end
|
127
|
+
|
128
|
+
node.children.each do |child|
|
129
|
+
child_depth = calculate_max_nesting_depth(child, depth)
|
130
|
+
max_depth = [max_depth, child_depth].max
|
131
|
+
end
|
132
|
+
|
133
|
+
max_depth
|
134
|
+
end
|
135
|
+
|
136
|
+
def calculate_abc_score(node)
|
137
|
+
assignments = 0
|
138
|
+
branches = 0
|
139
|
+
conditions = 0
|
140
|
+
|
141
|
+
find_nodes(node, :lvasgn) { assignments += 1 }
|
142
|
+
find_nodes(node, :ivasgn) { assignments += 1 }
|
143
|
+
find_nodes(node, :send) { branches += 1 }
|
144
|
+
find_nodes(node, :if) { conditions += 1 }
|
145
|
+
find_nodes(node, :case) { conditions += 1 }
|
146
|
+
|
147
|
+
Math.sqrt(assignments**2 + branches**2 + conditions**2).round(2)
|
148
|
+
end
|
149
|
+
|
150
|
+
# Code smell detection
|
151
|
+
def detect_long_methods
|
152
|
+
methods = []
|
153
|
+
find_nodes(@ast, :def) do |node|
|
154
|
+
line_count = count_lines_in_node(node)
|
155
|
+
if line_count > RailsCodeHealth.configuration.thresholds['ruby_thresholds']['method_length']['red']
|
156
|
+
methods << {
|
157
|
+
type: :long_method,
|
158
|
+
method_name: node.children[0],
|
159
|
+
line_count: line_count,
|
160
|
+
severity: :high
|
161
|
+
}
|
162
|
+
end
|
163
|
+
end
|
164
|
+
methods
|
165
|
+
end
|
166
|
+
|
167
|
+
def detect_god_classes
|
168
|
+
classes = []
|
169
|
+
find_nodes(@ast, :class) do |node|
|
170
|
+
line_count = count_lines_in_node(node)
|
171
|
+
method_count = count_methods_in_class(node)
|
172
|
+
|
173
|
+
if line_count > 400 && method_count > 20
|
174
|
+
classes << {
|
175
|
+
type: :god_class,
|
176
|
+
class_name: extract_class_name(node),
|
177
|
+
line_count: line_count,
|
178
|
+
method_count: method_count,
|
179
|
+
severity: :high
|
180
|
+
}
|
181
|
+
end
|
182
|
+
end
|
183
|
+
classes
|
184
|
+
end
|
185
|
+
|
186
|
+
def detect_high_complexity_methods
|
187
|
+
methods = []
|
188
|
+
find_nodes(@ast, :def) do |node|
|
189
|
+
complexity = calculate_cyclomatic_complexity(node)
|
190
|
+
if complexity > 15
|
191
|
+
methods << {
|
192
|
+
type: :high_complexity,
|
193
|
+
method_name: node.children[0],
|
194
|
+
complexity: complexity,
|
195
|
+
severity: :high
|
196
|
+
}
|
197
|
+
end
|
198
|
+
end
|
199
|
+
methods
|
200
|
+
end
|
201
|
+
|
202
|
+
def detect_too_many_parameters
|
203
|
+
methods = []
|
204
|
+
find_nodes(@ast, :def) do |node|
|
205
|
+
param_count = count_parameters(node)
|
206
|
+
if param_count > 5
|
207
|
+
methods << {
|
208
|
+
type: :too_many_parameters,
|
209
|
+
method_name: node.children[0],
|
210
|
+
parameter_count: param_count,
|
211
|
+
severity: :medium
|
212
|
+
}
|
213
|
+
end
|
214
|
+
end
|
215
|
+
methods
|
216
|
+
end
|
217
|
+
|
218
|
+
def detect_nested_conditionals
|
219
|
+
methods = []
|
220
|
+
find_nodes(@ast, :def) do |node|
|
221
|
+
max_depth = calculate_max_nesting_depth(node)
|
222
|
+
if max_depth > 4
|
223
|
+
methods << {
|
224
|
+
type: :nested_conditionals,
|
225
|
+
method_name: node.children[0],
|
226
|
+
nesting_depth: max_depth,
|
227
|
+
severity: :medium
|
228
|
+
}
|
229
|
+
end
|
230
|
+
end
|
231
|
+
methods
|
232
|
+
end
|
233
|
+
|
234
|
+
# Helper methods
|
235
|
+
def count_lines_in_node(node)
|
236
|
+
return 0 unless node.respond_to?(:loc) && node.loc.respond_to?(:last_line)
|
237
|
+
|
238
|
+
node.loc.last_line - node.loc.first_line + 1
|
239
|
+
end
|
240
|
+
|
241
|
+
def count_methods_in_class(class_node)
|
242
|
+
method_count = 0
|
243
|
+
find_nodes(class_node, :def) { method_count += 1 }
|
244
|
+
method_count
|
245
|
+
end
|
246
|
+
|
247
|
+
def count_public_methods_in_class(class_node)
|
248
|
+
# This is a simplified version - in reality, you'd need to track visibility modifiers
|
249
|
+
count_methods_in_class(class_node)
|
250
|
+
end
|
251
|
+
|
252
|
+
def count_parameters(method_node)
|
253
|
+
args_node = method_node.children[1]
|
254
|
+
return 0 unless args_node
|
255
|
+
|
256
|
+
args_node.children.count
|
257
|
+
end
|
258
|
+
|
259
|
+
def extract_class_name(class_node)
|
260
|
+
class_node.children[0]&.children&.last || 'Unknown'
|
261
|
+
end
|
262
|
+
|
263
|
+
def extract_inheritance(class_node)
|
264
|
+
superclass_node = class_node.children[1]
|
265
|
+
return nil unless superclass_node
|
266
|
+
|
267
|
+
if superclass_node.type == :const
|
268
|
+
superclass_node.children.last
|
269
|
+
else
|
270
|
+
'Unknown'
|
271
|
+
end
|
272
|
+
end
|
273
|
+
|
274
|
+
def calculate_class_complexity(class_node)
|
275
|
+
total_complexity = 0
|
276
|
+
find_nodes(class_node, :def) do |method_node|
|
277
|
+
total_complexity += calculate_cyclomatic_complexity(method_node)
|
278
|
+
end
|
279
|
+
total_complexity
|
280
|
+
end
|
281
|
+
|
282
|
+
def calculate_overall_complexity
|
283
|
+
total = 0
|
284
|
+
find_nodes(@ast, :def) do |node|
|
285
|
+
total += calculate_cyclomatic_complexity(node)
|
286
|
+
end
|
287
|
+
total
|
288
|
+
end
|
289
|
+
|
290
|
+
def find_max_nesting_depth(node)
|
291
|
+
calculate_max_nesting_depth(node)
|
292
|
+
end
|
293
|
+
|
294
|
+
def count_conditionals(node)
|
295
|
+
count = 0
|
296
|
+
find_nodes(node, :if) { count += 1 }
|
297
|
+
find_nodes(node, :case) { count += 1 }
|
298
|
+
count
|
299
|
+
end
|
300
|
+
|
301
|
+
def count_loops(node)
|
302
|
+
count = 0
|
303
|
+
find_nodes(node, :while) { count += 1 }
|
304
|
+
find_nodes(node, :until) { count += 1 }
|
305
|
+
find_nodes(node, :for) { count += 1 }
|
306
|
+
count
|
307
|
+
end
|
308
|
+
|
309
|
+
def nesting_node?(node)
|
310
|
+
[:if, :case, :while, :until, :for, :begin, :block].include?(node.type)
|
311
|
+
end
|
312
|
+
|
313
|
+
def has_rescue_block?(node)
|
314
|
+
has_rescue = false
|
315
|
+
find_nodes(node, :rescue) { has_rescue = true }
|
316
|
+
has_rescue
|
317
|
+
end
|
318
|
+
end
|
319
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
require 'parser/current'
|
2
|
+
require 'ast'
|
3
|
+
require 'rubocop/ast'
|
4
|
+
require 'active_support'
|
5
|
+
require 'json'
|
6
|
+
require 'pathname'
|
7
|
+
|
8
|
+
require_relative 'rails_code_health/version'
|
9
|
+
require_relative 'rails_code_health/configuration'
|
10
|
+
require_relative 'rails_code_health/project_detector'
|
11
|
+
require_relative 'rails_code_health/file_analyzer'
|
12
|
+
require_relative 'rails_code_health/ruby_analyzer'
|
13
|
+
require_relative 'rails_code_health/rails_analyzer'
|
14
|
+
require_relative 'rails_code_health/health_calculator'
|
15
|
+
require_relative 'rails_code_health/report_generator'
|
16
|
+
require_relative 'rails_code_health/cli'
|
17
|
+
|
18
|
+
module RailsCodeHealth
|
19
|
+
class Error < StandardError; end
|
20
|
+
|
21
|
+
class << self
|
22
|
+
def analyze(path = '.')
|
23
|
+
project_path = Pathname.new(path).expand_path
|
24
|
+
|
25
|
+
unless ProjectDetector.rails_project?(project_path)
|
26
|
+
raise Error, "Not a Rails project directory: #{project_path}"
|
27
|
+
end
|
28
|
+
|
29
|
+
analyzer = FileAnalyzer.new(project_path)
|
30
|
+
results = analyzer.analyze_all
|
31
|
+
|
32
|
+
health_calculator = HealthCalculator.new
|
33
|
+
scored_results = health_calculator.calculate_scores(results)
|
34
|
+
|
35
|
+
ReportGenerator.new(scored_results).generate
|
36
|
+
end
|
37
|
+
|
38
|
+
def configuration
|
39
|
+
@configuration ||= Configuration.new
|
40
|
+
end
|
41
|
+
|
42
|
+
def configure
|
43
|
+
yield(configuration) if block_given?
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|