eager_eye 1.2.5 → 1.2.6

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: 6fa0e6e3acb2c37751e7ab49a58826a016a68c7a33cc6305ddb6b82d6787c462
4
- data.tar.gz: af81d8c0cfde13914b00a9f64b4b1bef6731b12902a1358c7f27473c2ab9a68d
3
+ metadata.gz: f996747b6c12e539180e66cc74c11c70acfab4a6e4b84aed4039cc7b58dafe53
4
+ data.tar.gz: f23d5a21ceecc39c71b0303a5748472a8ea5e0f5f3256ffd5a10084a5af8a39c
5
5
  SHA512:
6
- metadata.gz: a0e233e5c53c6ee3c7a32239ca20625a58deb1b385af022cb582433d02070f7b861873f44cef5fe786ede0307caee4105202e49844902e8d75e14039623bdc74
7
- data.tar.gz: 238ca3ba9a777686867752bc4abbf1d10081d73215e3c0f7ed0e9360f7c380298b4736177c703f1c921fb22b4296e7544379bef69021408f9e9ad86280a3716c
6
+ metadata.gz: a75d25669ab2d7e96dbccb107ae30f817e5fd6a291651b68bc56dbea21f50bd5c8b24b0633dd21d6faeeb5a8abd999abc6baf57ac9c7e8c3c06aa4542dc570d3
7
+ data.tar.gz: 7d2e5af151c02923bf4e64f05bc277711228729fa08ddee3dd79699623efc33f3689290c1739c025d7851db0b59d90feeac99e3a65c9a13862fac8f36ddf46b6
data/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.6] - 2026-02-25
11
+
12
+ ### Changed
13
+
14
+ - Extract shared class-inspection logic into `ClassInspector` concern to reduce duplication across detectors
15
+ - Refactor all detectors to use `ClassInspector` for parent class and naming convention checks
16
+ - Simplify `Analyzer` by removing redundant delegation and streamlining detector orchestration
17
+ - Clean up `DelegationParser` with leaner parsing logic
18
+ - Improve `Base` detector with consolidated helper methods
19
+ - Streamline reporter classes (`Base`, `Console`) for clarity and consistency
20
+
21
+ ### Removed
22
+
23
+ - Remove duplicated class-matching code from `CallbackQuery`, `CountInIteration`, `CustomMethodQuery`, `DecoratorNPlusOne`, `DelegationNPlusOne`, `LoopAssociation`, and `SerializerNesting`
24
+
10
25
  ## [1.2.5] - 2026-02-21
11
26
 
12
27
  ### Added
data/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  <p align="center">
12
12
  <a href="https://github.com/hamzagedikkaya/eager_eye/actions/workflows/main.yml"><img src="https://github.com/hamzagedikkaya/eager_eye/actions/workflows/main.yml/badge.svg" alt="CI"></a>
13
- <a href="https://rubygems.org/gems/eager_eye"><img src="https://img.shields.io/badge/gem-v1.2.5-red.svg" alt="Gem Version"></a>
13
+ <a href="https://rubygems.org/gems/eager_eye"><img src="https://img.shields.io/badge/gem-v1.2.6-red.svg" alt="Gem Version"></a>
14
14
  <a href="https://github.com/hamzagedikkaya/eager_eye"><img src="https://img.shields.io/badge/coverage-95%25-brightgreen.svg" alt="Coverage"></a>
15
15
  <a href="https://www.ruby-lang.org/"><img src="https://img.shields.io/badge/ruby-%3E%3D%203.1-ruby.svg" alt="Ruby"></a>
16
16
  <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
@@ -27,38 +27,30 @@ module EagerEye
27
27
 
28
28
  def run
29
29
  @issues = []
30
- collect_association_preloads
31
- collect_delegation_maps
32
- analyze_files
30
+ collect_model_metadata
31
+ ruby_files.each { |file_path| analyze_file(file_path) }
33
32
  @issues
34
33
  end
35
34
 
36
35
  private
37
36
 
38
- def collect_association_preloads
37
+ def collect_model_metadata
39
38
  model_files.each do |file_path|
40
39
  ast = parse_source(File.read(file_path))
41
40
  next unless ast
42
41
 
43
- parser = AssociationParser.new
44
- parser.parse_model(ast, extract_model_name(file_path))
45
- @association_preloads.merge!(parser.preloaded_associations)
46
- end
47
- rescue StandardError
48
- nil
49
- end
42
+ model_name = extract_model_name(file_path)
50
43
 
51
- def collect_delegation_maps
52
- model_files.each do |file_path|
53
- ast = parse_source(File.read(file_path))
54
- next unless ast
44
+ assoc_parser = AssociationParser.new
45
+ assoc_parser.parse_model(ast, model_name)
46
+ @association_preloads.merge!(assoc_parser.preloaded_associations)
55
47
 
56
- parser = DelegationParser.new
57
- parser.parse_model(ast, extract_model_name(file_path))
58
- @delegation_maps.merge!(parser.delegation_maps)
48
+ deleg_parser = DelegationParser.new
49
+ deleg_parser.parse_model(ast, model_name)
50
+ @delegation_maps.merge!(deleg_parser.delegation_maps)
51
+ rescue Errno::ENOENT, Errno::EACCES
52
+ next
59
53
  end
60
- rescue StandardError
61
- nil
62
54
  end
63
55
 
64
56
  def model_files
@@ -66,25 +58,19 @@ module EagerEye
66
58
  end
67
59
 
68
60
  def extract_model_name(file_path)
69
- File.basename(file_path, ".rb").camelize
61
+ name = File.basename(file_path, ".rb")
62
+ name.respond_to?(:camelize) ? name.camelize : name.split("_").map(&:capitalize).join
70
63
  end
71
64
 
72
- def analyze_files
73
- ruby_files.each { |file_path| analyze_file(file_path) }
65
+ def ruby_files
66
+ paths.flat_map { |path| resolve_path(path) }.reject { |file| excluded?(file) }
74
67
  end
75
68
 
76
- def ruby_files
77
- all_files = paths.flat_map do |path|
78
- if File.file?(path)
79
- [path]
80
- elsif File.directory?(path)
81
- Dir.glob(File.join(path, "**", "*.rb"))
82
- else
83
- Dir.glob(path)
84
- end
85
- end
69
+ def resolve_path(path)
70
+ return [path] if File.file?(path)
71
+ return Dir.glob(File.join(path, "**", "*.rb")) if File.directory?(path)
86
72
 
87
- all_files.reject { |file| excluded?(file) }
73
+ Dir.glob(path)
88
74
  end
89
75
 
90
76
  def excluded?(file_path)
@@ -103,9 +89,10 @@ module EagerEye
103
89
 
104
90
  enabled_detectors.each do |detector|
105
91
  file_issues = detector.detect(*detector_args(detector, ast, file_path))
106
- file_issues.reject! { |issue| comment_parser.disabled_at?(issue.line_number, issue.detector) }
107
- file_issues.select! { |issue| issue.meets_minimum_severity?(min_severity) }
108
- @issues.concat(file_issues)
92
+ @issues.concat(file_issues.select do |issue|
93
+ !comment_parser.disabled_at?(issue.line_number, issue.detector) &&
94
+ issue.meets_minimum_severity?(min_severity)
95
+ end)
109
96
  end
110
97
  rescue Errno::ENOENT, Errno::EACCES => e
111
98
  warn "EagerEye: Could not read file #{file_path}: #{e.message}"
@@ -126,8 +113,7 @@ module EagerEye
126
113
 
127
114
  def enabled_detectors
128
115
  @enabled_detectors ||= EagerEye.configuration.enabled_detectors.filter_map do |name|
129
- detector_class = DETECTOR_CLASSES[name]
130
- detector_class&.new
116
+ DETECTOR_CLASSES[name]&.new
131
117
  end
132
118
  end
133
119
  end
@@ -5,7 +5,6 @@ module EagerEye
5
5
  FILE_DISABLE_PATTERN = /eager_eye:disable-file\s+(.+?)(?:\s+--|$)/i
6
6
  NEXT_LINE_PATTERN = /eager_eye:disable-next-line(?:\s+(.+?))?(?:\s+--|$)/i
7
7
  BLOCK_START_PATTERN = /eager_eye:disable-block(?:\s+(.+?))?(?:\s+--|$)/i
8
- BLOCK_END_PATTERN = /eager_eye:enable-block(?:\s+(.+?))?(?:\s+--|$)/i
9
8
  INLINE_DISABLE_PATTERN = /eager_eye:disable\s+(.+?)(?:\s+--|$)/i
10
9
  ENABLE_PATTERN = /eager_eye:enable(?:\s+(.+?))?(?:\s+--|$)/i
11
10
 
@@ -24,50 +24,42 @@ module EagerEye
24
24
  end
25
25
 
26
26
  def check_delegate(node, model_name)
27
- return unless bare_delegate_call?(node)
27
+ return unless delegate_call?(node)
28
28
 
29
29
  args = node.children[2..]
30
- methods = delegate_methods(args)
30
+ methods = extract_sym_args(args)
31
31
  return if methods.empty?
32
32
 
33
33
  to_target = extract_to_target(args)
34
34
  return unless to_target
35
35
 
36
- register_delegates(model_name, methods, to_target)
36
+ @delegation_maps[model_name] ||= {}
37
+ methods.each { |m| @delegation_maps[model_name][m] = to_target }
37
38
  end
38
39
 
39
- def bare_delegate_call?(node)
40
+ def delegate_call?(node)
40
41
  node.type == :send && node.children[0].nil? && node.children[1] == :delegate
41
42
  end
42
43
 
43
- def delegate_methods(args)
44
+ def extract_sym_args(args)
44
45
  args.select { |a| a&.type == :sym }.map { |a| a.children[0] }
45
46
  end
46
47
 
47
- def register_delegates(model_name, methods, to_target)
48
- @delegation_maps[model_name] ||= {}
49
- methods.each { |m| @delegation_maps[model_name][m] = to_target }
50
- end
51
-
52
48
  def extract_to_target(args)
53
49
  hash_arg = args.find { |a| a&.type == :hash }
54
50
  return unless hash_arg
55
51
 
56
- to_pair = hash_arg.children.find { |p| to_key_pair?(p) }
57
- extract_sym_value(to_pair)
58
- end
59
-
60
- def extract_sym_value(node)
61
- return unless node
52
+ to_pair = find_to_pair(hash_arg)
53
+ return unless to_pair
62
54
 
63
- value = node.children[1]
55
+ value = to_pair.children[1]
64
56
  value.children[0] if value&.type == :sym
65
57
  end
66
58
 
67
- def to_key_pair?(pair)
68
- pair.type == :pair &&
69
- pair.children[0]&.type == :sym &&
70
- pair.children[0].children[0] == :to
59
+ def find_to_pair(hash_node)
60
+ hash_node.children.find do |p|
61
+ p.type == :pair && p.children[0]&.type == :sym && p.children[0].children[0] == :to
62
+ end
71
63
  end
72
64
  end
73
65
  end
@@ -22,13 +22,12 @@ module EagerEye
22
22
  protected
23
23
 
24
24
  def create_issue(file_path:, line_number:, message:, severity: nil, suggestion: nil)
25
- resolved_severity = severity || configured_severity
26
25
  Issue.new(
27
26
  detector: self.class.detector_name,
28
27
  file_path: file_path,
29
28
  line_number: line_number,
30
29
  message: message,
31
- severity: resolved_severity,
30
+ severity: severity || configured_severity,
32
31
  suggestion: suggestion
33
32
  )
34
33
  end
@@ -73,6 +72,34 @@ module EagerEye
73
72
  symbols.add(key.children[0]) if key&.type == :sym
74
73
  end
75
74
  end
75
+
76
+ def extract_block_variable(block_node)
77
+ args = block_node&.children&.[](1)
78
+ first_arg = args&.children&.first
79
+ first_arg&.type == :arg ? first_arg.children[0] : nil
80
+ end
81
+
82
+ def receiver_chain_starts_with?(node, target_var)
83
+ return false unless node.is_a?(Parser::AST::Node)
84
+
85
+ case node.type
86
+ when :lvar then node.children[0] == target_var
87
+ when :send then receiver_chain_starts_with?(node.children[0], target_var)
88
+ else false
89
+ end
90
+ end
91
+
92
+ def reconstruct_chain(node)
93
+ return "" unless node.is_a?(Parser::AST::Node)
94
+
95
+ case node.type
96
+ when :lvar then node.children[0].to_s
97
+ when :send
98
+ receiver_str = reconstruct_chain(node.children[0])
99
+ receiver_str.empty? ? node.children[1].to_s : "#{receiver_str}.#{node.children[1]}"
100
+ else ""
101
+ end
102
+ end
76
103
  end
77
104
  end
78
105
  end
@@ -41,7 +41,6 @@ module EagerEye
41
41
  @issues = []
42
42
  @file_path = file_path
43
43
  @callback_methods = {}
44
-
45
44
  return @issues unless ast
46
45
 
47
46
  find_callback_definitions(ast)
@@ -67,9 +66,7 @@ module EagerEye
67
66
  node.children[2..].each do |arg|
68
67
  next unless arg.is_a?(Parser::AST::Node) && arg.type == :sym
69
68
 
70
- method_name = arg.children[0]
71
- callback_type = node.children[1]
72
- @callback_methods[method_name] = callback_type
69
+ @callback_methods[arg.children[0]] = node.children[1]
73
70
  end
74
71
  end
75
72
 
@@ -77,9 +74,8 @@ module EagerEye
77
74
  return unless node.is_a?(Parser::AST::Node)
78
75
 
79
76
  if node.type == :def && @callback_methods.key?(node.children[0])
80
- method_name = node.children[0]
81
77
  body = node.children[2]
82
- find_iterations_with_queries(body, method_name, @callback_methods[method_name]) if body
78
+ find_iterations_with_queries(body, node.children[0], @callback_methods[node.children[0]]) if body
83
79
  end
84
80
 
85
81
  node.children.each { |child| check_callback_methods(child) }
@@ -131,7 +127,6 @@ module EagerEye
131
127
  end
132
128
 
133
129
  def add_query_issue(node, method_name, callback_type)
134
- query_method = node.children[1]
135
130
  suggestion = if transactional_callback?(callback_type)
136
131
  "Callbacks run on every save/create/update. Move the query outside the iteration or preload data"
137
132
  else
@@ -141,7 +136,7 @@ module EagerEye
141
136
  @issues << create_issue(
142
137
  file_path: @file_path,
143
138
  line_number: node.loc.line,
144
- message: "Query method `.#{query_method}` found in `#{callback_type}` callback `:#{method_name}`",
139
+ message: "Query method `.#{node.children[1]}` found in `#{callback_type}` callback `:#{method_name}`",
145
140
  severity: :warning,
146
141
  suggestion: suggestion
147
142
  )
@@ -167,29 +162,6 @@ module EagerEye
167
162
  TRANSACTIONAL_CALLBACKS.include?(callback_type)
168
163
  end
169
164
 
170
- def extract_block_variable(block_node)
171
- args_node = block_node.children[1]
172
- return nil unless args_node&.type == :args
173
-
174
- first_arg = args_node.children[0]
175
- return nil unless first_arg&.type == :arg
176
-
177
- first_arg.children[0]
178
- end
179
-
180
- def receiver_chain_starts_with?(node, block_var)
181
- return false unless node.is_a?(Parser::AST::Node)
182
-
183
- case node.type
184
- when :lvar
185
- node.children[0] == block_var
186
- when :send
187
- receiver_chain_starts_with?(node.children[0], block_var)
188
- else
189
- false
190
- end
191
- end
192
-
193
165
  def non_ar_collection?(node)
194
166
  ns = root_namespace(node)
195
167
  ns && NON_AR_NAMESPACES.include?(ns)
@@ -199,10 +171,8 @@ module EagerEye
199
171
  return nil unless node.is_a?(Parser::AST::Node)
200
172
 
201
173
  case node.type
202
- when :const
203
- node.children[0].nil? ? node.children[1].to_s : root_namespace(node.children[0])
204
- when :send, :block
205
- root_namespace(node.children[0])
174
+ when :const then node.children[0].nil? ? node.children[1].to_s : root_namespace(node.children[0])
175
+ when :send, :block then root_namespace(node.children[0])
206
176
  end
207
177
  end
208
178
 
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EagerEye
4
+ module Detectors
5
+ module Concerns
6
+ module ClassInspector
7
+ HAS_MANY_ASSOCIATIONS = %w[
8
+ authors users owners creators admins members customers clients
9
+ posts articles comments categories tags children companies organizations
10
+ projects tasks items orders products accounts profiles settings
11
+ images avatars photos attachments documents
12
+ ].freeze
13
+
14
+ ACTIVE_STORAGE_METHODS = %i[
15
+ attached? attach attachment attachments blob blobs purge purge_later variant preview
16
+ ].freeze
17
+
18
+ private
19
+
20
+ def const_to_string(node)
21
+ return nil unless node&.type == :const
22
+
23
+ parts = []
24
+ current = node
25
+ while current&.type == :const
26
+ parts.unshift(current.children[1].to_s)
27
+ current = current.children[0]
28
+ end
29
+ parts.join("::")
30
+ end
31
+
32
+ def extract_class_name(class_node)
33
+ name_node = class_node.children[0]
34
+ name_node.children[1].to_s if name_node&.type == :const
35
+ end
36
+
37
+ def likely_association?(method_name)
38
+ HAS_MANY_ASSOCIATIONS.include?(method_name.to_s)
39
+ end
40
+
41
+ def collect_active_storage_lines(body)
42
+ lines = Set.new
43
+ traverse_ast(body) do |node|
44
+ lines << node.loc.line if node.type == :send && ACTIVE_STORAGE_METHODS.include?(node.children[1])
45
+ end
46
+ lines
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -15,7 +15,6 @@ module EagerEye
15
15
  def detect(ast, file_path)
16
16
  @issues = []
17
17
  @file_path = file_path
18
-
19
18
  return @issues unless ast
20
19
 
21
20
  find_iteration_blocks(ast) do |block_body, block_var|
@@ -60,64 +59,23 @@ module EagerEye
60
59
  def array_returning_method?(node)
61
60
  return false unless node.is_a?(Parser::AST::Node) && node.type == :send
62
61
 
63
- method_name = node.children[1].to_s
64
- ARRAY_METHOD_SUFFIXES.any? { |suffix| method_name.end_with?(suffix) }
62
+ ARRAY_METHOD_SUFFIXES.any? { |suffix| node.children[1].to_s.end_with?(suffix) }
65
63
  end
66
64
 
67
65
  def association_call_on_block_var?(node, block_var)
68
- return false unless node.is_a?(Parser::AST::Node) && node.type == :send
69
-
70
- receiver = node.children[0]
71
- return false unless receiver.is_a?(Parser::AST::Node)
72
-
73
- return true if receiver.type == :lvar && receiver.children[0] == block_var
74
-
75
- receiver.type == :send && chain_starts_with_block_var?(receiver, block_var)
76
- end
77
-
78
- def chain_starts_with_block_var?(node, block_var)
79
- return false unless node.is_a?(Parser::AST::Node)
80
-
81
- case node.type
82
- when :lvar then node.children[0] == block_var
83
- when :send then chain_starts_with_block_var?(node.children[0], block_var)
84
- else false
85
- end
86
- end
87
-
88
- def extract_block_variable(block_node)
89
- args_node = block_node.children[1]
90
- return nil unless args_node&.type == :args
91
-
92
- first_arg = args_node.children[0]
93
- first_arg&.type == :arg ? first_arg.children[0] : nil
66
+ node.is_a?(Parser::AST::Node) && node.type == :send &&
67
+ receiver_chain_starts_with?(node.children[0], block_var)
94
68
  end
95
69
 
96
70
  def add_issue(node)
97
- receiver_chain = reconstruct_chain(node.children[0])
98
-
71
+ chain = reconstruct_chain(node.children[0])
99
72
  @issues << create_issue(
100
73
  file_path: @file_path,
101
74
  line_number: node.loc.line,
102
- message: "`.count` called on `#{receiver_chain}` inside iteration always executes a COUNT query",
75
+ message: "`.count` called on `#{chain}` inside iteration always executes a COUNT query",
103
76
  suggestion: "Use `.size` instead (uses loaded collection) or add `counter_cache: true`"
104
77
  )
105
78
  end
106
-
107
- def reconstruct_chain(node)
108
- return "" unless node.is_a?(Parser::AST::Node)
109
-
110
- case node.type
111
- when :lvar
112
- node.children[0].to_s
113
- when :send
114
- receiver_str = reconstruct_chain(node.children[0])
115
- method = node.children[1]
116
- receiver_str.empty? ? method.to_s : "#{receiver_str}.#{method}"
117
- else
118
- ""
119
- end
120
- end
121
79
  end
122
80
  end
123
81
  end
@@ -22,8 +22,7 @@ module EagerEye
22
22
  @file_path = file_path
23
23
 
24
24
  find_iteration_blocks(ast) do |block_body, block_var, collection, definitions|
25
- is_array_collection = collection_is_array?(collection, definitions)
26
- check_block_for_query_methods(block_body, block_var, is_array_collection)
25
+ check_block_for_query_methods(block_body, block_var, collection_is_array?(collection, definitions))
27
26
  end
28
27
 
29
28
  @issues
@@ -71,52 +70,29 @@ module EagerEye
71
70
  return true if receiver_ends_with_safe_transform_method?(node.children[0])
72
71
 
73
72
  SAFE_QUERY_METHODS.include?(node.children[1]) &&
74
- is_array_collection && receiver_is_only_block_var?(node.children[0], block_var)
73
+ is_array_collection && direct_block_var?(node.children[0], block_var)
75
74
  end
76
75
 
77
- def receiver_is_only_block_var?(node, block_var)
76
+ def direct_block_var?(node, block_var)
78
77
  node.is_a?(Parser::AST::Node) && node.type == :lvar && node.children[0] == block_var
79
78
  end
80
79
 
81
- def receiver_chain_starts_with?(node, block_var)
82
- return false unless node.is_a?(Parser::AST::Node)
83
-
84
- case node.type
85
- when :lvar then node.children[0] == block_var
86
- when :send then receiver_chain_starts_with?(node.children[0], block_var)
87
- else false
88
- end
89
- end
90
-
91
- def extract_block_variable(block_node)
92
- args_node = block_node.children[1]
93
- return nil unless args_node&.type == :args
94
-
95
- first_arg = args_node.children[0]
96
- first_arg&.type == :arg ? first_arg.children[0] : nil
97
- end
98
-
99
80
  def collection_is_array?(node, definitions = {}, visited = Set.new)
100
81
  return false unless node.is_a?(Parser::AST::Node)
101
- return false if visited.include?(node.object_id)
102
-
103
- visited.add(node.object_id)
82
+ return false unless visited.add?(node.object_id)
104
83
 
105
84
  return true if %i[array hash].include?(node.type)
106
- return check_lvar_collection?(node, definitions, visited) if node.type == :lvar
107
- return check_send_collection?(node, definitions, visited) if node.type == :send
108
-
109
- false
110
- end
111
85
 
112
- def check_lvar_collection?(node, definitions, visited)
113
- return false unless definitions
114
-
115
- definition = definitions[node.children[0]]
116
- definition ? collection_is_array?(definition, definitions, visited) : false
86
+ case node.type
87
+ when :lvar
88
+ defn = definitions[node.children[0]]
89
+ defn && collection_is_array?(defn, definitions, visited)
90
+ when :send then send_returns_array?(node, definitions, visited)
91
+ else false
92
+ end
117
93
  end
118
94
 
119
- def check_send_collection?(node, definitions, visited)
95
+ def send_returns_array?(node, definitions, visited)
120
96
  method_name = node.children[1]
121
97
  return true if %i[map select collect flat_map uniq compact].include?(method_name)
122
98
  return true if SAFE_TRANSFORM_METHODS.include?(method_name)
@@ -128,41 +104,23 @@ module EagerEye
128
104
  return false unless node.is_a?(Parser::AST::Node) && node.type == :send
129
105
 
130
106
  method_name = node.children[1]
131
- SAFE_TRANSFORM_METHODS.include?(method_name) || array_column_method?(method_name)
107
+ SAFE_TRANSFORM_METHODS.include?(method_name) ||
108
+ ARRAY_COLUMN_SUFFIXES.any? { |suffix| method_name.to_s.end_with?(suffix) }
132
109
  end
133
110
 
134
111
  def receiver_is_query_chain?(node)
135
112
  node.is_a?(Parser::AST::Node) && node.type == :send && QUERY_METHODS.include?(node.children[1])
136
113
  end
137
114
 
138
- def array_column_method?(method_name)
139
- method_str = method_name.to_s
140
- ARRAY_COLUMN_SUFFIXES.any? { |suffix| method_str.end_with?(suffix) }
141
- end
142
-
143
115
  def add_issue(node)
144
- method_name = node.children[1]
145
- association_chain = reconstruct_chain(node.children[0])
146
-
116
+ chain = reconstruct_chain(node.children[0])
147
117
  @issues << create_issue(
148
118
  file_path: @file_path,
149
119
  line_number: node.loc.line,
150
- message: "Query method `.#{method_name}` called on `#{association_chain}` inside iteration",
120
+ message: "Query method `.#{node.children[1]}` called on `#{chain}` inside iteration",
151
121
  suggestion: "This query executes on each iteration. Consider preloading data or restructuring the query."
152
122
  )
153
123
  end
154
-
155
- def reconstruct_chain(node)
156
- return "" unless node.is_a?(Parser::AST::Node)
157
-
158
- case node.type
159
- when :lvar then node.children[0].to_s
160
- when :send
161
- receiver_str = reconstruct_chain(node.children[0])
162
- receiver_str.empty? ? node.children[1].to_s : "#{receiver_str}.#{node.children[1]}"
163
- else ""
164
- end
165
- end
166
124
  end
167
125
  end
168
126
  end
@@ -1,18 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "concerns/class_inspector"
4
+
3
5
  module EagerEye
4
6
  module Detectors
5
7
  class DecoratorNPlusOne < Base
8
+ include Concerns::ClassInspector
9
+
6
10
  DECORATOR_PATTERNS = %w[Draper::Decorator SimpleDelegator Delegator].freeze
7
11
  OBJECT_REFS = %i[object __getobj__ source model].freeze
8
- ACTIVE_STORAGE_METHODS = %i[attached? attach attachment attachments blob blobs purge purge_later variant
9
- preview].freeze
10
- HAS_MANY_ASSOCIATIONS = %w[
11
- authors users owners creators admins members customers clients
12
- posts articles comments categories tags children companies organizations
13
- projects tasks items orders products accounts profiles settings
14
- images avatars photos attachments documents
15
- ].freeze
16
12
 
17
13
  def self.detector_name
18
14
  :decorator_n_plus_one
@@ -22,34 +18,21 @@ module EagerEye
22
18
  return [] unless ast
23
19
 
24
20
  issues = []
25
-
26
21
  traverse_ast(ast) do |node|
27
- next unless decorator_class?(node)
22
+ next unless node.type == :class && decorator_class?(node)
28
23
 
29
24
  find_association_accesses(node, file_path, issues)
30
25
  end
31
-
32
26
  issues
33
27
  end
34
28
 
35
29
  private
36
30
 
37
31
  def decorator_class?(node)
38
- return false unless node.type == :class
39
-
40
32
  class_name = extract_class_name(node)
41
33
  return false unless class_name
42
34
 
43
- decorator_name_pattern?(class_name) || inherits_from_decorator?(node)
44
- end
45
-
46
- def decorator_name_pattern?(class_name)
47
- class_name.end_with?("Decorator", "Presenter", "ViewObject")
48
- end
49
-
50
- def extract_class_name(class_node)
51
- name_node = class_node.children[0]
52
- name_node.children[1].to_s if name_node&.type == :const
35
+ class_name.end_with?("Decorator", "Presenter", "ViewObject") || inherits_from_decorator?(node)
53
36
  end
54
37
 
55
38
  def inherits_from_decorator?(class_node)
@@ -60,18 +43,6 @@ module EagerEye
60
43
  DECORATOR_PATTERNS.any? { |p| parent_name&.include?(p.split("::").last) }
61
44
  end
62
45
 
63
- def const_to_string(node)
64
- return nil unless node&.type == :const
65
-
66
- parts = []
67
- current = node
68
- while current&.type == :const
69
- parts.unshift(current.children[1].to_s)
70
- current = current.children[0]
71
- end
72
- parts.join("::")
73
- end
74
-
75
46
  def find_association_accesses(class_node, file_path, issues)
76
47
  body = class_node.children[2]
77
48
  return unless body
@@ -88,49 +59,25 @@ module EagerEye
88
59
 
89
60
  storage_lines = collect_active_storage_lines(method_body)
90
61
  traverse_ast(method_body) do |node|
91
- next unless association_access?(node, storage_lines)
62
+ next unless node.type == :send
63
+ next if storage_lines.include?(node.loc.line)
92
64
 
93
65
  receiver = node.children[0]
94
66
  method_name = node.children[1]
95
- issues << create_decorator_issue(file_path, node.loc.line, receiver, method_name)
67
+ next unless object_reference?(receiver) && likely_association?(method_name)
68
+
69
+ ref = receiver.children[1]
70
+ issues << create_issue(
71
+ file_path: file_path,
72
+ line_number: node.loc.line,
73
+ message: "N+1 in decorator: `#{ref}.#{method_name}` loads association on each decorated object",
74
+ suggestion: "Eager load :#{method_name} in the controller before decorating the collection"
75
+ )
96
76
  end
97
77
  end
98
78
 
99
- def association_access?(node, storage_lines)
100
- return false unless node.type == :send
101
- return false if storage_lines.include?(node.loc.line)
102
-
103
- object_reference?(node.children[0]) && likely_association?(node.children[1])
104
- end
105
-
106
- def collect_active_storage_lines(body)
107
- lines = Set.new
108
- traverse_ast(body) do |node|
109
- next unless node.type == :send && ACTIVE_STORAGE_METHODS.include?(node.children[1])
110
-
111
- lines << node.loc.line
112
- end
113
- lines
114
- end
115
-
116
79
  def object_reference?(node)
117
- return false unless node&.type == :send
118
-
119
- node.children[0].nil? && OBJECT_REFS.include?(node.children[1])
120
- end
121
-
122
- def likely_association?(method_name)
123
- HAS_MANY_ASSOCIATIONS.include?(method_name.to_s)
124
- end
125
-
126
- def create_decorator_issue(file_path, line, receiver, method_name)
127
- ref = receiver.children[1]
128
- create_issue(
129
- file_path: file_path,
130
- line_number: line,
131
- message: "N+1 in decorator: `#{ref}.#{method_name}` loads association on each decorated object",
132
- suggestion: "Eager load :#{method_name} in the controller before decorating the collection"
133
- )
80
+ node&.type == :send && node.children[0].nil? && OBJECT_REFS.include?(node.children[1])
134
81
  end
135
82
  end
136
83
  end
@@ -23,14 +23,11 @@ module EagerEye
23
23
  next unless iteration_block?(node)
24
24
 
25
25
  block_var = extract_block_variable(node)
26
- next unless block_var
27
-
28
26
  block_body = node.children[2]
29
- next unless block_body
27
+ next unless block_var && block_body
30
28
 
31
29
  collection_node = node.children[0]
32
- model_name = infer_model_name(collection_node)
33
- delegates = build_delegates(model_name, delegation_maps, local_delegates)
30
+ delegates = build_delegates(infer_model_name(collection_node), delegation_maps, local_delegates)
34
31
  next if delegates.empty?
35
32
 
36
33
  included = extract_included_associations(collection_node)
@@ -45,17 +42,13 @@ module EagerEye
45
42
  def collect_local_delegates(ast)
46
43
  delegates = {}
47
44
  traverse_ast(ast) do |node|
48
- next unless delegate_call?(node)
45
+ next unless node.type == :send && node.children[0].nil? && node.children[1] == :delegate
49
46
 
50
47
  extract_delegate_info(node, delegates)
51
48
  end
52
49
  delegates
53
50
  end
54
51
 
55
- def delegate_call?(node)
56
- node.type == :send && node.children[0].nil? && node.children[1] == :delegate
57
- end
58
-
59
52
  def extract_delegate_info(node, delegates)
60
53
  args = node.children[2..]
61
54
  methods = args.select { |a| a&.type == :sym }.map { |a| a.children[0] }
@@ -71,21 +64,17 @@ module EagerEye
71
64
  hash_arg = args.find { |a| a&.type == :hash }
72
65
  return unless hash_arg
73
66
 
74
- to_pair = hash_arg.children.find { |p| to_key_pair?(p) }
75
- extract_sym_value(to_pair)
76
- end
77
-
78
- def extract_sym_value(node)
79
- return unless node
67
+ to_pair = find_to_pair(hash_arg)
68
+ return unless to_pair
80
69
 
81
- value = node.children[1]
70
+ value = to_pair.children[1]
82
71
  value.children[0] if value&.type == :sym
83
72
  end
84
73
 
85
- def to_key_pair?(pair)
86
- pair.type == :pair &&
87
- pair.children[0]&.type == :sym &&
88
- pair.children[0].children[0] == :to
74
+ def find_to_pair(hash_node)
75
+ hash_node.children.find do |p|
76
+ p.type == :pair && p.children[0]&.type == :sym && p.children[0].children[0] == :to
77
+ end
89
78
  end
90
79
 
91
80
  def build_delegates(model_name, delegation_maps, local_delegates)
@@ -94,26 +83,14 @@ module EagerEye
94
83
  end
95
84
 
96
85
  def iteration_block?(node)
97
- node.type == :block &&
98
- node.children[0]&.type == :send &&
86
+ node.type == :block && node.children[0]&.type == :send &&
99
87
  ITERATION_METHODS.include?(node.children[0].children[1])
100
88
  end
101
89
 
102
- def extract_block_variable(block_node)
103
- args = block_node&.children&.[](1)
104
- first_arg = args&.children&.first
105
- first_arg&.type == :arg ? first_arg.children[0] : nil
106
- end
107
-
108
90
  def infer_model_name(node)
109
- root = find_root_receiver(node)
110
- root&.type == :const ? root.children[1].to_s : nil
111
- end
112
-
113
- def find_root_receiver(node)
114
91
  current = node
115
92
  current = current.children[0] while current&.type == :send
116
- current
93
+ current&.type == :const ? current.children[1].to_s : nil
117
94
  end
118
95
 
119
96
  def extract_included_associations(collection_node)
@@ -122,55 +99,45 @@ module EagerEye
122
99
 
123
100
  current = collection_node
124
101
  while current&.type == :send
125
- extract_from_preload(current, included) if PRELOAD_METHODS.include?(current.children[1])
102
+ if PRELOAD_METHODS.include?(current.children[1])
103
+ included.merge(extract_symbols_from_args(extract_method_args(current)))
104
+ end
126
105
  current = current.children[0]
127
106
  end
128
107
  included
129
108
  end
130
109
 
131
- def extract_from_preload(method_node, included_set)
132
- args = extract_method_args(method_node)
133
- included_set.merge(extract_symbols_from_args(args))
134
- end
135
-
136
110
  def find_delegated_calls(block_body, block_var, delegates, included, file_path, issues)
137
111
  reported = Set.new
138
112
  traverse_ast(block_body) do |node|
139
113
  target_assoc = delegation_target(node, block_var, delegates, included, reported)
140
114
  next unless target_assoc
141
115
 
142
- issues << create_delegation_issue(node, block_var, target_assoc, file_path)
116
+ method = node.children[1]
117
+ issues << create_issue(
118
+ file_path: file_path,
119
+ line_number: node.loc.line,
120
+ message: "Potential N+1: `#{block_var}.#{method}` is delegated to `#{target_assoc}` — " \
121
+ "loads `#{target_assoc}` on each iteration",
122
+ suggestion: "Use `includes(:#{target_assoc})` before iterating"
123
+ )
143
124
  end
144
125
  end
145
126
 
146
127
  def delegation_target(node, block_var, delegates, included, reported)
147
- return unless node.type == :send
148
- return unless block_var_receiver?(node, block_var)
128
+ return unless node.type == :send && block_var_receiver?(node, block_var)
149
129
 
150
130
  method = node.children[1]
151
131
  target_assoc = delegates[method]
152
- return unless target_assoc
153
- return if included.include?(target_assoc)
154
- return unless reported.add?("#{node.loc.line}:#{method}")
132
+ return unless target_assoc && !included.include?(target_assoc)
155
133
 
156
- target_assoc
134
+ reported.add?("#{node.loc.line}:#{method}") ? target_assoc : nil
157
135
  end
158
136
 
159
137
  def block_var_receiver?(node, block_var)
160
138
  receiver = node.children[0]
161
139
  receiver&.type == :lvar && receiver.children[0] == block_var
162
140
  end
163
-
164
- def create_delegation_issue(node, block_var, target_assoc, file_path)
165
- method = node.children[1]
166
- create_issue(
167
- file_path: file_path,
168
- line_number: node.loc.line,
169
- message: "Potential N+1: `#{block_var}.#{method}` is delegated to `#{target_assoc}` — " \
170
- "loads `#{target_assoc}` on each iteration",
171
- suggestion: "Use `includes(:#{target_assoc})` before iterating"
172
- )
173
- end
174
141
  end
175
142
  end
176
143
  end
@@ -51,8 +51,7 @@ module EagerEye
51
51
 
52
52
  included = extract_included_associations(collection_node)
53
53
  included.merge(extract_variable_preloads(collection_node))
54
- model_name = infer_model_name_from_collection(collection_node)
55
- included.merge(get_association_preloads(model_name))
54
+ included.merge(get_association_preloads(infer_model_name_from_collection(collection_node)))
56
55
 
57
56
  find_association_calls(block_body, block_var, file_path, issues, included)
58
57
  end
@@ -63,10 +62,9 @@ module EagerEye
63
62
  private
64
63
 
65
64
  def get_association_preloads(model_name)
66
- key = "#{model_name}#*"
67
65
  preloaded = Set.new
68
- @association_preloads&.each do |assoc_key, assocs|
69
- preloaded.merge(assocs) if assoc_key.start_with?(key)
66
+ @association_preloads&.each do |key, assocs|
67
+ preloaded.merge(assocs) if key.start_with?("#{model_name}#")
70
68
  end
71
69
  preloaded
72
70
  end
@@ -83,12 +81,6 @@ module EagerEye
83
81
  ITERATION_METHODS.include?(node.children[0].children[1])
84
82
  end
85
83
 
86
- def extract_block_variable(block_node)
87
- args = block_node&.children&.[](1)
88
- first_arg = args&.children&.first
89
- first_arg&.type == :arg ? first_arg.children[0] : nil
90
- end
91
-
92
84
  def extract_included_associations(collection_node)
93
85
  included = Set.new
94
86
  return included unless collection_node&.type == :send
@@ -142,12 +134,12 @@ module EagerEye
142
134
 
143
135
  def find_last_send_method(node)
144
136
  current = node
145
- current = current.children[0] while current&.type == :send && !single_record_method?(current)
146
- current&.type == :send ? current.children[1] : nil
147
- end
137
+ while current&.type == :send
138
+ return current.children[1] if SINGLE_RECORD_METHODS.include?(current.children[1])
148
139
 
149
- def single_record_method?(node)
150
- SINGLE_RECORD_METHODS.include?(node.children[1])
140
+ current = current.children[0]
141
+ end
142
+ nil
151
143
  end
152
144
 
153
145
  def single_record_iteration?(node)
@@ -158,47 +150,40 @@ module EagerEye
158
150
  end
159
151
 
160
152
  def extract_includes_from_method(method_node, included_set)
161
- args = extract_method_args(method_node)
162
- included_set.merge(extract_symbols_from_args(args))
153
+ included_set.merge(extract_symbols_from_args(extract_method_args(method_node)))
163
154
  end
164
155
 
165
156
  def find_association_calls(node, block_var, file_path, issues, included_associations = Set.new)
166
157
  reported = Set.new
167
158
  traverse_ast(node) do |child|
168
- next unless should_report_issue?(child, block_var, reported, included_associations)
169
-
170
- add_n_plus_one_issue(child, block_var, file_path, issues, reported)
159
+ next unless reportable_association_call?(child, block_var, reported, included_associations)
160
+
161
+ method = child.children[1]
162
+ issues << create_issue(
163
+ file_path: file_path,
164
+ line_number: child.loc.line,
165
+ message: "Potential N+1 query: `#{block_var}.#{method}` called inside loop",
166
+ suggestion: "Use `includes(:#{method})` before iterating"
167
+ )
171
168
  end
172
169
  end
173
170
 
174
- def should_report_issue?(child, block_var, reported, included)
175
- return false unless child.type == :send
171
+ def reportable_association_call?(node, block_var, reported, included)
172
+ return false unless node.type == :send
176
173
 
177
- receiver = child.children[0]
178
- method = child.children[1]
174
+ receiver = node.children[0]
175
+ method = node.children[1]
179
176
  return false unless receiver&.type == :lvar && receiver.children[0] == block_var
180
- return false if excluded?(method, included)
177
+ return false if excluded_method?(method, included)
181
178
 
182
- key = "#{child.loc.line}:#{method}"
183
- !reported.include?(key) && reported.add(key)
179
+ reported.add?("#{node.loc.line}:#{method}")
184
180
  end
185
181
 
186
- def excluded?(method, included)
182
+ def excluded_method?(method, included)
187
183
  EXCLUDED_METHODS.include?(method) ||
188
184
  !ASSOCIATION_NAMES.include?(method.to_s) ||
189
185
  included.include?(method)
190
186
  end
191
-
192
- def add_n_plus_one_issue(node, block_var, file_path, issues, reported)
193
- method = node.children[1]
194
- reported << "#{node.loc.line}:#{method}"
195
- issues << create_issue(
196
- file_path: file_path,
197
- line_number: node.loc.line,
198
- message: "Potential N+1 query: `#{block_var}.#{method}` called inside loop",
199
- suggestion: "Use `includes(:#{method})` before iterating"
200
- )
201
- end
202
187
  end
203
188
  end
204
189
  end
@@ -1,19 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "concerns/class_inspector"
4
+
3
5
  module EagerEye
4
6
  module Detectors
5
7
  class SerializerNesting < Base
8
+ include Concerns::ClassInspector
9
+
6
10
  SERIALIZER_PATTERNS = %w[ActiveModel::Serializer ActiveModelSerializers::Model Blueprinter::Base Alba::Resource].freeze
7
11
  ATTRIBUTE_METHODS = %i[attribute field attributes].freeze
8
12
  OBJECT_REFS = %i[object record resource].freeze
9
- ACTIVE_STORAGE_METHODS = %i[attached? attach attachment attachments blob blobs purge purge_later variant
10
- preview].freeze
11
- HAS_MANY_ASSOCIATIONS = %w[
12
- authors users owners creators admins members customers clients
13
- posts articles comments categories tags children companies organizations
14
- projects tasks items orders products accounts profiles settings
15
- images avatars photos attachments documents
16
- ].freeze
17
13
 
18
14
  def self.detector_name
19
15
  :serializer_nesting
@@ -23,21 +19,17 @@ module EagerEye
23
19
  return [] unless ast
24
20
 
25
21
  issues = []
26
-
27
22
  traverse_ast(ast) do |node|
28
- next unless serializer_class?(node)
23
+ next unless node.type == :class && serializer_class?(node)
29
24
 
30
25
  find_nested_associations(node, file_path, issues)
31
26
  end
32
-
33
27
  issues
34
28
  end
35
29
 
36
30
  private
37
31
 
38
32
  def serializer_class?(node)
39
- return false unless node.type == :class
40
-
41
33
  class_name = extract_class_name(node)
42
34
  return false unless class_name
43
35
 
@@ -45,11 +37,6 @@ module EagerEye
45
37
  inherits_from_serializer?(node) || includes_serializer_module?(node)
46
38
  end
47
39
 
48
- def extract_class_name(class_node)
49
- name_node = class_node.children[0]
50
- name_node.children[1].to_s if name_node&.type == :const
51
- end
52
-
53
40
  def inherits_from_serializer?(class_node)
54
41
  parent_node = class_node.children[1]
55
42
  return false unless parent_node
@@ -73,18 +60,6 @@ module EagerEye
73
60
  arg && const_to_string(arg)&.include?("Alba")
74
61
  end
75
62
 
76
- def const_to_string(node)
77
- return nil unless node&.type == :const
78
-
79
- parts = []
80
- current = node
81
- while current&.type == :const
82
- parts.unshift(current.children[1].to_s)
83
- current = current.children[0]
84
- end
85
- parts.join("::")
86
- end
87
-
88
63
  def find_nested_associations(class_node, file_path, issues)
89
64
  body = class_node.children[2]
90
65
  return unless body
@@ -121,16 +96,6 @@ module EagerEye
121
96
  end
122
97
  end
123
98
 
124
- def collect_active_storage_lines(block_body)
125
- lines = Set.new
126
- traverse_ast(block_body) do |node|
127
- next unless node.type == :send && ACTIVE_STORAGE_METHODS.include?(node.children[1])
128
-
129
- lines << node.loc.line
130
- end
131
- lines
132
- end
133
-
134
99
  def object_reference?(node)
135
100
  return false unless node
136
101
 
@@ -148,10 +113,6 @@ module EagerEye
148
113
  else "object"
149
114
  end
150
115
  end
151
-
152
- def likely_association?(method_name)
153
- HAS_MANY_ASSOCIATIONS.include?(method_name.to_s)
154
- end
155
116
  end
156
117
  end
157
118
  end
@@ -19,17 +19,13 @@ module EagerEye
19
19
  issues.group_by(&:file_path)
20
20
  end
21
21
 
22
- def info_count
23
- issues.count { |i| i.severity == :info }
22
+ def severity_counts
23
+ @severity_counts ||= issues.map(&:severity).tally
24
24
  end
25
25
 
26
- def warning_count
27
- issues.count { |i| i.severity == :warning }
28
- end
29
-
30
- def error_count
31
- issues.count { |i| i.severity == :error }
32
- end
26
+ def info_count = severity_counts.fetch(:info, 0)
27
+ def warning_count = severity_counts.fetch(:warning, 0)
28
+ def error_count = severity_counts.fetch(:error, 0)
33
29
  end
34
30
  end
35
31
  end
@@ -73,12 +73,12 @@ module EagerEye
73
73
  end
74
74
 
75
75
  def summary
76
- t = issues.size
77
- e = error_count
78
- w = warning_count
79
- i = info_count
80
- "Total: #{t} issue#{"s" unless t == 1} " \
81
- "(#{e} error#{"s" unless e == 1}, #{w} warning#{"s" unless w == 1}, #{i} info)"
76
+ "Total: #{pluralize(issues.size, "issue")} " \
77
+ "(#{pluralize(error_count, "error")}, #{pluralize(warning_count, "warning")}, #{info_count} info)"
78
+ end
79
+
80
+ def pluralize(count, word)
81
+ "#{count} #{word}#{"s" unless count == 1}"
82
82
  end
83
83
 
84
84
  def colorize(text, color)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EagerEye
4
- VERSION = "1.2.5"
4
+ VERSION = "1.2.6"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: eager_eye
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.5
4
+ version: 1.2.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - hamzagedikkaya
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-02-22 00:00:00.000000000 Z
11
+ date: 2026-02-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ast
@@ -67,6 +67,7 @@ files:
67
67
  - lib/eager_eye/delegation_parser.rb
68
68
  - lib/eager_eye/detectors/base.rb
69
69
  - lib/eager_eye/detectors/callback_query.rb
70
+ - lib/eager_eye/detectors/concerns/class_inspector.rb
70
71
  - lib/eager_eye/detectors/concerns/non_ar_source_detector.rb
71
72
  - lib/eager_eye/detectors/count_in_iteration.rb
72
73
  - lib/eager_eye/detectors/custom_method_query.rb