rails_code_health 0.1.0 → 0.2.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 +20 -2
- data/README.md +2 -1
- data/lib/rails_code_health/configuration.rb +14 -1
- data/lib/rails_code_health/file_analyzer.rb +4 -1
- data/lib/rails_code_health/health_calculator.rb +125 -0
- data/lib/rails_code_health/project_detector.rb +76 -1
- data/lib/rails_code_health/rails_analyzer.rb +321 -0
- data/lib/rails_code_health/report_generator.rb +138 -3
- data/lib/rails_code_health/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: aaafc84bb8cf4bad9931fb39ef97d3a1bf76a61bc4e2289191df870f10a33e1e
|
4
|
+
data.tar.gz: ad77a703a06b6eee946756f7645ef76de9c943fcadd192c27a425bd37f3027fd
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 832e4d0437e9e92e121da7a890f3451ee5f37047a586db713edfb07265ab55e5f8c0f221b21f9c4eccb974b7e4b4429ae28cedcd0c7ec5c060f633733996cea8
|
7
|
+
data.tar.gz: f00785bb224064d66c01486a4929fd74ba7102559f9280c3c95f619fb67034a41babcb7e2e93ffe74cb37a08848d7d19d9f0cc25a8aaab460ad5f667e695b926
|
data/CHANGELOG.md
CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
7
7
|
|
8
8
|
## [Unreleased]
|
9
9
|
|
10
|
+
## [0.2.0] - 2025-06-19
|
11
|
+
|
12
|
+
### Added
|
13
|
+
- Comprehensive test suite with RSpec covering all major components
|
14
|
+
- Test coverage for Configuration, FileAnalyzer, HealthCalculator, ProjectDetector, RailsAnalyzer, and ReportGenerator
|
15
|
+
|
16
|
+
### Changed
|
17
|
+
- Enhanced Configuration class with improved validation and error handling
|
18
|
+
- Improved FileAnalyzer with better file processing capabilities
|
19
|
+
- Updated HealthCalculator with more robust scoring algorithms
|
20
|
+
- Enhanced ProjectDetector with better Rails project detection
|
21
|
+
- Improved RailsAnalyzer with more comprehensive Rails pattern analysis
|
22
|
+
- Enhanced ReportGenerator with better formatting and output options
|
23
|
+
|
24
|
+
### Fixed
|
25
|
+
- Various bug fixes and improvements based on user feedback
|
26
|
+
|
10
27
|
## [0.1.0] - 2025-06-17
|
11
28
|
|
12
29
|
### Added
|
@@ -26,5 +43,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
26
43
|
- **Reporting**: Detailed console output with health categories and JSON export
|
27
44
|
- **CLI**: `rails-health` command with options for format, output file, and custom configuration
|
28
45
|
|
29
|
-
[Unreleased]: https://github.com/
|
30
|
-
[0.
|
46
|
+
[Unreleased]: https://github.com/gkosmo/rails_code_health/compare/v0.2.0...HEAD
|
47
|
+
[0.2.0]: https://github.com/gkosmo/rails_code_health/compare/v0.1.0...v0.2.0
|
48
|
+
[0.1.0]: https://github.com/gkosmo/rails_code_health/releases/tag/v0.1.0
|
data/README.md
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
# Rails Code Health
|
2
2
|
|
3
|
-
A Ruby gem that evaluates the code health of Ruby on Rails applications, inspired by CodeScene's research on technical debt and maintainability.
|
3
|
+
A Ruby gem that evaluates the code health of Ruby on Rails applications, [inspired by CodeScene's research on technical debt and maintainability](https://arxiv.org/pdf/2203.04374
|
4
|
+
).
|
4
5
|
|
5
6
|
## Overview
|
6
7
|
|
@@ -64,6 +64,10 @@ module RailsCodeHealth
|
|
64
64
|
'view_length' => { 'green' => 30, 'yellow' => 50, 'red' => 100 },
|
65
65
|
'migration_complexity' => { 'green' => 10, 'yellow' => 20, 'red' => 40 }
|
66
66
|
},
|
67
|
+
'service_thresholds' => {
|
68
|
+
'dependency_count' => { 'green' => 3, 'yellow' => 5, 'red' => 8 },
|
69
|
+
'complexity_score' => { 'green' => 10, 'yellow' => 15, 'red' => 25 }
|
70
|
+
},
|
67
71
|
'file_type_multipliers' => {
|
68
72
|
'controllers' => 1.2,
|
69
73
|
'models' => 1.0,
|
@@ -71,7 +75,16 @@ module RailsCodeHealth
|
|
71
75
|
'helpers' => 0.9,
|
72
76
|
'lib' => 1.1,
|
73
77
|
'specs' => 0.7,
|
74
|
-
'migrations' => 0.6
|
78
|
+
'migrations' => 0.6,
|
79
|
+
'service' => 1.3,
|
80
|
+
'interactor' => 1.2,
|
81
|
+
'serializer' => 0.9,
|
82
|
+
'form' => 1.1,
|
83
|
+
'decorator' => 0.9,
|
84
|
+
'presenter' => 0.9,
|
85
|
+
'policy' => 1.2,
|
86
|
+
'job' => 1.0,
|
87
|
+
'worker' => 1.0
|
75
88
|
},
|
76
89
|
'scoring_weights' => {
|
77
90
|
'method_length' => 0.15,
|
@@ -80,7 +80,10 @@ module RailsCodeHealth
|
|
80
80
|
view_patterns = [
|
81
81
|
@project_path + 'app/views/**/*.erb',
|
82
82
|
@project_path + 'app/views/**/*.haml',
|
83
|
-
@project_path + 'app/views/**/*.slim'
|
83
|
+
@project_path + 'app/views/**/*.slim',
|
84
|
+
@project_path + 'app/**/views/**/*.erb',
|
85
|
+
@project_path + 'app/**/views/**/*.haml',
|
86
|
+
@project_path + 'app/**/views/**/*.slim'
|
84
87
|
]
|
85
88
|
|
86
89
|
files = []
|
@@ -107,6 +107,12 @@ module RailsCodeHealth
|
|
107
107
|
penalties.concat(calculate_helper_penalties(rails_data))
|
108
108
|
when :migration
|
109
109
|
penalties.concat(calculate_migration_penalties(rails_data))
|
110
|
+
when :service
|
111
|
+
penalties.concat(calculate_service_penalties(rails_data))
|
112
|
+
when :interactor
|
113
|
+
penalties.concat(calculate_interactor_penalties(rails_data))
|
114
|
+
when :serializer
|
115
|
+
penalties.concat(calculate_serializer_penalties(rails_data))
|
110
116
|
end
|
111
117
|
|
112
118
|
penalties
|
@@ -132,6 +138,11 @@ module RailsCodeHealth
|
|
132
138
|
penalties << 1.5 * @weights['rails_conventions']
|
133
139
|
end
|
134
140
|
|
141
|
+
# Business logic in controller
|
142
|
+
if controller_data[:has_business_logic]
|
143
|
+
penalties << 2.0 * @weights['rails_conventions']
|
144
|
+
end
|
145
|
+
|
135
146
|
penalties
|
136
147
|
end
|
137
148
|
|
@@ -212,6 +223,102 @@ module RailsCodeHealth
|
|
212
223
|
penalties
|
213
224
|
end
|
214
225
|
|
226
|
+
def calculate_service_penalties(service_data)
|
227
|
+
penalties = []
|
228
|
+
|
229
|
+
# Missing call method - critical for services
|
230
|
+
unless service_data[:has_call_method]
|
231
|
+
penalties << 3.0 * @weights['rails_conventions']
|
232
|
+
end
|
233
|
+
|
234
|
+
# Too many dependencies
|
235
|
+
dependency_count = service_data[:dependencies]&.count || 0
|
236
|
+
service_thresholds = @thresholds['service_thresholds']['dependency_count']
|
237
|
+
if dependency_count > service_thresholds['yellow']
|
238
|
+
severity = dependency_count > service_thresholds['red'] ? 2.0 : 1.0
|
239
|
+
penalties << severity * @weights['rails_conventions']
|
240
|
+
end
|
241
|
+
|
242
|
+
# High complexity
|
243
|
+
complexity = service_data[:complexity_score] || 0
|
244
|
+
complexity_thresholds = @thresholds['service_thresholds']['complexity_score']
|
245
|
+
if complexity > complexity_thresholds['yellow']
|
246
|
+
severity = complexity > complexity_thresholds['red'] ? 2.0 : 1.0
|
247
|
+
penalties << severity * @weights['rails_conventions']
|
248
|
+
end
|
249
|
+
|
250
|
+
# Missing error handling
|
251
|
+
error_handling = service_data[:error_handling] || {}
|
252
|
+
unless error_handling[:has_error_handling]
|
253
|
+
penalties << 1.0 * @weights['rails_conventions']
|
254
|
+
end
|
255
|
+
|
256
|
+
penalties
|
257
|
+
end
|
258
|
+
|
259
|
+
def calculate_interactor_penalties(interactor_data)
|
260
|
+
penalties = []
|
261
|
+
|
262
|
+
# Missing call method - critical for interactors
|
263
|
+
unless interactor_data[:has_call_method]
|
264
|
+
penalties << 3.0 * @weights['rails_conventions']
|
265
|
+
end
|
266
|
+
|
267
|
+
# Complex organizers
|
268
|
+
if interactor_data[:is_organizer]
|
269
|
+
complexity = interactor_data[:complexity_score] || 0
|
270
|
+
if complexity > 20 # Organizers should be simple orchestrators
|
271
|
+
penalties << 2.0 * @weights['rails_conventions']
|
272
|
+
end
|
273
|
+
end
|
274
|
+
|
275
|
+
# Missing failure handling
|
276
|
+
fail_usage = interactor_data[:fail_usage] || {}
|
277
|
+
if (fail_usage[:context_fail] || 0) == 0 && (fail_usage[:fail_bang] || 0) == 0
|
278
|
+
penalties << 1.5 * @weights['rails_conventions']
|
279
|
+
end
|
280
|
+
|
281
|
+
# High complexity for regular interactors
|
282
|
+
unless interactor_data[:is_organizer]
|
283
|
+
complexity = interactor_data[:complexity_score] || 0
|
284
|
+
if complexity > 15
|
285
|
+
penalties << 1.5 * @weights['rails_conventions']
|
286
|
+
end
|
287
|
+
end
|
288
|
+
|
289
|
+
penalties
|
290
|
+
end
|
291
|
+
|
292
|
+
def calculate_serializer_penalties(serializer_data)
|
293
|
+
penalties = []
|
294
|
+
|
295
|
+
# Too many attributes/associations (fat serializer)
|
296
|
+
attribute_count = serializer_data[:attribute_count] || 0
|
297
|
+
association_count = serializer_data[:association_count] || 0
|
298
|
+
total_fields = attribute_count + association_count
|
299
|
+
|
300
|
+
if total_fields > 20
|
301
|
+
penalties << 2.0 * @weights['rails_conventions']
|
302
|
+
elsif total_fields > 15
|
303
|
+
penalties << 1.0 * @weights['rails_conventions']
|
304
|
+
end
|
305
|
+
|
306
|
+
# Too many custom methods (complex logic)
|
307
|
+
custom_method_count = serializer_data[:custom_method_count] || 0
|
308
|
+
if custom_method_count > 10
|
309
|
+
penalties << 1.5 * @weights['rails_conventions']
|
310
|
+
elsif custom_method_count > 5
|
311
|
+
penalties << 0.5 * @weights['rails_conventions']
|
312
|
+
end
|
313
|
+
|
314
|
+
# Empty serializer (no value)
|
315
|
+
if total_fields == 0 && custom_method_count == 0
|
316
|
+
penalties << 1.0 * @weights['rails_conventions']
|
317
|
+
end
|
318
|
+
|
319
|
+
penalties
|
320
|
+
end
|
321
|
+
|
215
322
|
def calculate_code_smell_penalties(file_result)
|
216
323
|
penalties = []
|
217
324
|
|
@@ -360,6 +467,24 @@ module RailsCodeHealth
|
|
360
467
|
recommendations << "Reduce model callbacks (#{smell[:count]} found) - consider service objects"
|
361
468
|
when :data_changes_in_migration
|
362
469
|
recommendations << "Avoid data changes in migrations - use rake tasks instead"
|
470
|
+
when :business_logic_in_controller
|
471
|
+
recommendations << "Extract business logic from controller to service objects"
|
472
|
+
when :missing_call_method
|
473
|
+
recommendations << "Add a call method to follow service/interactor conventions"
|
474
|
+
when :fat_service
|
475
|
+
recommendations << "Break down service into smaller, focused services (complexity: #{smell[:complexity]})"
|
476
|
+
when :missing_error_handling
|
477
|
+
recommendations << "Add proper error handling with rescue blocks"
|
478
|
+
when :complex_organizer
|
479
|
+
recommendations << "Simplify organizer - it should only orchestrate other interactors (complexity: #{smell[:complexity]})"
|
480
|
+
when :missing_failure_handling
|
481
|
+
recommendations << "Add failure handling using context.fail or fail! methods"
|
482
|
+
when :fat_serializer
|
483
|
+
recommendations << "Split serializer - it has #{smell[:field_count]} fields"
|
484
|
+
when :complex_serializer
|
485
|
+
recommendations << "Move complex logic out of serializer (#{smell[:method_count]} custom methods)"
|
486
|
+
when :empty_serializer
|
487
|
+
recommendations << "Add attributes or associations to provide value"
|
363
488
|
end
|
364
489
|
end
|
365
490
|
end
|
@@ -24,7 +24,7 @@ module RailsCodeHealth
|
|
24
24
|
relative_path = file_path.relative_path_from(project_root).to_s
|
25
25
|
|
26
26
|
case relative_path
|
27
|
-
when %r{^app/controllers/.*_controller\.rb$}
|
27
|
+
when %r{^app/.*/controllers/.*_controller\.rb$}, %r{^app/controllers/.*_controller\.rb$}
|
28
28
|
:controller
|
29
29
|
when %r{^app/models/.*\.rb$}
|
30
30
|
:model
|
@@ -32,6 +32,28 @@ module RailsCodeHealth
|
|
32
32
|
:view
|
33
33
|
when %r{^app/helpers/.*_helper\.rb$}
|
34
34
|
:helper
|
35
|
+
when %r{^app/.*/services/.*\.rb$}, %r{^app/services/.*\.rb$}
|
36
|
+
:service
|
37
|
+
when %r{^app/.*/interactors/.*\.rb$}, %r{^app/interactors/.*\.rb$}
|
38
|
+
:interactor
|
39
|
+
when %r{^app/.*/serializers/.*\.rb$}, %r{^app/serializers/.*\.rb$}
|
40
|
+
:serializer
|
41
|
+
when %r{^app/.*/forms/.*\.rb$}, %r{^app/forms/.*\.rb$}
|
42
|
+
:form
|
43
|
+
when %r{^app/.*/decorators/.*\.rb$}, %r{^app/decorators/.*\.rb$}
|
44
|
+
:decorator
|
45
|
+
when %r{^app/.*/presenters/.*\.rb$}, %r{^app/presenters/.*\.rb$}
|
46
|
+
:presenter
|
47
|
+
when %r{^app/.*/policies/.*\.rb$}, %r{^app/policies/.*\.rb$}
|
48
|
+
:policy
|
49
|
+
when %r{^app/.*/jobs/.*\.rb$}, %r{^app/jobs/.*\.rb$}
|
50
|
+
:job
|
51
|
+
when %r{^app/.*/workers/.*\.rb$}, %r{^app/workers/.*\.rb$}
|
52
|
+
:worker
|
53
|
+
when %r{^app/.*/mailers/.*\.rb$}, %r{^app/mailers/.*\.rb$}
|
54
|
+
:mailer
|
55
|
+
when %r{^app/.*/channels/.*\.rb$}, %r{^app/channels/.*\.rb$}
|
56
|
+
:channel
|
35
57
|
when %r{^lib/.*\.rb$}
|
36
58
|
:lib
|
37
59
|
when %r{^db/migrate/.*\.rb$}
|
@@ -45,8 +67,61 @@ module RailsCodeHealth
|
|
45
67
|
end
|
46
68
|
end
|
47
69
|
|
70
|
+
def detect_detailed_file_type(file_path, project_root)
|
71
|
+
relative_path = file_path.relative_path_from(project_root).to_s
|
72
|
+
file_type = detect_file_type(file_path, project_root)
|
73
|
+
|
74
|
+
context = {
|
75
|
+
organization: detect_organization_pattern(relative_path),
|
76
|
+
domain: extract_domain(relative_path),
|
77
|
+
area: extract_area(relative_path),
|
78
|
+
api_version: extract_api_version(relative_path)
|
79
|
+
}.compact
|
80
|
+
|
81
|
+
{ type: file_type, context: context }
|
82
|
+
end
|
83
|
+
|
48
84
|
private
|
49
85
|
|
86
|
+
def detect_organization_pattern(relative_path)
|
87
|
+
case relative_path
|
88
|
+
when %r{^app/topics/}
|
89
|
+
:topic_based
|
90
|
+
when %r{^app/domains/}
|
91
|
+
:domain_based
|
92
|
+
when %r{^app/modules/}
|
93
|
+
:module_based
|
94
|
+
else
|
95
|
+
:traditional
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def extract_domain(relative_path)
|
100
|
+
case relative_path
|
101
|
+
when %r{^app/topics/([^/]+)/}
|
102
|
+
$1
|
103
|
+
when %r{^app/domains/([^/]+)/}
|
104
|
+
$1
|
105
|
+
when %r{^app/modules/([^/]+)/}
|
106
|
+
$1
|
107
|
+
else
|
108
|
+
nil
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def extract_area(relative_path)
|
113
|
+
areas = %w[admin backoffice api]
|
114
|
+
areas.each do |area|
|
115
|
+
return area if relative_path.include?("/#{area}/")
|
116
|
+
end
|
117
|
+
nil
|
118
|
+
end
|
119
|
+
|
120
|
+
def extract_api_version(relative_path)
|
121
|
+
match = relative_path.match(%r{/v(\d+)/})
|
122
|
+
match ? "v#{match[1]}" : nil
|
123
|
+
end
|
124
|
+
|
50
125
|
def has_rails_files?(path)
|
51
126
|
RAILS_INDICATORS.any? { |file| (path + file).exist? }
|
52
127
|
end
|
@@ -21,6 +21,12 @@ module RailsCodeHealth
|
|
21
21
|
analyze_helper
|
22
22
|
when :migration
|
23
23
|
analyze_migration
|
24
|
+
when :service
|
25
|
+
analyze_service
|
26
|
+
when :interactor
|
27
|
+
analyze_interactor
|
28
|
+
when :serializer
|
29
|
+
analyze_serializer
|
24
30
|
else
|
25
31
|
{}
|
26
32
|
end
|
@@ -37,6 +43,7 @@ module RailsCodeHealth
|
|
37
43
|
has_before_actions: has_before_actions?,
|
38
44
|
uses_strong_parameters: uses_strong_parameters?,
|
39
45
|
has_direct_model_access: has_direct_model_access?,
|
46
|
+
has_business_logic: has_business_logic?,
|
40
47
|
response_formats: detect_response_formats,
|
41
48
|
rails_smells: detect_controller_smells
|
42
49
|
}
|
@@ -137,6 +144,18 @@ module RailsCodeHealth
|
|
137
144
|
formats
|
138
145
|
end
|
139
146
|
|
147
|
+
def has_business_logic?
|
148
|
+
business_logic_patterns = [
|
149
|
+
/if.*&&.*/, # Complex conditionals
|
150
|
+
/\.each\s*do/, # Iteration
|
151
|
+
/\b(calculate|compute|process)\b/, # Business operations
|
152
|
+
/\.(sum|count|average)\b/, # Aggregations
|
153
|
+
/transaction\s*do/ # Database transactions
|
154
|
+
]
|
155
|
+
|
156
|
+
business_logic_patterns.any? { |pattern| @source.match?(pattern) }
|
157
|
+
end
|
158
|
+
|
140
159
|
def detect_controller_smells
|
141
160
|
smells = []
|
142
161
|
|
@@ -163,6 +182,13 @@ module RailsCodeHealth
|
|
163
182
|
}
|
164
183
|
end
|
165
184
|
|
185
|
+
if has_business_logic?
|
186
|
+
smells << {
|
187
|
+
type: :business_logic_in_controller,
|
188
|
+
severity: :high
|
189
|
+
}
|
190
|
+
end
|
191
|
+
|
166
192
|
smells
|
167
193
|
end
|
168
194
|
|
@@ -372,6 +398,301 @@ module RailsCodeHealth
|
|
372
398
|
smells
|
373
399
|
end
|
374
400
|
|
401
|
+
# Service analysis methods
|
402
|
+
def analyze_service
|
403
|
+
return {} unless @ast
|
404
|
+
|
405
|
+
{
|
406
|
+
rails_type: :service,
|
407
|
+
has_call_method: has_call_method?,
|
408
|
+
dependencies: detect_service_dependencies,
|
409
|
+
error_handling: detect_error_handling,
|
410
|
+
complexity_score: calculate_service_complexity,
|
411
|
+
rails_smells: detect_service_smells
|
412
|
+
}
|
413
|
+
end
|
414
|
+
|
415
|
+
def has_call_method?
|
416
|
+
return false unless @ast
|
417
|
+
|
418
|
+
has_instance_call = false
|
419
|
+
has_class_call = false
|
420
|
+
|
421
|
+
find_nodes(@ast, :def) do |node|
|
422
|
+
method_name = node.children[0].to_s
|
423
|
+
has_instance_call = true if method_name == 'call'
|
424
|
+
end
|
425
|
+
|
426
|
+
find_nodes(@ast, :defs) do |node|
|
427
|
+
method_name = node.children[1].to_s
|
428
|
+
has_class_call = true if method_name == 'call'
|
429
|
+
end
|
430
|
+
|
431
|
+
has_instance_call || has_class_call
|
432
|
+
end
|
433
|
+
|
434
|
+
def detect_service_dependencies
|
435
|
+
dependencies = []
|
436
|
+
|
437
|
+
# ActiveRecord usage
|
438
|
+
if @source.match?(/\w+\.find\(/) || @source.match?(/\w+\.where\(/) || @source.match?(/\w+\.create\(/)
|
439
|
+
dependencies << :active_record
|
440
|
+
end
|
441
|
+
|
442
|
+
# External APIs
|
443
|
+
if @source.include?('Net::HTTP') || @source.include?('HTTParty') || @source.include?('Faraday')
|
444
|
+
dependencies << :external_api
|
445
|
+
end
|
446
|
+
|
447
|
+
# File system
|
448
|
+
if @source.include?('File.') || @source.include?('Dir.') || @source.include?('FileUtils')
|
449
|
+
dependencies << :file_system
|
450
|
+
end
|
451
|
+
|
452
|
+
# Email
|
453
|
+
if @source.include?('Mailer') || @source.include?('deliver') || @source.include?('ActionMailer')
|
454
|
+
dependencies << :email
|
455
|
+
end
|
456
|
+
|
457
|
+
# Cache
|
458
|
+
if @source.include?('Rails.cache') || @source.include?('cache_store') || @source.include?('Redis')
|
459
|
+
dependencies << :cache
|
460
|
+
end
|
461
|
+
|
462
|
+
dependencies.uniq
|
463
|
+
end
|
464
|
+
|
465
|
+
def detect_error_handling
|
466
|
+
error_handling = {
|
467
|
+
rescue_blocks: @source.scan(/rescue/).count,
|
468
|
+
raise_statements: @source.scan(/raise/).count
|
469
|
+
}
|
470
|
+
|
471
|
+
error_handling[:has_error_handling] = error_handling[:rescue_blocks] > 0 || error_handling[:raise_statements] > 0
|
472
|
+
error_handling
|
473
|
+
end
|
474
|
+
|
475
|
+
def calculate_service_complexity
|
476
|
+
complexity = 0
|
477
|
+
|
478
|
+
# Base complexity from dependencies
|
479
|
+
complexity += detect_service_dependencies.count * 2
|
480
|
+
|
481
|
+
# Conditional complexity
|
482
|
+
complexity += @source.scan(/\bif\b/).count
|
483
|
+
complexity += @source.scan(/\bunless\b/).count
|
484
|
+
complexity += @source.scan(/\bcase\b/).count
|
485
|
+
|
486
|
+
# Error handling complexity
|
487
|
+
error_handling = detect_error_handling
|
488
|
+
complexity += error_handling[:rescue_blocks] * 2
|
489
|
+
complexity += error_handling[:raise_statements]
|
490
|
+
|
491
|
+
complexity
|
492
|
+
end
|
493
|
+
|
494
|
+
def detect_service_smells
|
495
|
+
smells = []
|
496
|
+
|
497
|
+
unless has_call_method?
|
498
|
+
smells << {
|
499
|
+
type: :missing_call_method,
|
500
|
+
severity: :high
|
501
|
+
}
|
502
|
+
end
|
503
|
+
|
504
|
+
complexity = calculate_service_complexity
|
505
|
+
if complexity > 15
|
506
|
+
smells << {
|
507
|
+
type: :fat_service,
|
508
|
+
complexity: complexity,
|
509
|
+
severity: :high
|
510
|
+
}
|
511
|
+
end
|
512
|
+
|
513
|
+
error_handling = detect_error_handling
|
514
|
+
unless error_handling[:has_error_handling]
|
515
|
+
smells << {
|
516
|
+
type: :missing_error_handling,
|
517
|
+
severity: :medium
|
518
|
+
}
|
519
|
+
end
|
520
|
+
|
521
|
+
smells
|
522
|
+
end
|
523
|
+
|
524
|
+
# Interactor analysis methods
|
525
|
+
def analyze_interactor
|
526
|
+
return {} unless @ast
|
527
|
+
|
528
|
+
{
|
529
|
+
rails_type: :interactor,
|
530
|
+
has_call_method: has_call_method?,
|
531
|
+
context_usage: detect_context_usage,
|
532
|
+
fail_usage: detect_fail_usage,
|
533
|
+
is_organizer: is_organizer?,
|
534
|
+
complexity_score: calculate_interactor_complexity,
|
535
|
+
rails_smells: detect_interactor_smells
|
536
|
+
}
|
537
|
+
end
|
538
|
+
|
539
|
+
def detect_context_usage
|
540
|
+
{
|
541
|
+
context_references: @source.scan(/(?<!@)\bcontext\./).count,
|
542
|
+
instance_context_references: @source.scan(/@context/).count
|
543
|
+
}
|
544
|
+
end
|
545
|
+
|
546
|
+
def detect_fail_usage
|
547
|
+
{
|
548
|
+
context_fail: @source.scan(/context\.fail/).count,
|
549
|
+
fail_bang: @source.scan(/fail!/).count
|
550
|
+
}
|
551
|
+
end
|
552
|
+
|
553
|
+
def is_organizer?
|
554
|
+
@source.include?('organize') || @source.include?('Interactor::Organizer')
|
555
|
+
end
|
556
|
+
|
557
|
+
def calculate_interactor_complexity
|
558
|
+
complexity = 0
|
559
|
+
|
560
|
+
# Base complexity for organizers
|
561
|
+
complexity += 5 if is_organizer?
|
562
|
+
|
563
|
+
# Context usage complexity
|
564
|
+
context_usage = detect_context_usage
|
565
|
+
complexity += context_usage[:context_references]
|
566
|
+
complexity += context_usage[:instance_context_references]
|
567
|
+
|
568
|
+
# Conditional complexity
|
569
|
+
complexity += @source.scan(/\bif\b/).count
|
570
|
+
complexity += @source.scan(/\bunless\b/).count
|
571
|
+
|
572
|
+
# Fail usage adds complexity
|
573
|
+
fail_usage = detect_fail_usage
|
574
|
+
complexity += fail_usage[:context_fail] * 2
|
575
|
+
complexity += fail_usage[:fail_bang] * 2
|
576
|
+
|
577
|
+
complexity
|
578
|
+
end
|
579
|
+
|
580
|
+
def detect_interactor_smells
|
581
|
+
smells = []
|
582
|
+
|
583
|
+
unless has_call_method?
|
584
|
+
smells << {
|
585
|
+
type: :missing_call_method,
|
586
|
+
severity: :high
|
587
|
+
}
|
588
|
+
end
|
589
|
+
|
590
|
+
if is_organizer?
|
591
|
+
complexity = calculate_interactor_complexity
|
592
|
+
if complexity > 20
|
593
|
+
smells << {
|
594
|
+
type: :complex_organizer,
|
595
|
+
complexity: complexity,
|
596
|
+
severity: :high
|
597
|
+
}
|
598
|
+
end
|
599
|
+
end
|
600
|
+
|
601
|
+
fail_usage = detect_fail_usage
|
602
|
+
if fail_usage[:context_fail] == 0 && fail_usage[:fail_bang] == 0
|
603
|
+
smells << {
|
604
|
+
type: :missing_failure_handling,
|
605
|
+
severity: :medium
|
606
|
+
}
|
607
|
+
end
|
608
|
+
|
609
|
+
smells
|
610
|
+
end
|
611
|
+
|
612
|
+
# Serializer analysis methods
|
613
|
+
def analyze_serializer
|
614
|
+
return {} unless @ast
|
615
|
+
|
616
|
+
{
|
617
|
+
rails_type: :serializer,
|
618
|
+
attribute_count: count_serializer_attributes,
|
619
|
+
association_count: count_serializer_associations,
|
620
|
+
custom_method_count: count_custom_serializer_methods,
|
621
|
+
has_conditional_attributes: has_conditional_attributes?,
|
622
|
+
rails_smells: detect_serializer_smells
|
623
|
+
}
|
624
|
+
end
|
625
|
+
|
626
|
+
def count_serializer_attributes
|
627
|
+
# Count attributes declarations (lines starting with attributes/attribute)
|
628
|
+
@source.scan(/^\s*attributes?\s+/).count + @source.scan(/^\s*attribute\s+/).count
|
629
|
+
end
|
630
|
+
|
631
|
+
def count_serializer_associations
|
632
|
+
associations = 0
|
633
|
+
association_methods = %w[has_one has_many belongs_to]
|
634
|
+
|
635
|
+
association_methods.each do |method|
|
636
|
+
associations += @source.scan(/^\s*#{method}\s+/).count
|
637
|
+
end
|
638
|
+
|
639
|
+
associations
|
640
|
+
end
|
641
|
+
|
642
|
+
def count_custom_serializer_methods
|
643
|
+
return 0 unless @ast
|
644
|
+
|
645
|
+
method_count = 0
|
646
|
+
standard_methods = %w[initialize attributes serialize serializable_hash]
|
647
|
+
|
648
|
+
find_nodes(@ast, :def) do |node|
|
649
|
+
method_name = node.children[0].to_s
|
650
|
+
unless standard_methods.include?(method_name) || method_name.start_with?('_')
|
651
|
+
method_count += 1
|
652
|
+
end
|
653
|
+
end
|
654
|
+
|
655
|
+
method_count
|
656
|
+
end
|
657
|
+
|
658
|
+
def has_conditional_attributes?
|
659
|
+
@source.include?('if:') || @source.include?('unless:') || @source.include?('condition:')
|
660
|
+
end
|
661
|
+
|
662
|
+
def detect_serializer_smells
|
663
|
+
smells = []
|
664
|
+
|
665
|
+
attribute_count = count_serializer_attributes
|
666
|
+
association_count = count_serializer_associations
|
667
|
+
total_fields = attribute_count + association_count
|
668
|
+
|
669
|
+
if total_fields > 20
|
670
|
+
smells << {
|
671
|
+
type: :fat_serializer,
|
672
|
+
field_count: total_fields,
|
673
|
+
severity: :high
|
674
|
+
}
|
675
|
+
end
|
676
|
+
|
677
|
+
custom_method_count = count_custom_serializer_methods
|
678
|
+
if custom_method_count > 10
|
679
|
+
smells << {
|
680
|
+
type: :complex_serializer,
|
681
|
+
method_count: custom_method_count,
|
682
|
+
severity: :medium
|
683
|
+
}
|
684
|
+
end
|
685
|
+
|
686
|
+
if attribute_count == 0 && association_count == 0
|
687
|
+
smells << {
|
688
|
+
type: :empty_serializer,
|
689
|
+
severity: :low
|
690
|
+
}
|
691
|
+
end
|
692
|
+
|
693
|
+
smells
|
694
|
+
end
|
695
|
+
|
375
696
|
# Helper methods
|
376
697
|
def find_nodes(node, type, &block)
|
377
698
|
return unless node.is_a?(Parser::AST::Node)
|
@@ -58,18 +58,110 @@ module RailsCodeHealth
|
|
58
58
|
|
59
59
|
file_types = @results.group_by { |r| r[:file_type] }
|
60
60
|
|
61
|
-
|
61
|
+
# Define the order for file types
|
62
|
+
type_order = [
|
63
|
+
:controller, :model, :view, :helper, :migration,
|
64
|
+
:service, :interactor, :serializer, :form, :decorator,
|
65
|
+
:presenter, :policy, :job, :worker, :mailer, :channel,
|
66
|
+
:lib, :config, :test, :ruby
|
67
|
+
]
|
68
|
+
|
69
|
+
# Sort file types by the defined order, with unknown types at the end
|
70
|
+
sorted_types = file_types.keys.sort_by do |type|
|
71
|
+
index = type_order.index(type)
|
72
|
+
index || type_order.length
|
73
|
+
end
|
74
|
+
|
75
|
+
sorted_types.each do |type, _|
|
76
|
+
files = file_types[type]
|
62
77
|
next if files.empty?
|
63
78
|
|
64
79
|
avg_score = (files.sum { |f| f[:health_score] || 0 } / files.count.to_f).round(1)
|
65
80
|
healthy_count = files.count { |f| f[:health_category] == :healthy }
|
66
81
|
|
67
|
-
|
82
|
+
# Get file type emoji
|
83
|
+
emoji = get_file_type_emoji(type)
|
84
|
+
|
85
|
+
# Show context breakdown for new file types
|
86
|
+
context_info = ""
|
87
|
+
if [:service, :interactor, :serializer, :form, :decorator, :presenter, :policy, :job, :worker].include?(type)
|
88
|
+
context_info = generate_context_breakdown(files)
|
89
|
+
end
|
90
|
+
|
91
|
+
breakdown << " #{emoji} #{type.to_s.capitalize}: #{files.count} files, avg score: #{avg_score}, #{healthy_count} healthy"
|
92
|
+
breakdown << context_info if !context_info.empty?
|
68
93
|
end
|
69
94
|
|
70
95
|
breakdown.join("\n")
|
71
96
|
end
|
72
97
|
|
98
|
+
def get_file_type_emoji(type)
|
99
|
+
emoji_map = {
|
100
|
+
controller: "🎮",
|
101
|
+
model: "📊",
|
102
|
+
view: "🖼️",
|
103
|
+
helper: "🔧",
|
104
|
+
migration: "📈",
|
105
|
+
service: "⚙️",
|
106
|
+
interactor: "🔄",
|
107
|
+
serializer: "📦",
|
108
|
+
form: "📝",
|
109
|
+
decorator: "🎨",
|
110
|
+
presenter: "🎪",
|
111
|
+
policy: "🛡️",
|
112
|
+
job: "⚡",
|
113
|
+
worker: "👷",
|
114
|
+
mailer: "📧",
|
115
|
+
channel: "📡",
|
116
|
+
lib: "📚",
|
117
|
+
config: "⚙️",
|
118
|
+
test: "🧪",
|
119
|
+
ruby: "💎"
|
120
|
+
}
|
121
|
+
emoji_map[type] || "📄"
|
122
|
+
end
|
123
|
+
|
124
|
+
def generate_context_breakdown(files)
|
125
|
+
# Check if any files have context information
|
126
|
+
files_with_context = files.select { |f| f[:context] && !f[:context].empty? }
|
127
|
+
return "" if files_with_context.empty?
|
128
|
+
|
129
|
+
breakdown_lines = []
|
130
|
+
|
131
|
+
# Organization patterns
|
132
|
+
organizations = files_with_context.group_by { |f| f[:context][:organization] }
|
133
|
+
if organizations.keys.any? { |org| org != :traditional }
|
134
|
+
org_breakdown = organizations.map do |org, org_files|
|
135
|
+
next if org == :traditional
|
136
|
+
"#{org.to_s.gsub('_', ' ').capitalize}: #{org_files.count}"
|
137
|
+
end.compact
|
138
|
+
breakdown_lines << " 📁 Organization: #{org_breakdown.join(', ')}" if org_breakdown.any?
|
139
|
+
end
|
140
|
+
|
141
|
+
# Domains
|
142
|
+
domains = files_with_context.group_by { |f| f[:context][:domain] }.reject { |domain, _| domain.nil? }
|
143
|
+
if domains.any?
|
144
|
+
domain_breakdown = domains.map { |domain, domain_files| "#{domain}: #{domain_files.count}" }
|
145
|
+
breakdown_lines << " 🏢 Domains: #{domain_breakdown.join(', ')}"
|
146
|
+
end
|
147
|
+
|
148
|
+
# Areas
|
149
|
+
areas = files_with_context.group_by { |f| f[:context][:area] }.reject { |area, _| area.nil? }
|
150
|
+
if areas.any?
|
151
|
+
area_breakdown = areas.map { |area, area_files| "#{area}: #{area_files.count}" }
|
152
|
+
breakdown_lines << " 🏠 Areas: #{area_breakdown.join(', ')}"
|
153
|
+
end
|
154
|
+
|
155
|
+
# API versions
|
156
|
+
api_versions = files_with_context.group_by { |f| f[:context][:api_version] }.reject { |version, _| version.nil? }
|
157
|
+
if api_versions.any?
|
158
|
+
version_breakdown = api_versions.map { |version, version_files| "#{version}: #{version_files.count}" }
|
159
|
+
breakdown_lines << " 🔢 API Versions: #{version_breakdown.join(', ')}"
|
160
|
+
end
|
161
|
+
|
162
|
+
breakdown_lines.join("\n")
|
163
|
+
end
|
164
|
+
|
73
165
|
def generate_detailed_report
|
74
166
|
detailed = []
|
75
167
|
detailed << "📋 Detailed File Analysis"
|
@@ -174,7 +266,21 @@ module RailsCodeHealth
|
|
174
266
|
end
|
175
267
|
|
176
268
|
summary << "#{rank}. #{prefix} #{health_emoji} #{result[:relative_path]}"
|
177
|
-
|
269
|
+
|
270
|
+
# Build context string
|
271
|
+
context_parts = ["Type: #{result[:file_type]}", "Size: #{format_file_size(result[:file_size])}"]
|
272
|
+
|
273
|
+
if result[:context] && !result[:context].empty?
|
274
|
+
context_info = []
|
275
|
+
context_info << "#{result[:context][:domain]}" if result[:context][:domain]
|
276
|
+
context_info << "#{result[:context][:area]}" if result[:context][:area]
|
277
|
+
context_info << "#{result[:context][:api_version]}" if result[:context][:api_version]
|
278
|
+
context_info << "#{result[:context][:organization].to_s.gsub('_', ' ')}" if result[:context][:organization] && result[:context][:organization] != :traditional
|
279
|
+
|
280
|
+
context_parts << "Context: #{context_info.join(', ')}" if context_info.any?
|
281
|
+
end
|
282
|
+
|
283
|
+
summary << " Score: #{result[:health_score]}/10.0 | #{context_parts.join(' | ')}"
|
178
284
|
|
179
285
|
# Add key metrics if available
|
180
286
|
if result[:ruby_analysis]
|
@@ -200,11 +306,24 @@ module RailsCodeHealth
|
|
200
306
|
case result[:rails_analysis][:rails_type]
|
201
307
|
when :controller
|
202
308
|
rails_info << "#{result[:rails_analysis][:action_count]} actions"
|
309
|
+
rails_info << "business logic" if result[:rails_analysis][:has_business_logic]
|
203
310
|
when :model
|
204
311
|
rails_info << "#{result[:rails_analysis][:association_count]} associations" if result[:rails_analysis][:association_count]
|
205
312
|
rails_info << "#{result[:rails_analysis][:validation_count]} validations" if result[:rails_analysis][:validation_count]
|
206
313
|
when :view
|
207
314
|
rails_info << "#{result[:rails_analysis][:logic_lines]} logic lines" if result[:rails_analysis][:logic_lines]
|
315
|
+
when :service
|
316
|
+
rails_info << "call method" if result[:rails_analysis][:has_call_method]
|
317
|
+
rails_info << "deps: #{result[:rails_analysis][:dependencies].join(', ')}" if result[:rails_analysis][:dependencies]&.any?
|
318
|
+
rails_info << "complexity: #{result[:rails_analysis][:complexity_score]}" if result[:rails_analysis][:complexity_score]
|
319
|
+
when :interactor
|
320
|
+
rails_info << "call method" if result[:rails_analysis][:has_call_method]
|
321
|
+
rails_info << "organizer" if result[:rails_analysis][:is_organizer]
|
322
|
+
rails_info << "complexity: #{result[:rails_analysis][:complexity_score]}" if result[:rails_analysis][:complexity_score]
|
323
|
+
when :serializer
|
324
|
+
rails_info << "#{result[:rails_analysis][:attribute_count]} attributes" if result[:rails_analysis][:attribute_count]
|
325
|
+
rails_info << "#{result[:rails_analysis][:association_count]} associations" if result[:rails_analysis][:association_count]
|
326
|
+
rails_info << "#{result[:rails_analysis][:custom_method_count]} custom methods" if result[:rails_analysis][:custom_method_count]
|
208
327
|
end
|
209
328
|
|
210
329
|
summary << " Rails: #{rails_info.join(', ')}" if rails_info.any?
|
@@ -296,6 +415,7 @@ module RailsCodeHealth
|
|
296
415
|
when :controller
|
297
416
|
metrics[:controller_actions] = rails_metrics[:action_count]
|
298
417
|
metrics[:uses_strong_parameters] = rails_metrics[:uses_strong_parameters]
|
418
|
+
metrics[:has_business_logic] = rails_metrics[:has_business_logic]
|
299
419
|
when :model
|
300
420
|
metrics[:associations] = rails_metrics[:association_count]
|
301
421
|
metrics[:validations] = rails_metrics[:validation_count]
|
@@ -303,6 +423,21 @@ module RailsCodeHealth
|
|
303
423
|
when :view
|
304
424
|
metrics[:view_logic_lines] = rails_metrics[:logic_lines]
|
305
425
|
metrics[:has_inline_styles] = rails_metrics[:has_inline_styles]
|
426
|
+
when :service
|
427
|
+
metrics[:has_call_method] = rails_metrics[:has_call_method]
|
428
|
+
metrics[:dependencies] = rails_metrics[:dependencies]
|
429
|
+
metrics[:complexity_score] = rails_metrics[:complexity_score]
|
430
|
+
metrics[:error_handling] = rails_metrics[:error_handling]
|
431
|
+
when :interactor
|
432
|
+
metrics[:has_call_method] = rails_metrics[:has_call_method]
|
433
|
+
metrics[:is_organizer] = rails_metrics[:is_organizer]
|
434
|
+
metrics[:context_usage] = rails_metrics[:context_usage]
|
435
|
+
metrics[:complexity_score] = rails_metrics[:complexity_score]
|
436
|
+
when :serializer
|
437
|
+
metrics[:attribute_count] = rails_metrics[:attribute_count]
|
438
|
+
metrics[:association_count] = rails_metrics[:association_count]
|
439
|
+
metrics[:custom_method_count] = rails_metrics[:custom_method_count]
|
440
|
+
metrics[:has_conditional_attributes] = rails_metrics[:has_conditional_attributes]
|
306
441
|
end
|
307
442
|
end
|
308
443
|
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: rails_code_health
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- George Kosmopoulos
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-06-
|
11
|
+
date: 2025-06-19 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: parser
|