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.
@@ -0,0 +1,117 @@
1
+ module RailsCodeHealth
2
+ class FileAnalyzer
3
+ def initialize(project_path)
4
+ @project_path = Pathname.new(project_path)
5
+ end
6
+
7
+ def analyze_all
8
+ ruby_files = find_ruby_files
9
+ view_files = find_view_files
10
+
11
+ all_files = ruby_files + view_files
12
+
13
+ all_files.map do |file_path|
14
+ analyze_file(file_path)
15
+ end.compact
16
+ end
17
+
18
+ def analyze_file(file_path)
19
+ file_path = Pathname.new(file_path) unless file_path.is_a?(Pathname)
20
+
21
+ return nil unless file_path.exist?
22
+
23
+ file_type = ProjectDetector.detect_file_type(file_path, @project_path)
24
+ return nil unless file_type
25
+
26
+ result = {
27
+ file_path: file_path.to_s,
28
+ relative_path: file_path.relative_path_from(@project_path).to_s,
29
+ file_type: file_type,
30
+ file_size: file_path.size,
31
+ last_modified: file_path.mtime
32
+ }
33
+
34
+ # Analyze Ruby code if it's a Ruby file
35
+ if file_path.extname == '.rb'
36
+ ruby_analyzer = RubyAnalyzer.new(file_path)
37
+ result[:ruby_analysis] = ruby_analyzer.analyze
38
+ end
39
+
40
+ # Add Rails-specific analysis
41
+ rails_analyzer = RailsAnalyzer.new(file_path, file_type)
42
+ rails_analysis = rails_analyzer.analyze
43
+ result[:rails_analysis] = rails_analysis unless rails_analysis.empty?
44
+
45
+ result
46
+ rescue => e
47
+ {
48
+ file_path: file_path.to_s,
49
+ relative_path: file_path.relative_path_from(@project_path).to_s,
50
+ file_type: file_type,
51
+ error: "Analysis failed: #{e.message}"
52
+ }
53
+ end
54
+
55
+ private
56
+
57
+ def find_ruby_files
58
+ ruby_patterns = [
59
+ @project_path + 'app/**/*.rb',
60
+ @project_path + 'lib/**/*.rb',
61
+ @project_path + 'config/**/*.rb',
62
+ @project_path + 'db/migrate/*.rb'
63
+ ]
64
+
65
+ files = []
66
+ ruby_patterns.each do |pattern|
67
+ files.concat(Dir.glob(pattern))
68
+ end
69
+
70
+ # Filter out files we don't want to analyze
71
+ files.reject! do |file|
72
+ relative_path = Pathname.new(file).relative_path_from(@project_path).to_s
73
+ should_skip_file?(relative_path)
74
+ end
75
+
76
+ files.map { |f| Pathname.new(f) }
77
+ end
78
+
79
+ def find_view_files
80
+ view_patterns = [
81
+ @project_path + 'app/views/**/*.erb',
82
+ @project_path + 'app/views/**/*.haml',
83
+ @project_path + 'app/views/**/*.slim'
84
+ ]
85
+
86
+ files = []
87
+ view_patterns.each do |pattern|
88
+ files.concat(Dir.glob(pattern))
89
+ end
90
+
91
+ files.map { |f| Pathname.new(f) }
92
+ end
93
+
94
+ def should_skip_file?(relative_path)
95
+ skip_patterns = [
96
+ %r{^vendor/},
97
+ %r{^tmp/},
98
+ %r{^log/},
99
+ %r{^node_modules/},
100
+ %r{^coverage/},
101
+ %r{\.git/},
102
+ %r{^public/assets/},
103
+ %r{^db/schema\.rb$},
104
+ %r{^config/routes\.rb$}, # Often auto-generated and long
105
+ %r{^config/application\.rb$}, # Framework boilerplate
106
+ %r{^config/environment\.rb$}, # Framework boilerplate
107
+ %r{^config/environments/}, # Environment configs
108
+ %r{^config/initializers/devise\.rb$}, # Often very long generated files
109
+ %r{^app/assets/},
110
+ %r{_test\.rb$},
111
+ %r{_spec\.rb$}
112
+ ]
113
+
114
+ skip_patterns.any? { |pattern| relative_path.match?(pattern) }
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,370 @@
1
+ module RailsCodeHealth
2
+ class HealthCalculator
3
+ def initialize
4
+ @config = RailsCodeHealth.configuration
5
+ @thresholds = @config.thresholds
6
+ @weights = @thresholds['scoring_weights']
7
+ end
8
+
9
+ def calculate_scores(analysis_results)
10
+ analysis_results.map do |file_result|
11
+ health_score = calculate_file_health_score(file_result)
12
+ file_result.merge(
13
+ health_score: health_score,
14
+ health_category: categorize_health(health_score),
15
+ recommendations: generate_recommendations(file_result)
16
+ )
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def calculate_file_health_score(file_result)
23
+ # Start with a perfect score of 10
24
+ base_score = 10.0
25
+
26
+ # Apply penalties based on different factors
27
+ penalties = []
28
+
29
+ # Ruby-specific penalties
30
+ penalties.concat(calculate_ruby_penalties(file_result))
31
+
32
+ # Rails-specific penalties
33
+ penalties.concat(calculate_rails_penalties(file_result))
34
+
35
+ # Code smell penalties
36
+ penalties.concat(calculate_code_smell_penalties(file_result))
37
+
38
+ # Apply file type multiplier
39
+ file_type_multiplier = get_file_type_multiplier(file_result[:file_type])
40
+
41
+ # Calculate weighted penalty
42
+ total_penalty = penalties.sum * file_type_multiplier
43
+
44
+ # Ensure score doesn't go below 1
45
+ final_score = [base_score - total_penalty, 1.0].max
46
+
47
+ final_score.round(1)
48
+ end
49
+
50
+ def calculate_ruby_penalties(file_result)
51
+ return [] unless file_result[:ruby_analysis]
52
+
53
+ penalties = []
54
+ ruby_data = file_result[:ruby_analysis]
55
+
56
+ # Method length penalties
57
+ if ruby_data[:method_metrics]
58
+ ruby_data[:method_metrics].each do |method|
59
+ penalty = calculate_method_length_penalty(method[:line_count])
60
+ penalties << penalty * @weights['method_length'] if penalty > 0
61
+ end
62
+ end
63
+
64
+ # Class length penalties
65
+ if ruby_data[:class_metrics]
66
+ ruby_data[:class_metrics].each do |klass|
67
+ penalty = calculate_class_length_penalty(klass[:line_count])
68
+ penalties << penalty * @weights['class_length'] if penalty > 0
69
+ end
70
+ end
71
+
72
+ # Complexity penalties
73
+ if ruby_data[:method_metrics]
74
+ ruby_data[:method_metrics].each do |method|
75
+ # Cyclomatic complexity
76
+ complexity_penalty = calculate_complexity_penalty(method[:cyclomatic_complexity])
77
+ penalties << complexity_penalty * @weights['cyclomatic_complexity'] if complexity_penalty > 0
78
+
79
+ # Nesting depth
80
+ nesting_penalty = calculate_nesting_penalty(method[:nesting_depth])
81
+ penalties << nesting_penalty * @weights['nesting_depth'] if nesting_penalty > 0
82
+
83
+ # Parameter count
84
+ param_penalty = calculate_parameter_penalty(method[:parameter_count])
85
+ penalties << param_penalty * @weights['parameter_count'] if param_penalty > 0
86
+ end
87
+ end
88
+
89
+ penalties
90
+ end
91
+
92
+ def calculate_rails_penalties(file_result)
93
+ return [] unless file_result[:rails_analysis]
94
+
95
+ penalties = []
96
+ rails_data = file_result[:rails_analysis]
97
+ file_type = rails_data[:rails_type]
98
+
99
+ case file_type
100
+ when :controller
101
+ penalties.concat(calculate_controller_penalties(rails_data))
102
+ when :model
103
+ penalties.concat(calculate_model_penalties(rails_data))
104
+ when :view
105
+ penalties.concat(calculate_view_penalties(rails_data))
106
+ when :helper
107
+ penalties.concat(calculate_helper_penalties(rails_data))
108
+ when :migration
109
+ penalties.concat(calculate_migration_penalties(rails_data))
110
+ end
111
+
112
+ penalties
113
+ end
114
+
115
+ def calculate_controller_penalties(controller_data)
116
+ penalties = []
117
+
118
+ # Too many actions
119
+ action_count = controller_data[:action_count] || 0
120
+ if action_count > @thresholds['rails_specific']['controller_actions']['yellow']
121
+ severity = action_count > @thresholds['rails_specific']['controller_actions']['red'] ? 2.0 : 1.0
122
+ penalties << severity * @weights['rails_conventions']
123
+ end
124
+
125
+ # Missing strong parameters
126
+ unless controller_data[:uses_strong_parameters]
127
+ penalties << 1.0 * @weights['rails_conventions']
128
+ end
129
+
130
+ # Direct model access
131
+ if controller_data[:has_direct_model_access]
132
+ penalties << 1.5 * @weights['rails_conventions']
133
+ end
134
+
135
+ penalties
136
+ end
137
+
138
+ def calculate_model_penalties(model_data)
139
+ penalties = []
140
+
141
+ # Fat model
142
+ if model_data[:has_fat_model_smell]
143
+ penalties << 2.0 * @weights['rails_conventions']
144
+ end
145
+
146
+ # Missing validations
147
+ validation_count = model_data[:validation_count] || 0
148
+ if validation_count == 0
149
+ penalties << 0.5 * @weights['rails_conventions']
150
+ end
151
+
152
+ # Too many callbacks
153
+ callback_count = model_data[:callback_count] || 0
154
+ if callback_count > 5
155
+ penalties << 1.0 * @weights['rails_conventions']
156
+ end
157
+
158
+ penalties
159
+ end
160
+
161
+ def calculate_view_penalties(view_data)
162
+ penalties = []
163
+
164
+ # Long views
165
+ total_lines = view_data[:total_lines] || 0
166
+ if total_lines > @thresholds['rails_specific']['view_length']['yellow']
167
+ severity = total_lines > @thresholds['rails_specific']['view_length']['red'] ? 2.0 : 1.0
168
+ penalties << severity * @weights['rails_conventions']
169
+ end
170
+
171
+ # Logic in views
172
+ logic_lines = view_data[:logic_lines] || 0
173
+ if logic_lines > 5
174
+ penalties << (logic_lines / 5.0) * @weights['rails_conventions']
175
+ end
176
+
177
+ # Inline styles/JavaScript
178
+ if view_data[:has_inline_styles]
179
+ penalties << 0.5 * @weights['rails_conventions']
180
+ end
181
+
182
+ if view_data[:has_inline_javascript]
183
+ penalties << 1.0 * @weights['rails_conventions']
184
+ end
185
+
186
+ penalties
187
+ end
188
+
189
+ def calculate_helper_penalties(helper_data)
190
+ penalties = []
191
+
192
+ method_count = helper_data[:method_count] || 0
193
+ if method_count > 15
194
+ penalties << 1.0 * @weights['rails_conventions']
195
+ end
196
+
197
+ penalties
198
+ end
199
+
200
+ def calculate_migration_penalties(migration_data)
201
+ penalties = []
202
+
203
+ if migration_data[:has_data_changes]
204
+ penalties << 2.0 * @weights['rails_conventions']
205
+ end
206
+
207
+ complexity = migration_data[:complexity_score] || 0
208
+ if complexity > 20
209
+ penalties << 1.0 * @weights['rails_conventions']
210
+ end
211
+
212
+ penalties
213
+ end
214
+
215
+ def calculate_code_smell_penalties(file_result)
216
+ penalties = []
217
+
218
+ # Ruby code smells
219
+ if file_result[:ruby_analysis] && file_result[:ruby_analysis][:code_smells]
220
+ file_result[:ruby_analysis][:code_smells].each do |smell|
221
+ penalty = case smell[:severity]
222
+ when :high then 2.0
223
+ when :medium then 1.0
224
+ when :low then 0.5
225
+ else 0.5
226
+ end
227
+ penalties << penalty * @weights['code_smells']
228
+ end
229
+ end
230
+
231
+ # Rails code smells
232
+ if file_result[:rails_analysis] && file_result[:rails_analysis][:rails_smells]
233
+ file_result[:rails_analysis][:rails_smells].each do |smell|
234
+ penalty = case smell[:severity]
235
+ when :high then 2.0
236
+ when :medium then 1.0
237
+ when :low then 0.5
238
+ else 0.5
239
+ end
240
+ penalties << penalty * @weights['code_smells']
241
+ end
242
+ end
243
+
244
+ penalties
245
+ end
246
+
247
+ # Individual penalty calculation methods
248
+ def calculate_method_length_penalty(line_count)
249
+ thresholds = @thresholds['ruby_thresholds']['method_length']
250
+ return 0 if line_count <= thresholds['green']
251
+ return 1.0 if line_count <= thresholds['yellow']
252
+ return 2.0 if line_count <= thresholds['red']
253
+ 3.0 # Extremely long methods
254
+ end
255
+
256
+ def calculate_class_length_penalty(line_count)
257
+ thresholds = @thresholds['ruby_thresholds']['class_length']
258
+ return 0 if line_count <= thresholds['green']
259
+ return 1.0 if line_count <= thresholds['yellow']
260
+ return 2.0 if line_count <= thresholds['red']
261
+ 3.0 # Extremely long classes
262
+ end
263
+
264
+ def calculate_complexity_penalty(complexity)
265
+ thresholds = @thresholds['ruby_thresholds']['cyclomatic_complexity']
266
+ return 0 if complexity <= thresholds['green']
267
+ return 1.0 if complexity <= thresholds['yellow']
268
+ return 2.0 if complexity <= thresholds['red']
269
+ 3.0 # Extremely complex methods
270
+ end
271
+
272
+ def calculate_nesting_penalty(depth)
273
+ thresholds = @thresholds['ruby_thresholds']['nesting_depth']
274
+ return 0 if depth <= thresholds['green']
275
+ return 1.0 if depth <= thresholds['yellow']
276
+ return 2.0 if depth <= thresholds['red']
277
+ 3.0 # Extremely nested code
278
+ end
279
+
280
+ def calculate_parameter_penalty(param_count)
281
+ thresholds = @thresholds['ruby_thresholds']['parameter_count']
282
+ return 0 if param_count <= thresholds['green']
283
+ return 0.5 if param_count <= thresholds['yellow']
284
+ return 1.0 if param_count <= thresholds['red']
285
+ 2.0 # Too many parameters
286
+ end
287
+
288
+ def get_file_type_multiplier(file_type)
289
+ @thresholds['file_type_multipliers'][file_type.to_s] || 1.0
290
+ end
291
+
292
+ def categorize_health(score)
293
+ case score
294
+ when 8.0..10.0
295
+ :healthy
296
+ when 4.0...8.0
297
+ :warning
298
+ when 1.0...4.0
299
+ :alert
300
+ else
301
+ :critical
302
+ end
303
+ end
304
+
305
+ def generate_recommendations(file_result)
306
+ recommendations = []
307
+
308
+ # Add recommendations based on analysis results
309
+ if file_result[:ruby_analysis]
310
+ recommendations.concat(generate_ruby_recommendations(file_result[:ruby_analysis]))
311
+ end
312
+
313
+ if file_result[:rails_analysis]
314
+ recommendations.concat(generate_rails_recommendations(file_result[:rails_analysis]))
315
+ end
316
+
317
+ recommendations.uniq
318
+ end
319
+
320
+ def generate_ruby_recommendations(ruby_analysis)
321
+ recommendations = []
322
+
323
+ if ruby_analysis[:code_smells]
324
+ ruby_analysis[:code_smells].each do |smell|
325
+ case smell[:type]
326
+ when :long_method
327
+ recommendations << "Break down the #{smell[:method_name]} method (#{smell[:line_count]} lines) into smaller, focused methods"
328
+ when :god_class
329
+ recommendations << "Refactor #{smell[:class_name]} class into smaller, more focused classes"
330
+ when :high_complexity
331
+ recommendations << "Reduce complexity of #{smell[:method_name]} method (complexity: #{smell[:complexity]})"
332
+ when :too_many_parameters
333
+ recommendations << "Reduce parameter count for #{smell[:method_name]} method or introduce parameter objects"
334
+ when :nested_conditionals
335
+ recommendations << "Reduce nesting depth in #{smell[:method_name]} method using guard clauses or extraction"
336
+ end
337
+ end
338
+ end
339
+
340
+ recommendations
341
+ end
342
+
343
+ def generate_rails_recommendations(rails_analysis)
344
+ recommendations = []
345
+
346
+ if rails_analysis[:rails_smells]
347
+ rails_analysis[:rails_smells].each do |smell|
348
+ case smell[:type]
349
+ when :too_many_actions
350
+ recommendations << "Consider splitting this controller - it has #{smell[:count]} actions"
351
+ when :missing_strong_parameters
352
+ recommendations << "Implement strong parameters for security"
353
+ when :direct_model_access
354
+ recommendations << "Move model logic to the model layer or service objects"
355
+ when :fat_model
356
+ recommendations << "Extract business logic into service objects or concerns"
357
+ when :logic_in_view
358
+ recommendations << "Move view logic to helpers or presenters (#{smell[:logic_lines]} logic lines found)"
359
+ when :callback_hell
360
+ recommendations << "Reduce model callbacks (#{smell[:count]} found) - consider service objects"
361
+ when :data_changes_in_migration
362
+ recommendations << "Avoid data changes in migrations - use rake tasks instead"
363
+ end
364
+ end
365
+ end
366
+
367
+ recommendations
368
+ end
369
+ end
370
+ end
@@ -0,0 +1,74 @@
1
+ module RailsCodeHealth
2
+ class ProjectDetector
3
+ RAILS_INDICATORS = [
4
+ 'config/application.rb',
5
+ 'config/environment.rb',
6
+ 'Gemfile'
7
+ ].freeze
8
+
9
+ RAILS_DIRECTORIES = [
10
+ 'app/controllers',
11
+ 'app/models',
12
+ 'app/views',
13
+ 'config'
14
+ ].freeze
15
+
16
+ class << self
17
+ def rails_project?(path)
18
+ path = Pathname.new(path) unless path.is_a?(Pathname)
19
+
20
+ has_rails_files?(path) && has_rails_structure?(path) && has_rails_gemfile?(path)
21
+ end
22
+
23
+ def detect_file_type(file_path, project_root)
24
+ relative_path = file_path.relative_path_from(project_root).to_s
25
+
26
+ case relative_path
27
+ when %r{^app/controllers/.*_controller\.rb$}
28
+ :controller
29
+ when %r{^app/models/.*\.rb$}
30
+ :model
31
+ when %r{^app/views/.*\.(erb|haml|slim)$}
32
+ :view
33
+ when %r{^app/helpers/.*_helper\.rb$}
34
+ :helper
35
+ when %r{^lib/.*\.rb$}
36
+ :lib
37
+ when %r{^db/migrate/.*\.rb$}
38
+ :migration
39
+ when %r{^spec/.*_spec\.rb$}, %r{^test/.*_test\.rb$}
40
+ :test
41
+ when %r{^config/.*\.rb$}
42
+ :config
43
+ else
44
+ :ruby if file_path.extname == '.rb'
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def has_rails_files?(path)
51
+ RAILS_INDICATORS.any? { |file| (path + file).exist? }
52
+ end
53
+
54
+ def has_rails_structure?(path)
55
+ RAILS_DIRECTORIES.all? { |dir| (path + dir).directory? }
56
+ end
57
+
58
+ def has_rails_gemfile?(path)
59
+ gemfile_path = path + 'Gemfile'
60
+ return false unless gemfile_path.exist?
61
+
62
+ begin
63
+ gemfile_content = gemfile_path.read
64
+ gemfile_content.include?('rails') ||
65
+ gemfile_content.include?('railties') ||
66
+ gemfile_content.include?('activesupport')
67
+ rescue => e
68
+ # If we can't read the Gemfile, assume it's not Rails
69
+ false
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end