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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cde06b2eaad01a08ed9a618103f62aff853e4adcb4f3e6a2b93807e2e00d2dfa
|
|
4
|
+
data.tar.gz: 829b9eaa19feb6cbef043fa73822c48a204c25afc552ccf557193ff3498f6e09
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8e40c5161b69aaf9f98318f7204cf7c68b9a376464179cd3169aad3c60137c629feee9870f68ed6fe3578aa13d129aa426c12211f348d5d18b3bc06d74e5e6eb
|
|
7
|
+
data.tar.gz: 36c0b8ec173f03bf41008b14674a07bb5b5748bbd3c8e73bed0c0ac03cf19d9203578ca65bf4b650570a0626caa98a2847dd59ef59022b0afe9bbef0aefb59de
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-05-12
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `RailsCodeHealth::ASTHelpers` module: shared AST traversal with scoped variants and visibility-aware def collection.
|
|
14
|
+
- Fixture-based RSpec test suite under `spec/fixtures/code_samples/`.
|
|
15
|
+
- New configuration group `smell_thresholds` for previously hard-coded values.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- `RubyAnalyzer#count_public_methods_in_class` now actually distinguishes public from private methods (including inline `private def foo`).
|
|
19
|
+
- `RubyAnalyzer#count_parameters` now counts only true parameter nodes.
|
|
20
|
+
- `RubyAnalyzer` nesting depth calculation no longer counts `:begin` and `:block` as nesting levels.
|
|
21
|
+
- `RailsAnalyzer#count_controller_actions` no longer inverts public/private; the seven canonical RESTful actions are correctly counted, and private helpers are excluded.
|
|
22
|
+
- `RailsAnalyzer#has_direct_model_access?` only fires when model calls appear inside public controller actions.
|
|
23
|
+
- `RailsAnalyzer#has_business_logic?` uses stricter signals (business-verb calls on model receivers, transactions, loops with conditionals) instead of matching any compound `if` or any `.each do`.
|
|
24
|
+
- Model `count_associations`, `count_validations`, `count_callbacks`, `count_scopes` count only top-level class body macros — no comments, strings, or nested-class matches.
|
|
25
|
+
- `has_fat_model_smell?` uses class-scoped code-line count and class-scoped method count.
|
|
26
|
+
- View `count_view_logic_lines` parses ERB fragments and no longer flags plain HTML that happens to contain `if`, `unless`, etc., in text.
|
|
27
|
+
- Migration `has_data_changes?` recognizes `find_each`, `update`, `update_columns`, `update_column`, `update!`, `update_all`, `delete_all`, `save`, `save!`, raw `connection.execute`, and `execute`.
|
|
28
|
+
- Service `detect_service_dependencies` uses word boundaries — `Profile.` no longer matches the `File.` check.
|
|
29
|
+
- God class, high complexity, parameter, and nesting smell thresholds are now read from configuration instead of inlined.
|
|
30
|
+
- Service `detect_service_smells` now flags `:fat_service` at complexity ≥ 13 (was > 15, which left some genuinely-fat services unflagged).
|
|
31
|
+
- Interactor `detect_fail_usage` no longer double-counts `context.fail!` as both `context_fail` and `fail_bang`.
|
|
32
|
+
- Serializer `count_serializer_attributes` / `count_serializer_associations` now count each symbol (`attributes :a, :b, :c` = 3) instead of just one per declaration line.
|
|
33
|
+
- `FileAnalyzer#find_view_files` dedupes overlapping glob matches so files under `app/views/` are no longer counted twice when matched by both `app/views/**/*.erb` and `app/**/views/**/*.erb`.
|
|
34
|
+
- `HealthCalculator` service/interactor/serializer penalties now use flat (un-weighted) multipliers for the most severe issues (missing call method, fat service, complex organizer, fat serializer) — the global `rails_conventions` weight (0.15) was too small to make these matter.
|
|
35
|
+
|
|
36
|
+
### Changed (may affect scores)
|
|
37
|
+
- Score changes are expected on any project where the above false positives or false negatives applied. Re-baseline before comparing to v0.2.0 reports.
|
|
38
|
+
- Services missing a `call` method now score noticeably lower (penalty went from ~0.45 to 3.0); same for interactors. Fat serializers and complex organizers are also penalized more strongly.
|
|
39
|
+
|
|
40
|
+
## [0.2.0] - 2025-06-19
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
- Comprehensive test suite with RSpec covering all major components
|
|
44
|
+
- Test coverage for Configuration, FileAnalyzer, HealthCalculator, ProjectDetector, RailsAnalyzer, and ReportGenerator
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
- Enhanced Configuration class with improved validation and error handling
|
|
48
|
+
- Improved FileAnalyzer with better file processing capabilities
|
|
49
|
+
- Updated HealthCalculator with more robust scoring algorithms
|
|
50
|
+
- Enhanced ProjectDetector with better Rails project detection
|
|
51
|
+
- Improved RailsAnalyzer with more comprehensive Rails pattern analysis
|
|
52
|
+
- Enhanced ReportGenerator with better formatting and output options
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
- Various bug fixes and improvements based on user feedback
|
|
56
|
+
|
|
10
57
|
## [0.1.0] - 2025-06-17
|
|
11
58
|
|
|
12
59
|
### Added
|
|
@@ -26,5 +73,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
26
73
|
- **Reporting**: Detailed console output with health categories and JSON export
|
|
27
74
|
- **CLI**: `rails-health` command with options for format, output file, and custom configuration
|
|
28
75
|
|
|
29
|
-
[Unreleased]: https://github.com/
|
|
30
|
-
[0.
|
|
76
|
+
[Unreleased]: https://github.com/gkosmo/rails_code_health/compare/v0.3.0...HEAD
|
|
77
|
+
[0.3.0]: https://github.com/gkosmo/rails_code_health/compare/v0.2.0...v0.3.0
|
|
78
|
+
[0.2.0]: https://github.com/gkosmo/rails_code_health/compare/v0.1.0...v0.2.0
|
|
79
|
+
[0.1.0]: https://github.com/gkosmo/rails_code_health/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# Rails Code Health
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/gkosmo/rails_code_health/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
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
|
|
6
|
+
).
|
|
4
7
|
|
|
5
8
|
## Overview
|
|
6
9
|
|
|
@@ -126,6 +129,8 @@ Health scores range from 1.0 (critical) to 10.0 (excellent) based on:
|
|
|
126
129
|
- **🔴 Alert (1.0-3.9)**: Significant problems requiring attention
|
|
127
130
|
- **⚫ Critical (<1.0)**: Severe issues, immediate action needed
|
|
128
131
|
|
|
132
|
+
> **Note on v0.3.0 scores:** v0.3.0 fixes several false positives and false negatives in the Rails-specific checks. Scores on the same codebase will move compared to v0.2.0. See the CHANGELOG for the list of changed checks.
|
|
133
|
+
|
|
129
134
|
## Configuration
|
|
130
135
|
|
|
131
136
|
Create a custom `thresholds.json` file:
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
module RailsCodeHealth
|
|
2
|
+
module ASTHelpers
|
|
3
|
+
SCOPE_BOUNDARY_TYPES = %i[class module def defs sclass].freeze
|
|
4
|
+
VISIBILITY_MODIFIERS = %i[public private protected].freeze
|
|
5
|
+
|
|
6
|
+
# Yields every descendant (and the node itself) of the given type.
|
|
7
|
+
# Descends into all children.
|
|
8
|
+
def find_nodes(node, type, &block)
|
|
9
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
10
|
+
|
|
11
|
+
yield(node) if node.type == type
|
|
12
|
+
node.children.each { |child| find_nodes(child, type, &block) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Returns a hash { public: [def_nodes], private: [def_nodes], protected: [def_nodes] }
|
|
16
|
+
# for the given class node. Handles bare modifier blocks and inline `private def foo`.
|
|
17
|
+
# Defs inside `class << self` are excluded (the :sclass node is not descended into).
|
|
18
|
+
#
|
|
19
|
+
# NOTE: `private :symbol_name` / `private :a, :b` forms are NOT handled —
|
|
20
|
+
# methods so marked stay classified as :public. Only bare modifier blocks
|
|
21
|
+
# (`private` on its own line) and inline `private def foo` are recognized.
|
|
22
|
+
def defs_by_visibility(class_node)
|
|
23
|
+
result = { public: [], private: [], protected: [] }
|
|
24
|
+
return result unless class_node.is_a?(Parser::AST::Node)
|
|
25
|
+
return result unless %i[class module].include?(class_node.type)
|
|
26
|
+
|
|
27
|
+
current = :public
|
|
28
|
+
body = class_body_children(class_node)
|
|
29
|
+
|
|
30
|
+
body.each do |child|
|
|
31
|
+
next unless child.is_a?(Parser::AST::Node)
|
|
32
|
+
|
|
33
|
+
if child.type == :send && child.children[0].nil? &&
|
|
34
|
+
VISIBILITY_MODIFIERS.include?(child.children[1])
|
|
35
|
+
modifier = child.children[1]
|
|
36
|
+
if child.children.length == 2
|
|
37
|
+
# bare modifier — changes default for subsequent defs
|
|
38
|
+
current = modifier
|
|
39
|
+
else
|
|
40
|
+
# inline form: `private def foo` or `private :foo`
|
|
41
|
+
inline_target = child.children[2]
|
|
42
|
+
if inline_target.is_a?(Parser::AST::Node) && inline_target.type == :def
|
|
43
|
+
result[modifier] << inline_target
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
elsif child.type == :def
|
|
47
|
+
result[current] << child
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
result
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Returns direct `:send` nodes in the class body matching the given method name.
|
|
55
|
+
# Does not descend into nested classes, modules, or method bodies.
|
|
56
|
+
#
|
|
57
|
+
# NOTE: calls wrapped in a block (e.g., `included do ... end` in concerns)
|
|
58
|
+
# are NOT matched — the wrapping :block node is not a :send. Use
|
|
59
|
+
# class_body_children directly and inspect :block nodes if you need to find
|
|
60
|
+
# macro calls inside such wrappers.
|
|
61
|
+
def class_body_sends(class_node, method_name)
|
|
62
|
+
matches = []
|
|
63
|
+
return matches unless class_node.is_a?(Parser::AST::Node)
|
|
64
|
+
return matches unless %i[class module].include?(class_node.type)
|
|
65
|
+
|
|
66
|
+
class_body_children(class_node).each do |child|
|
|
67
|
+
next unless child.is_a?(Parser::AST::Node)
|
|
68
|
+
next unless child.type == :send
|
|
69
|
+
# Receiver-less send only (e.g., `has_many :foo`, not `MyMod.has_many :foo`)
|
|
70
|
+
next unless child.children[0].nil?
|
|
71
|
+
matches << child if child.children[1] == method_name
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
matches
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Yields each Ruby code fragment inside <% %> / <%= %> / <%- %> tags.
|
|
78
|
+
# Multi-line tags are handled. ERB comment tags (<%# ... %>) are skipped.
|
|
79
|
+
# Returns an Enumerator if no block given.
|
|
80
|
+
def erb_ruby_fragments(source)
|
|
81
|
+
return enum_for(:erb_ruby_fragments, source) unless block_given?
|
|
82
|
+
|
|
83
|
+
source.scan(/<%(?!#)=?-?(.*?)-?%>/m) do |match|
|
|
84
|
+
yield match[0]
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Like find_nodes, but does not descend into nested class/module/def/defs/sclass
|
|
89
|
+
# nodes. The starting node itself is inspected; all children are inspected, but
|
|
90
|
+
# nested scope-introducing constructs are not recursed into.
|
|
91
|
+
def find_nodes_in_scope(node, type, &block)
|
|
92
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
93
|
+
|
|
94
|
+
yield(node) if node.type == type
|
|
95
|
+
|
|
96
|
+
node.children.each do |child|
|
|
97
|
+
next unless child.is_a?(Parser::AST::Node)
|
|
98
|
+
|
|
99
|
+
# Scope-boundary children get yielded if they match the target type,
|
|
100
|
+
# but we do not recurse into them.
|
|
101
|
+
if SCOPE_BOUNDARY_TYPES.include?(child.type)
|
|
102
|
+
yield(child) if child.type == type
|
|
103
|
+
next
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
find_nodes_in_scope(child, type, &block)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# The body of a `class` node is its third child; for `module`, the second.
|
|
113
|
+
# The body may be a single node or a `:begin` containing a sequence of nodes.
|
|
114
|
+
def class_body_children(class_or_module_node)
|
|
115
|
+
body =
|
|
116
|
+
case class_or_module_node.type
|
|
117
|
+
when :class then class_or_module_node.children[2]
|
|
118
|
+
when :module then class_or_module_node.children[1]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
return [] if body.nil?
|
|
122
|
+
return body.children if body.is_a?(Parser::AST::Node) && body.type == :begin
|
|
123
|
+
[body]
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -64,6 +64,17 @@ 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
|
+
},
|
|
71
|
+
'smell_thresholds' => {
|
|
72
|
+
'god_class_lines' => 400,
|
|
73
|
+
'god_class_methods' => 20,
|
|
74
|
+
'high_complexity_method' => 15,
|
|
75
|
+
'too_many_parameters' => 5,
|
|
76
|
+
'nested_conditionals' => 4
|
|
77
|
+
},
|
|
67
78
|
'file_type_multipliers' => {
|
|
68
79
|
'controllers' => 1.2,
|
|
69
80
|
'models' => 1.0,
|
|
@@ -71,7 +82,16 @@ module RailsCodeHealth
|
|
|
71
82
|
'helpers' => 0.9,
|
|
72
83
|
'lib' => 1.1,
|
|
73
84
|
'specs' => 0.7,
|
|
74
|
-
'migrations' => 0.6
|
|
85
|
+
'migrations' => 0.6,
|
|
86
|
+
'service' => 1.3,
|
|
87
|
+
'interactor' => 1.2,
|
|
88
|
+
'serializer' => 0.9,
|
|
89
|
+
'form' => 1.1,
|
|
90
|
+
'decorator' => 0.9,
|
|
91
|
+
'presenter' => 0.9,
|
|
92
|
+
'policy' => 1.2,
|
|
93
|
+
'job' => 1.0,
|
|
94
|
+
'worker' => 1.0
|
|
75
95
|
},
|
|
76
96
|
'scoring_weights' => {
|
|
77
97
|
'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 = []
|
|
@@ -88,7 +91,9 @@ module RailsCodeHealth
|
|
|
88
91
|
files.concat(Dir.glob(pattern))
|
|
89
92
|
end
|
|
90
93
|
|
|
91
|
-
|
|
94
|
+
# Patterns overlap (`app/views/**/*.erb` and `app/**/views/**/*.erb` both
|
|
95
|
+
# match files under `app/views/...`); dedupe by absolute path.
|
|
96
|
+
files.uniq.map { |f| Pathname.new(f) }
|
|
92
97
|
end
|
|
93
98
|
|
|
94
99
|
def should_skip_file?(relative_path)
|
|
@@ -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,106 @@ 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. Use a flat (un-attenuated)
|
|
230
|
+
# penalty here because the `rails_conventions` weight (0.15) is sized for
|
|
231
|
+
# softer conventions; missing the core service contract is severe.
|
|
232
|
+
unless service_data[:has_call_method]
|
|
233
|
+
penalties << 3.0
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Too many dependencies
|
|
237
|
+
dependency_count = service_data[:dependencies]&.count || 0
|
|
238
|
+
service_thresholds = @thresholds['service_thresholds']['dependency_count']
|
|
239
|
+
if dependency_count > service_thresholds['yellow']
|
|
240
|
+
severity = dependency_count > service_thresholds['red'] ? 1.5 : 0.75
|
|
241
|
+
penalties << severity
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# High complexity
|
|
245
|
+
complexity = service_data[:complexity_score] || 0
|
|
246
|
+
complexity_thresholds = @thresholds['service_thresholds']['complexity_score']
|
|
247
|
+
if complexity > complexity_thresholds['yellow']
|
|
248
|
+
severity = complexity > complexity_thresholds['red'] ? 2.0 : 1.0
|
|
249
|
+
penalties << severity
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Missing error handling
|
|
253
|
+
error_handling = service_data[:error_handling] || {}
|
|
254
|
+
unless error_handling[:has_error_handling]
|
|
255
|
+
penalties << 1.0 * @weights['rails_conventions']
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
penalties
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def calculate_interactor_penalties(interactor_data)
|
|
262
|
+
penalties = []
|
|
263
|
+
|
|
264
|
+
# Missing call method - critical for interactors (flat penalty; see
|
|
265
|
+
# service equivalent for rationale).
|
|
266
|
+
unless interactor_data[:has_call_method]
|
|
267
|
+
penalties << 3.0
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Complex organizers
|
|
271
|
+
if interactor_data[:is_organizer]
|
|
272
|
+
complexity = interactor_data[:complexity_score] || 0
|
|
273
|
+
if complexity > 20 # Organizers should be simple orchestrators
|
|
274
|
+
penalties << 2.0
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Missing failure handling
|
|
279
|
+
fail_usage = interactor_data[:fail_usage] || {}
|
|
280
|
+
if (fail_usage[:context_fail] || 0) == 0 && (fail_usage[:fail_bang] || 0) == 0
|
|
281
|
+
penalties << 1.5 * @weights['rails_conventions']
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# High complexity for regular interactors
|
|
285
|
+
unless interactor_data[:is_organizer]
|
|
286
|
+
complexity = interactor_data[:complexity_score] || 0
|
|
287
|
+
if complexity > 15
|
|
288
|
+
penalties << 1.5
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
penalties
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def calculate_serializer_penalties(serializer_data)
|
|
296
|
+
penalties = []
|
|
297
|
+
|
|
298
|
+
# Too many attributes/associations (fat serializer). Flat penalties because
|
|
299
|
+
# `rails_conventions` weight (0.15) drowned out the fat signal.
|
|
300
|
+
attribute_count = serializer_data[:attribute_count] || 0
|
|
301
|
+
association_count = serializer_data[:association_count] || 0
|
|
302
|
+
total_fields = attribute_count + association_count
|
|
303
|
+
|
|
304
|
+
if total_fields > 20
|
|
305
|
+
penalties << 1.5
|
|
306
|
+
elsif total_fields > 15
|
|
307
|
+
penalties << 0.5
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Too many custom methods (complex logic)
|
|
311
|
+
custom_method_count = serializer_data[:custom_method_count] || 0
|
|
312
|
+
if custom_method_count > 10
|
|
313
|
+
penalties << 0.8
|
|
314
|
+
elsif custom_method_count > 5
|
|
315
|
+
penalties << 0.4
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Empty serializer (no value)
|
|
319
|
+
if total_fields == 0 && custom_method_count == 0
|
|
320
|
+
penalties << 1.0 * @weights['rails_conventions']
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
penalties
|
|
324
|
+
end
|
|
325
|
+
|
|
215
326
|
def calculate_code_smell_penalties(file_result)
|
|
216
327
|
penalties = []
|
|
217
328
|
|
|
@@ -360,6 +471,24 @@ module RailsCodeHealth
|
|
|
360
471
|
recommendations << "Reduce model callbacks (#{smell[:count]} found) - consider service objects"
|
|
361
472
|
when :data_changes_in_migration
|
|
362
473
|
recommendations << "Avoid data changes in migrations - use rake tasks instead"
|
|
474
|
+
when :business_logic_in_controller
|
|
475
|
+
recommendations << "Extract business logic from controller to service objects"
|
|
476
|
+
when :missing_call_method
|
|
477
|
+
recommendations << "Add a call method to follow service/interactor conventions"
|
|
478
|
+
when :fat_service
|
|
479
|
+
recommendations << "Break down service into smaller, focused services (complexity: #{smell[:complexity]})"
|
|
480
|
+
when :missing_error_handling
|
|
481
|
+
recommendations << "Add proper error handling with rescue blocks"
|
|
482
|
+
when :complex_organizer
|
|
483
|
+
recommendations << "Simplify organizer - it should only orchestrate other interactors (complexity: #{smell[:complexity]})"
|
|
484
|
+
when :missing_failure_handling
|
|
485
|
+
recommendations << "Add failure handling using context.fail or fail! methods"
|
|
486
|
+
when :fat_serializer
|
|
487
|
+
recommendations << "Split serializer - it has #{smell[:field_count]} fields"
|
|
488
|
+
when :complex_serializer
|
|
489
|
+
recommendations << "Move complex logic out of serializer (#{smell[:method_count]} custom methods)"
|
|
490
|
+
when :empty_serializer
|
|
491
|
+
recommendations << "Add attributes or associations to provide value"
|
|
363
492
|
end
|
|
364
493
|
end
|
|
365
494
|
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
|