rails_code_health 0.2.0 → 0.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aaafc84bb8cf4bad9931fb39ef97d3a1bf76a61bc4e2289191df870f10a33e1e
4
- data.tar.gz: ad77a703a06b6eee946756f7645ef76de9c943fcadd192c27a425bd37f3027fd
3
+ metadata.gz: 806988266c00efaa8181b4379e6baa5f5ff9e5913fa4a0801fd993ededf3a580
4
+ data.tar.gz: 61e1909e93309e22265b961038f87da7c0367f38cf6cf9a763449e144e39c73f
5
5
  SHA512:
6
- metadata.gz: 832e4d0437e9e92e121da7a890f3451ee5f37047a586db713edfb07265ab55e5f8c0f221b21f9c4eccb974b7e4b4429ae28cedcd0c7ec5c060f633733996cea8
7
- data.tar.gz: f00785bb224064d66c01486a4929fd74ba7102559f9280c3c95f619fb67034a41babcb7e2e93ffe74cb37a08848d7d19d9f0cc25a8aaab460ad5f667e695b926
6
+ metadata.gz: 37d498b553e834f76811b405f6be6b967fec45512170098b70b818bc952de8321cf7790dce0f7b93b1353fc373b12614ba9f7be98466e871d26f25b22ad75938
7
+ data.tar.gz: 1ca51781fa1cccfdabd3ef5e138d2cd2aea2e0a64432ea7e26802464c4e5c732185c09a8622ca4a558f8ccef7762653a168982dcc1db9f6fe29633ded8feb673
data/CHANGELOG.md CHANGED
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.1] - 2026-05-18
11
+
12
+ ### Fixed
13
+ - `ReportGenerator` no longer includes migration files in the "Top Performing Files" showcase. Migrations are almost always trivially simple by construction, so they routinely crowded out genuinely well-written healthy files in the top-5 list. Migrations are still analyzed and still appear in the "Files Needing Most Attention" list when problematic.
14
+
15
+ ### Added
16
+ - `docs/index.html` static landing page for GitHub Pages, explaining the why and how of the gem.
17
+
18
+ ## [0.3.0] - 2026-05-12
19
+
20
+ ### Added
21
+ - `RailsCodeHealth::ASTHelpers` module: shared AST traversal with scoped variants and visibility-aware def collection.
22
+ - Fixture-based RSpec test suite under `spec/fixtures/code_samples/`.
23
+ - New configuration group `smell_thresholds` for previously hard-coded values.
24
+
25
+ ### Fixed
26
+ - `RubyAnalyzer#count_public_methods_in_class` now actually distinguishes public from private methods (including inline `private def foo`).
27
+ - `RubyAnalyzer#count_parameters` now counts only true parameter nodes.
28
+ - `RubyAnalyzer` nesting depth calculation no longer counts `:begin` and `:block` as nesting levels.
29
+ - `RailsAnalyzer#count_controller_actions` no longer inverts public/private; the seven canonical RESTful actions are correctly counted, and private helpers are excluded.
30
+ - `RailsAnalyzer#has_direct_model_access?` only fires when model calls appear inside public controller actions.
31
+ - `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`.
32
+ - Model `count_associations`, `count_validations`, `count_callbacks`, `count_scopes` count only top-level class body macros — no comments, strings, or nested-class matches.
33
+ - `has_fat_model_smell?` uses class-scoped code-line count and class-scoped method count.
34
+ - View `count_view_logic_lines` parses ERB fragments and no longer flags plain HTML that happens to contain `if`, `unless`, etc., in text.
35
+ - Migration `has_data_changes?` recognizes `find_each`, `update`, `update_columns`, `update_column`, `update!`, `update_all`, `delete_all`, `save`, `save!`, raw `connection.execute`, and `execute`.
36
+ - Service `detect_service_dependencies` uses word boundaries — `Profile.` no longer matches the `File.` check.
37
+ - God class, high complexity, parameter, and nesting smell thresholds are now read from configuration instead of inlined.
38
+ - Service `detect_service_smells` now flags `:fat_service` at complexity ≥ 13 (was > 15, which left some genuinely-fat services unflagged).
39
+ - Interactor `detect_fail_usage` no longer double-counts `context.fail!` as both `context_fail` and `fail_bang`.
40
+ - Serializer `count_serializer_attributes` / `count_serializer_associations` now count each symbol (`attributes :a, :b, :c` = 3) instead of just one per declaration line.
41
+ - `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`.
42
+ - `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.
43
+
44
+ ### Changed (may affect scores)
45
+ - 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.
46
+ - 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.
47
+
10
48
  ## [0.2.0] - 2025-06-19
11
49
 
12
50
  ### Added
@@ -43,6 +81,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
43
81
  - **Reporting**: Detailed console output with health categories and JSON export
44
82
  - **CLI**: `rails-health` command with options for format, output file, and custom configuration
45
83
 
46
- [Unreleased]: https://github.com/gkosmo/rails_code_health/compare/v0.2.0...HEAD
84
+ [Unreleased]: https://github.com/gkosmo/rails_code_health/compare/v0.3.1...HEAD
85
+ [0.3.1]: https://github.com/gkosmo/rails_code_health/compare/v0.3.0...v0.3.1
86
+ [0.3.0]: https://github.com/gkosmo/rails_code_health/compare/v0.2.0...v0.3.0
47
87
  [0.2.0]: https://github.com/gkosmo/rails_code_health/compare/v0.1.0...v0.2.0
48
88
  [0.1.0]: https://github.com/gkosmo/rails_code_health/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Rails Code Health
2
2
 
3
+ [![CI](https://github.com/gkosmo/rails_code_health/actions/workflows/ci.yml/badge.svg)](https://github.com/gkosmo/rails_code_health/actions/workflows/ci.yml)
4
+
3
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
4
6
  ).
5
7
 
@@ -127,6 +129,8 @@ Health scores range from 1.0 (critical) to 10.0 (excellent) based on:
127
129
  - **🔴 Alert (1.0-3.9)**: Significant problems requiring attention
128
130
  - **⚫ Critical (<1.0)**: Severe issues, immediate action needed
129
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
+
130
134
  ## Configuration
131
135
 
132
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
@@ -68,6 +68,13 @@ module RailsCodeHealth
68
68
  'dependency_count' => { 'green' => 3, 'yellow' => 5, 'red' => 8 },
69
69
  'complexity_score' => { 'green' => 10, 'yellow' => 15, 'red' => 25 }
70
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
+ },
71
78
  'file_type_multipliers' => {
72
79
  'controllers' => 1.2,
73
80
  'models' => 1.0,
@@ -91,7 +91,9 @@ module RailsCodeHealth
91
91
  files.concat(Dir.glob(pattern))
92
92
  end
93
93
 
94
- files.map { |f| Pathname.new(f) }
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) }
95
97
  end
96
98
 
97
99
  def should_skip_file?(relative_path)
@@ -225,97 +225,101 @@ module RailsCodeHealth
225
225
 
226
226
  def calculate_service_penalties(service_data)
227
227
  penalties = []
228
-
229
- # Missing call method - critical for services
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.
230
232
  unless service_data[:has_call_method]
231
- penalties << 3.0 * @weights['rails_conventions']
233
+ penalties << 3.0
232
234
  end
233
-
235
+
234
236
  # Too many dependencies
235
237
  dependency_count = service_data[:dependencies]&.count || 0
236
238
  service_thresholds = @thresholds['service_thresholds']['dependency_count']
237
239
  if dependency_count > service_thresholds['yellow']
238
- severity = dependency_count > service_thresholds['red'] ? 2.0 : 1.0
239
- penalties << severity * @weights['rails_conventions']
240
+ severity = dependency_count > service_thresholds['red'] ? 1.5 : 0.75
241
+ penalties << severity
240
242
  end
241
-
243
+
242
244
  # High complexity
243
245
  complexity = service_data[:complexity_score] || 0
244
246
  complexity_thresholds = @thresholds['service_thresholds']['complexity_score']
245
247
  if complexity > complexity_thresholds['yellow']
246
248
  severity = complexity > complexity_thresholds['red'] ? 2.0 : 1.0
247
- penalties << severity * @weights['rails_conventions']
249
+ penalties << severity
248
250
  end
249
-
251
+
250
252
  # Missing error handling
251
253
  error_handling = service_data[:error_handling] || {}
252
254
  unless error_handling[:has_error_handling]
253
255
  penalties << 1.0 * @weights['rails_conventions']
254
256
  end
255
-
257
+
256
258
  penalties
257
259
  end
258
260
 
259
261
  def calculate_interactor_penalties(interactor_data)
260
262
  penalties = []
261
-
262
- # Missing call method - critical for interactors
263
+
264
+ # Missing call method - critical for interactors (flat penalty; see
265
+ # service equivalent for rationale).
263
266
  unless interactor_data[:has_call_method]
264
- penalties << 3.0 * @weights['rails_conventions']
267
+ penalties << 3.0
265
268
  end
266
-
269
+
267
270
  # Complex organizers
268
271
  if interactor_data[:is_organizer]
269
272
  complexity = interactor_data[:complexity_score] || 0
270
273
  if complexity > 20 # Organizers should be simple orchestrators
271
- penalties << 2.0 * @weights['rails_conventions']
274
+ penalties << 2.0
272
275
  end
273
276
  end
274
-
277
+
275
278
  # Missing failure handling
276
279
  fail_usage = interactor_data[:fail_usage] || {}
277
280
  if (fail_usage[:context_fail] || 0) == 0 && (fail_usage[:fail_bang] || 0) == 0
278
281
  penalties << 1.5 * @weights['rails_conventions']
279
282
  end
280
-
283
+
281
284
  # High complexity for regular interactors
282
285
  unless interactor_data[:is_organizer]
283
286
  complexity = interactor_data[:complexity_score] || 0
284
287
  if complexity > 15
285
- penalties << 1.5 * @weights['rails_conventions']
288
+ penalties << 1.5
286
289
  end
287
290
  end
288
-
291
+
289
292
  penalties
290
293
  end
291
294
 
292
295
  def calculate_serializer_penalties(serializer_data)
293
296
  penalties = []
294
-
295
- # Too many attributes/associations (fat serializer)
297
+
298
+ # Too many attributes/associations (fat serializer). Flat penalties because
299
+ # `rails_conventions` weight (0.15) drowned out the fat signal.
296
300
  attribute_count = serializer_data[:attribute_count] || 0
297
301
  association_count = serializer_data[:association_count] || 0
298
302
  total_fields = attribute_count + association_count
299
-
303
+
300
304
  if total_fields > 20
301
- penalties << 2.0 * @weights['rails_conventions']
305
+ penalties << 1.5
302
306
  elsif total_fields > 15
303
- penalties << 1.0 * @weights['rails_conventions']
307
+ penalties << 0.5
304
308
  end
305
-
309
+
306
310
  # Too many custom methods (complex logic)
307
311
  custom_method_count = serializer_data[:custom_method_count] || 0
308
312
  if custom_method_count > 10
309
- penalties << 1.5 * @weights['rails_conventions']
313
+ penalties << 0.8
310
314
  elsif custom_method_count > 5
311
- penalties << 0.5 * @weights['rails_conventions']
315
+ penalties << 0.4
312
316
  end
313
-
317
+
314
318
  # Empty serializer (no value)
315
319
  if total_fields == 0 && custom_method_count == 0
316
320
  penalties << 1.0 * @weights['rails_conventions']
317
321
  end
318
-
322
+
319
323
  penalties
320
324
  end
321
325
 
@@ -1,5 +1,7 @@
1
1
  module RailsCodeHealth
2
2
  class RailsAnalyzer
3
+ include RailsCodeHealth::ASTHelpers
4
+
3
5
  def initialize(file_path, file_type)
4
6
  @file_path = file_path
5
7
  @file_type = file_type
@@ -104,11 +106,10 @@ module RailsCodeHealth
104
106
  return 0 unless @ast
105
107
 
106
108
  action_count = 0
107
- find_nodes(@ast, :def) do |node|
108
- method_name = node.children[0].to_s
109
- # Skip private methods and Rails internal methods
110
- unless method_name.start_with?('_') || private_controller_method?(method_name)
111
- action_count += 1
109
+ find_nodes(@ast, :class) do |class_node|
110
+ defs_by_visibility(class_node)[:public].each do |def_node|
111
+ name = def_node.children[0].to_s
112
+ action_count += 1 unless name.start_with?('_')
112
113
  end
113
114
  end
114
115
  action_count
@@ -122,17 +123,25 @@ module RailsCodeHealth
122
123
  @source.include?('params.require') || @source.include?('params.permit')
123
124
  end
124
125
 
126
+ DIRECT_MODEL_METHODS = %i[find find_by where create create! update update! all first last destroy_all].freeze
127
+
125
128
  def has_direct_model_access?
126
- # Look for direct ActiveRecord calls in controller actions
127
- model_patterns = [
128
- /\w+\.find\(/,
129
- /\w+\.where\(/,
130
- /\w+\.create\(/,
131
- /\w+\.update\(/,
132
- /\w+\.all/
133
- ]
134
-
135
- model_patterns.any? { |pattern| @source.match?(pattern) }
129
+ return false unless @ast
130
+
131
+ found = false
132
+ find_nodes(@ast, :class) do |class_node|
133
+ defs_by_visibility(class_node)[:public].each do |def_node|
134
+ find_nodes(def_node, :send) do |send_node|
135
+ receiver = send_node.children[0]
136
+ method_name = send_node.children[1]
137
+ # Receiver must be a constant (a likely model class) and the method must be an AR method.
138
+ next unless receiver.is_a?(Parser::AST::Node) && receiver.type == :const
139
+ next unless DIRECT_MODEL_METHODS.include?(method_name)
140
+ found = true
141
+ end
142
+ end
143
+ end
144
+ found
136
145
  end
137
146
 
138
147
  def detect_response_formats
@@ -144,16 +153,22 @@ module RailsCodeHealth
144
153
  formats
145
154
  end
146
155
 
156
+ BUSINESS_VERBS = %i[calculate compute process charge refund transition].freeze
157
+ # Methods with these prefixes likely indicate business logic.
158
+ # `process_` was previously included but removed because of false positives
159
+ # on common AR attributes like `process_id`.
160
+ BUSINESS_VERB_PREFIXES = %w[calculate_ compute_].freeze
161
+
147
162
  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) }
163
+ return false unless @ast
164
+
165
+ found = false
166
+ find_nodes(@ast, :class) do |class_node|
167
+ defs_by_visibility(class_node)[:public].each do |def_node|
168
+ found = true if action_has_business_logic?(def_node)
169
+ end
170
+ end
171
+ found
157
172
  end
158
173
 
159
174
  def detect_controller_smells
@@ -192,52 +207,115 @@ module RailsCodeHealth
192
207
  smells
193
208
  end
194
209
 
210
+ def action_has_business_logic?(def_node)
211
+ # NOTE: arithmetic signal (plan A2 item 2) intentionally not implemented —
212
+ # accuracy of detecting "two non-literal operands" was deemed not worth
213
+ # the false positive risk in v0.3.0.
214
+ return true if business_verb_call?(def_node)
215
+ return true if transaction_block?(def_node)
216
+ return true if loop_with_conditional?(def_node)
217
+ false
218
+ end
219
+
220
+ def business_verb_call?(def_node)
221
+ found = false
222
+ find_nodes(def_node, :send) do |send_node|
223
+ method_name = send_node.children[1].to_s
224
+ if BUSINESS_VERBS.include?(method_name.to_sym) ||
225
+ BUSINESS_VERB_PREFIXES.any? { |p| method_name.start_with?(p) }
226
+ found = true
227
+ end
228
+ end
229
+ found
230
+ end
231
+
232
+ def transaction_block?(def_node)
233
+ found = false
234
+ find_nodes(def_node, :block) do |block_node|
235
+ send_node = block_node.children[0]
236
+ next unless send_node.is_a?(Parser::AST::Node) && send_node.type == :send
237
+ found = true if send_node.children[1] == :transaction
238
+ end
239
+ found
240
+ end
241
+
242
+ def loop_with_conditional?(def_node)
243
+ found = false
244
+ find_nodes(def_node, :block) do |block_node|
245
+ send_node = block_node.children[0]
246
+ next unless send_node.is_a?(Parser::AST::Node) && send_node.type == :send
247
+ next unless %i[each map select reject].include?(send_node.children[1])
248
+ # Look for conditionals inside the block body.
249
+ find_nodes(block_node.children[2], :if) { found = true }
250
+ find_nodes(block_node.children[2], :case) { found = true }
251
+ end
252
+ found
253
+ end
254
+
255
+ ASSOCIATION_MACROS = %i[belongs_to has_one has_many has_and_belongs_to_many].freeze
256
+ VALIDATION_MACROS = %i[validates validates_presence_of validates_uniqueness_of validates_format_of validates_length_of validates_numericality_of validates_inclusion_of validates_exclusion_of validates_acceptance_of validates_confirmation_of].freeze
257
+ CALLBACK_MACROS = %i[before_save after_save before_create after_create before_update after_update before_destroy after_destroy after_commit after_rollback before_validation after_validation].freeze
258
+
195
259
  # Model analysis methods
196
260
  def count_associations
197
- associations = 0
198
- association_methods = %w[belongs_to has_one has_many has_and_belongs_to_many]
199
-
200
- association_methods.each do |method|
201
- associations += @source.scan(/#{method}\s+:/).count
261
+ total = 0
262
+ find_nodes(@ast, :class) do |class_node|
263
+ ASSOCIATION_MACROS.each do |macro|
264
+ total += class_body_sends(class_node, macro).size
265
+ end
202
266
  end
203
-
204
- associations
267
+ total
205
268
  end
206
269
 
207
270
  def count_validations
208
- validations = 0
209
- validation_methods = %w[validates validates_presence_of validates_uniqueness_of validates_format_of]
210
-
211
- validation_methods.each do |method|
212
- validations += @source.scan(/#{method}\s+/).count
271
+ total = 0
272
+ find_nodes(@ast, :class) do |class_node|
273
+ VALIDATION_MACROS.each do |macro|
274
+ total += class_body_sends(class_node, macro).size
275
+ end
213
276
  end
214
-
215
- validations
277
+ total
216
278
  end
217
279
 
218
280
  def count_callbacks
219
- callbacks = 0
220
- callback_methods = %w[before_save after_save before_create after_create before_update after_update before_destroy after_destroy]
221
-
222
- callback_methods.each do |method|
223
- callbacks += @source.scan(/#{method}\s+/).count
281
+ total = 0
282
+ find_nodes(@ast, :class) do |class_node|
283
+ CALLBACK_MACROS.each do |macro|
284
+ total += class_body_sends(class_node, macro).size
285
+ end
224
286
  end
225
-
226
- callbacks
287
+ total
227
288
  end
228
289
 
229
290
  def count_scopes
230
- @source.scan(/scope\s+:/).count
291
+ total = 0
292
+ find_nodes(@ast, :class) do |class_node|
293
+ total += class_body_sends(class_node, :scope).size
294
+ end
295
+ total
231
296
  end
232
297
 
233
298
  def has_fat_model_smell?
234
299
  return false unless @ast
235
300
 
236
- line_count = @source.lines.count
301
+ class_node = nil
302
+ find_nodes(@ast, :class) { |n| class_node ||= n }
303
+ return false unless class_node
304
+
305
+ code_lines = code_line_count(class_node)
237
306
  method_count = 0
238
- find_nodes(@ast, :def) { method_count += 1 }
239
-
240
- line_count > 200 && method_count > 15
307
+ find_nodes_in_scope(class_node, :def) { method_count += 1 }
308
+
309
+ code_lines > 200 && method_count > 15
310
+ end
311
+
312
+ def code_line_count(node)
313
+ return 0 unless node.respond_to?(:loc) && node.loc.respond_to?(:expression)
314
+ expr = node.loc.expression
315
+ return 0 unless expr
316
+
317
+ lines = expr.source.lines
318
+ lines.count { |line| line.strip != '' && !line.strip.start_with?('#') }
241
319
  end
242
320
 
243
321
  def detect_model_smells
@@ -270,16 +348,16 @@ module RailsCodeHealth
270
348
  smells
271
349
  end
272
350
 
351
+ VIEW_CONTROL_FLOW_KEYWORDS = %w[if unless elsif else case for while end].freeze
352
+
273
353
  # View analysis methods
274
- def count_view_logic_lines(lines)
275
- logic_count = 0
276
-
277
- lines.each do |line|
278
- # Count Ruby code blocks in ERB
279
- logic_count += 1 if line.match?(/<%((?!%>).)*%>/) || line.match?(/<%((?!%>).)*if|unless|case|for|while/)
354
+ def count_view_logic_lines(_lines)
355
+ count = 0
356
+ erb_ruby_fragments(@source).each do |ruby|
357
+ tokens = ruby.scan(/\b\w+\b/)
358
+ count += 1 if (tokens & VIEW_CONTROL_FLOW_KEYWORDS).any?
280
359
  end
281
-
282
- logic_count
360
+ count
283
361
  end
284
362
 
285
363
  def has_inline_styles?
@@ -350,10 +428,22 @@ module RailsCodeHealth
350
428
  smells
351
429
  end
352
430
 
431
+ DATA_CHANGE_METHODS = %i[
432
+ execute update_all delete_all
433
+ find_each update update_columns update_column
434
+ update! save save!
435
+ ].freeze
436
+
353
437
  # Migration analysis methods
354
438
  def has_data_changes?
355
- data_methods = %w[execute update_all delete_all]
356
- data_methods.any? { |method| @source.include?(method) }
439
+ return false unless @ast
440
+
441
+ found = false
442
+ find_nodes(@ast, :send) do |send_node|
443
+ method_name = send_node.children[1]
444
+ found = true if DATA_CHANGE_METHODS.include?(method_name)
445
+ end
446
+ found
357
447
  end
358
448
 
359
449
  def has_index_changes?
@@ -432,34 +522,29 @@ module RailsCodeHealth
432
522
  end
433
523
 
434
524
  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
525
+ deps = []
526
+
527
+ if @source.match?(/\b(?:[A-Z]\w*)\.(?:find|find_by|where|create|create!|update|update!|all|first|last)\b/)
528
+ deps << :active_record
440
529
  end
441
-
442
- # External APIs
443
- if @source.include?('Net::HTTP') || @source.include?('HTTParty') || @source.include?('Faraday')
444
- dependencies << :external_api
530
+
531
+ if @source.match?(/\b(?:Net::HTTP|HTTParty|Faraday|RestClient|Typhoeus)\b/)
532
+ deps << :external_api
445
533
  end
446
-
447
- # File system
448
- if @source.include?('File.') || @source.include?('Dir.') || @source.include?('FileUtils')
449
- dependencies << :file_system
534
+
535
+ if @source.match?(/\b(?:File|Dir|FileUtils|Pathname)\.\w+/)
536
+ deps << :file_system
450
537
  end
451
-
452
- # Email
453
- if @source.include?('Mailer') || @source.include?('deliver') || @source.include?('ActionMailer')
454
- dependencies << :email
538
+
539
+ if @source.match?(/\b(?:Mailer|ActionMailer)\b/) || @source.match?(/\.deliver(?:_now|_later)?\b/)
540
+ deps << :email
455
541
  end
456
-
457
- # Cache
458
- if @source.include?('Rails.cache') || @source.include?('cache_store') || @source.include?('Redis')
459
- dependencies << :cache
542
+
543
+ if @source.match?(/\bRails\.cache\b/) || @source.match?(/\bRedis\b/) || @source.match?(/\bcache_store\b/)
544
+ deps << :cache
460
545
  end
461
-
462
- dependencies.uniq
546
+
547
+ deps.uniq
463
548
  end
464
549
 
465
550
  def detect_error_handling
@@ -502,7 +587,7 @@ module RailsCodeHealth
502
587
  end
503
588
 
504
589
  complexity = calculate_service_complexity
505
- if complexity > 15
590
+ if complexity >= 13
506
591
  smells << {
507
592
  type: :fat_service,
508
593
  complexity: complexity,
@@ -546,7 +631,8 @@ module RailsCodeHealth
546
631
  def detect_fail_usage
547
632
  {
548
633
  context_fail: @source.scan(/context\.fail/).count,
549
- fail_bang: @source.scan(/fail!/).count
634
+ # `fail!` only — NOT `context.fail!` (that's counted as context_fail).
635
+ fail_bang: @source.scan(/(?<!\.)\bfail!/).count
550
636
  }
551
637
  end
552
638
 
@@ -624,19 +710,23 @@ module RailsCodeHealth
624
710
  end
625
711
 
626
712
  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
713
+ # Count each symbol passed to attributes/attribute calls.
714
+ # `attributes :id, :name, :email` counts as 3; `attribute :full_name` as 1.
715
+ count = 0
716
+ @source.scan(/^\s*attributes?\s+(.+)$/).each do |(args)|
717
+ count += args.scan(/:\w+/).size
718
+ end
719
+ count
629
720
  end
630
721
 
631
722
  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
723
+ count = 0
724
+ %w[has_one has_many belongs_to].each do |method|
725
+ @source.scan(/^\s*#{method}\s+(.+)$/).each do |(args)|
726
+ count += args.scan(/:\w+/).size
727
+ end
637
728
  end
638
-
639
- associations
729
+ count
640
730
  end
641
731
 
642
732
  def count_custom_serializer_methods
@@ -693,20 +783,5 @@ module RailsCodeHealth
693
783
  smells
694
784
  end
695
785
 
696
- # Helper methods
697
- def find_nodes(node, type, &block)
698
- return unless node.is_a?(Parser::AST::Node)
699
-
700
- yield(node) if node.type == type
701
-
702
- node.children.each do |child|
703
- find_nodes(child, type, &block)
704
- end
705
- end
706
-
707
- def private_controller_method?(method_name)
708
- %w[show new edit create update destroy].include?(method_name) ||
709
- method_name.end_with?('_params')
710
- end
711
786
  end
712
787
  end
@@ -183,7 +183,7 @@ module RailsCodeHealth
183
183
  end
184
184
 
185
185
  # Show best files if we have healthy ones
186
- healthy_files = @results.select { |r| r[:health_category] == :healthy }
186
+ healthy_files = @results.select { |r| r[:health_category] == :healthy && r[:file_type] != :migration }
187
187
  if healthy_files.any?
188
188
  detailed << "✅ Top Performing Files:"
189
189
  detailed << ""
@@ -1,5 +1,6 @@
1
1
  module RailsCodeHealth
2
2
  class RubyAnalyzer
3
+ include RailsCodeHealth::ASTHelpers
3
4
  def initialize(file_path)
4
5
  @file_path = file_path
5
6
  @source = File.read(file_path)
@@ -89,17 +90,6 @@ module RailsCodeHealth
89
90
  smells
90
91
  end
91
92
 
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
93
  # Complexity calculations
104
94
  def calculate_cyclomatic_complexity(node)
105
95
  complexity = 1 # Base complexity
@@ -166,11 +156,12 @@ module RailsCodeHealth
166
156
 
167
157
  def detect_god_classes
168
158
  classes = []
159
+ t = RailsCodeHealth.configuration.thresholds['smell_thresholds']
169
160
  find_nodes(@ast, :class) do |node|
170
161
  line_count = count_lines_in_node(node)
171
162
  method_count = count_methods_in_class(node)
172
-
173
- if line_count > 400 && method_count > 20
163
+
164
+ if line_count > t['god_class_lines'] && method_count > t['god_class_methods']
174
165
  classes << {
175
166
  type: :god_class,
176
167
  class_name: extract_class_name(node),
@@ -185,9 +176,10 @@ module RailsCodeHealth
185
176
 
186
177
  def detect_high_complexity_methods
187
178
  methods = []
179
+ threshold = RailsCodeHealth.configuration.thresholds['smell_thresholds']['high_complexity_method']
188
180
  find_nodes(@ast, :def) do |node|
189
181
  complexity = calculate_cyclomatic_complexity(node)
190
- if complexity > 15
182
+ if complexity > threshold
191
183
  methods << {
192
184
  type: :high_complexity,
193
185
  method_name: node.children[0],
@@ -201,9 +193,10 @@ module RailsCodeHealth
201
193
 
202
194
  def detect_too_many_parameters
203
195
  methods = []
196
+ threshold = RailsCodeHealth.configuration.thresholds['smell_thresholds']['too_many_parameters']
204
197
  find_nodes(@ast, :def) do |node|
205
198
  param_count = count_parameters(node)
206
- if param_count > 5
199
+ if param_count > threshold
207
200
  methods << {
208
201
  type: :too_many_parameters,
209
202
  method_name: node.children[0],
@@ -217,9 +210,10 @@ module RailsCodeHealth
217
210
 
218
211
  def detect_nested_conditionals
219
212
  methods = []
213
+ threshold = RailsCodeHealth.configuration.thresholds['smell_thresholds']['nested_conditionals']
220
214
  find_nodes(@ast, :def) do |node|
221
215
  max_depth = calculate_max_nesting_depth(node)
222
- if max_depth > 4
216
+ if max_depth > threshold
223
217
  methods << {
224
218
  type: :nested_conditionals,
225
219
  method_name: node.children[0],
@@ -245,15 +239,16 @@ module RailsCodeHealth
245
239
  end
246
240
 
247
241
  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)
242
+ defs_by_visibility(class_node)[:public].size
250
243
  end
251
244
 
245
+ PARAM_TYPES = %i[arg optarg restarg kwarg kwoptarg kwrestarg blockarg].freeze
246
+
252
247
  def count_parameters(method_node)
253
248
  args_node = method_node.children[1]
254
- return 0 unless args_node
255
-
256
- args_node.children.count
249
+ return 0 unless args_node.is_a?(Parser::AST::Node)
250
+
251
+ args_node.children.count { |c| c.is_a?(Parser::AST::Node) && PARAM_TYPES.include?(c.type) }
257
252
  end
258
253
 
259
254
  def extract_class_name(class_node)
@@ -306,8 +301,10 @@ module RailsCodeHealth
306
301
  count
307
302
  end
308
303
 
304
+ NESTING_TYPES = %i[if case while until for].freeze
305
+
309
306
  def nesting_node?(node)
310
- [:if, :case, :while, :until, :for, :begin, :block].include?(node.type)
307
+ NESTING_TYPES.include?(node.type)
311
308
  end
312
309
 
313
310
  def has_rescue_block?(node)
@@ -1,3 +1,3 @@
1
1
  module RailsCodeHealth
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.1"
3
3
  end
@@ -6,6 +6,7 @@ require 'json'
6
6
  require 'pathname'
7
7
 
8
8
  require_relative 'rails_code_health/version'
9
+ require_relative 'rails_code_health/ast_helpers'
9
10
  require_relative 'rails_code_health/configuration'
10
11
  require_relative 'rails_code_health/project_detector'
11
12
  require_relative 'rails_code_health/file_analyzer'
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_code_health
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - George Kosmopoulos
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2025-06-19 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: parser
@@ -151,6 +150,7 @@ files:
151
150
  - bin/rails-health
152
151
  - config/tresholds.json
153
152
  - lib/rails_code_health.rb
153
+ - lib/rails_code_health/ast_helpers.rb
154
154
  - lib/rails_code_health/cli.rb
155
155
  - lib/rails_code_health/configuration.rb
156
156
  - lib/rails_code_health/file_analyzer.rb
@@ -164,7 +164,6 @@ homepage: https://github.com/gkosmo/rails_code_health
164
164
  licenses:
165
165
  - MIT
166
166
  metadata: {}
167
- post_install_message:
168
167
  rdoc_options: []
169
168
  require_paths:
170
169
  - lib
@@ -179,8 +178,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
179
178
  - !ruby/object:Gem::Version
180
179
  version: '0'
181
180
  requirements: []
182
- rubygems_version: 3.3.26
183
- signing_key:
181
+ rubygems_version: 3.6.9
184
182
  specification_version: 4
185
183
  summary: Code health analyzer for Ruby on Rails applications
186
184
  test_files: []