eager_eye 1.2.9 → 1.2.11

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: 4cedfbe1ec9b556f757e9b161f896d96986bfc2dac8bf865c6e1bc289f6777b8
4
- data.tar.gz: 8c0dc65a55e21c5d4e122f51293ac480f01435af2b719a262efe6ccdfa04603c
3
+ metadata.gz: 6f108cfabf63f2311f76b96145ea91535ef022703c3d10f73f04cbf6132110ba
4
+ data.tar.gz: ee909d0893f862770c3e481adf5e9041da306c86e821458dd5c069404738ff1a
5
5
  SHA512:
6
- metadata.gz: 64d29ba207011df8fee0e11e6557de652ad5e3ed87c140e68cbedebb44988a477da0e2d5842fe28460811ebcc53db974f5403f4d73dd9de2653980a329e9a10c
7
- data.tar.gz: f1dc481bc8b85c6e03443edee9508216ffd111d49c09c0aabf021ef9250e42bc8a84ca95ab7fa6afb808ab9c041736fb75af40bbb8acdd2efd4f5ac060f879b7
6
+ metadata.gz: 387702e5516001bd86637e039c999dbb52cf30e1d20ec293afd23c70a4b591be34f0aa245bc96eb6d5bec4e1e34a3a9bc9e09295525db53550b2df6833a3a06c
7
+ data.tar.gz: 605eec5df28fdaf038d77c95c564e8ee3eb4768be9e900be58c96b99a0b715ad4d28b286c65a2c7844f61906146db39168761744386f73ce5f61e1e77fe7c4cb
data/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.11] - 2026-03-17
11
+
12
+ ### Added
13
+
14
+ - **`each_with_object` / `reduce` / `inject` Support** - `LoopAssociation` and `CustomMethodQuery` detectors now recognize these iteration methods
15
+ - `each_with_object` and `each_with_index` blocks are detected (item is first param)
16
+ - `reduce` and `inject` blocks are detected with correct variable extraction (item is second param: `|memo, item|`)
17
+ - `extract_iteration_variable` helper added to `Base` for reuse across detectors
18
+
19
+ ## [1.2.10] - 2026-03-16
20
+
21
+ ### Added
22
+
23
+ - **Cross-File Analysis** - Model methods containing queries are now detected when called from other files
24
+ - New `MethodQueryParser` scans model files for methods that execute queries (`.where`, `.find_by`, `.count`, `.pluck`, etc.)
25
+ - `LoopAssociation` detects cross-file query methods called inside loops (e.g. `user.active_orders` in a controller)
26
+ - `CustomMethodQuery` detects model query methods called inside iterations
27
+ - `SerializerNesting` detects model query methods called in serializer attribute blocks
28
+ - `DecoratorNPlusOne` detects model query methods called in decorator methods
29
+ - Two-pass architecture: first pass collects method signatures, second pass detects cross-file N+1 patterns
30
+
10
31
  ## [1.2.9] - 2026-03-13
11
32
 
12
33
  ### Changed
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.9-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.11-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>
@@ -739,6 +739,7 @@ EagerEye uses static analysis, which means:
739
739
  - **No runtime context** - Cannot know if associations are already eager loaded elsewhere
740
740
  - **Heuristic-based** - Uses naming conventions to identify associations (may have false positives)
741
741
  - **Ruby code only** - Does not analyze SQL queries or ActiveRecord internals
742
+ - **Cross-file scope** - Cross-file analysis is limited to model-defined methods; controller-to-view or service-to-service patterns are not yet tracked
742
743
 
743
744
  For best results, use EagerEye alongside runtime tools like Bullet for comprehensive N+1 detection.
744
745
 
@@ -19,23 +19,25 @@ module EagerEye
19
19
  }.freeze
20
20
 
21
21
  DETECTOR_EXTRA_ARGS = {
22
- Detectors::LoopAssociation => %i[association_preloads association_names],
23
- Detectors::SerializerNesting => %i[association_names],
22
+ Detectors::LoopAssociation => %i[association_preloads association_names method_queries],
23
+ Detectors::SerializerNesting => %i[association_names method_queries],
24
24
  Detectors::MissingCounterCache => %i[association_names],
25
- Detectors::DecoratorNPlusOne => %i[association_names],
25
+ Detectors::DecoratorNPlusOne => %i[association_names method_queries],
26
+ Detectors::CustomMethodQuery => %i[method_queries],
26
27
  Detectors::DelegationNPlusOne => %i[delegation_maps],
27
28
  Detectors::ScopeChainNPlusOne => %i[scope_maps],
28
29
  Detectors::ValidationNPlusOne => %i[uniqueness_models]
29
30
  }.freeze
30
31
 
31
- attr_reader :paths, :issues, :association_preloads, :association_names, :delegation_maps, :scope_maps,
32
- :uniqueness_models
32
+ attr_reader :paths, :issues, :association_preloads, :association_names, :method_queries, :delegation_maps,
33
+ :scope_maps, :uniqueness_models
33
34
 
34
35
  def initialize(paths: nil)
35
36
  @paths = Array(paths || EagerEye.configuration.app_path)
36
37
  @issues = []
37
38
  @association_preloads = {}
38
39
  @association_names = Set.new
40
+ @method_queries = {}
39
41
  @delegation_maps = {}
40
42
  @scope_maps = {}
41
43
  @uniqueness_models = Set.new
@@ -73,6 +75,10 @@ module EagerEye
73
75
  validation_parser = ValidationParser.new
74
76
  validation_parser.parse_model(ast, model_name)
75
77
  @uniqueness_models.merge(validation_parser.uniqueness_models)
78
+
79
+ method_query_parser = MethodQueryParser.new
80
+ method_query_parser.parse_model(ast, model_name)
81
+ @method_queries.merge!(method_query_parser.method_queries)
76
82
  rescue Errno::ENOENT, Errno::EACCES
77
83
  next
78
84
  end
@@ -73,12 +73,24 @@ module EagerEye
73
73
  end
74
74
  end
75
75
 
76
+ ACCUMULATOR_FIRST_METHODS = %i[reduce inject].freeze
77
+
76
78
  def extract_block_variable(block_node)
77
79
  args = block_node&.children&.[](1)
78
80
  first_arg = args&.children&.first
79
81
  first_arg&.type == :arg ? first_arg.children[0] : nil
80
82
  end
81
83
 
84
+ def extract_iteration_variable(block_node)
85
+ idx = accumulator_first?(block_node) ? 1 : 0
86
+ arg = block_node.children[1]&.children&.[](idx)
87
+ arg&.type == :arg ? arg.children[0] : nil
88
+ end
89
+
90
+ def accumulator_first?(block_node)
91
+ ACCUMULATOR_FIRST_METHODS.include?(block_node.children[0]&.children&.[](1))
92
+ end
93
+
82
94
  def receiver_chain_starts_with?(node, target_var)
83
95
  return false unless node.is_a?(Parser::AST::Node)
84
96
 
@@ -9,20 +9,23 @@ module EagerEye
9
9
  SAFE_TRANSFORM_METHODS = %i[keys values split [] params sort pluck ids to_s to_a to_i chars bytes].freeze
10
10
  ARRAY_COLUMN_SUFFIXES = %w[_ids _tags _types _codes _names _values _arr].freeze
11
11
  ITERATION_METHODS = %i[each map select find_all reject collect detect find_index flat_map
12
+ each_with_index each_with_object reduce inject
12
13
  find_each find_in_batches in_batches].freeze
13
14
 
14
15
  def self.detector_name
15
16
  :custom_method_query
16
17
  end
17
18
 
18
- def detect(ast, file_path)
19
+ def detect(ast, file_path, method_queries = {})
19
20
  return [] unless ast
20
21
 
21
22
  @issues = []
22
23
  @file_path = file_path
24
+ @method_queries = method_queries
23
25
 
24
26
  find_iteration_blocks(ast) do |block_body, block_var, collection, definitions|
25
27
  check_block_for_query_methods(block_body, block_var, collection_is_array?(collection, definitions))
28
+ check_block_for_model_query_methods(block_body, block_var)
26
29
  end
27
30
 
28
31
  @issues
@@ -36,7 +39,7 @@ module EagerEye
36
39
  definitions[node.children[0]] = node.children[1] if node.type == :lvasgn
37
40
 
38
41
  if iteration_block?(node)
39
- block_var = extract_block_variable(node)
42
+ block_var = extract_iteration_variable(node)
40
43
  block_body = node.children[2]
41
44
  yield(block_body, block_var, node.children[0], definitions) if block_var && block_body
42
45
  end
@@ -112,6 +115,31 @@ module EagerEye
112
115
  node.is_a?(Parser::AST::Node) && node.type == :send && QUERY_METHODS.include?(node.children[1])
113
116
  end
114
117
 
118
+ def check_block_for_model_query_methods(node, block_var)
119
+ return unless node.is_a?(Parser::AST::Node)
120
+
121
+ if model_query_call?(node, block_var)
122
+ method = node.children[1]
123
+ @issues << create_issue(
124
+ file_path: @file_path,
125
+ line_number: node.loc.line,
126
+ message: "Model method `.#{method}` contains a query and is called inside iteration",
127
+ suggestion: "This method executes a query on each iteration. Preload data or move the query outside."
128
+ )
129
+ end
130
+ node.children.each { |child| check_block_for_model_query_methods(child, block_var) }
131
+ end
132
+
133
+ def model_query_call?(node, block_var)
134
+ return false unless node.type == :send
135
+
136
+ receiver = node.children[0]
137
+ method = node.children[1]
138
+ return false unless receiver&.type == :lvar && receiver.children[0] == block_var
139
+
140
+ @method_queries&.any? { |_model, methods| methods.include?(method) }
141
+ end
142
+
115
143
  def add_issue(node)
116
144
  chain = reconstruct_chain(node.children[0])
117
145
  @issues << create_issue(
@@ -14,10 +14,11 @@ module EagerEye
14
14
  :decorator_n_plus_one
15
15
  end
16
16
 
17
- def detect(ast, file_path, association_names = Set.new)
17
+ def detect(ast, file_path, association_names = Set.new, method_queries = {})
18
18
  return [] unless ast
19
19
 
20
20
  @dynamic_associations = association_names
21
+ @method_queries = method_queries
21
22
  issues = []
22
23
  traverse_ast(ast) do |node|
23
24
  next unless node.type == :class && decorator_class?(node)
@@ -65,21 +66,34 @@ module EagerEye
65
66
 
66
67
  receiver = node.children[0]
67
68
  method_name = node.children[1]
68
- next unless object_reference?(receiver) && likely_association?(method_name)
69
+ next unless object_reference?(receiver)
69
70
 
70
71
  ref = receiver.children[1]
71
- issues << create_issue(
72
- file_path: file_path,
73
- line_number: node.loc.line,
74
- message: "N+1 in decorator: `#{ref}.#{method_name}` loads association on each decorated object",
75
- suggestion: "Eager load :#{method_name} in the controller before decorating the collection"
76
- )
72
+ if likely_association?(method_name)
73
+ issues << create_issue(
74
+ file_path: file_path,
75
+ line_number: node.loc.line,
76
+ message: "N+1 in decorator: `#{ref}.#{method_name}` loads association on each decorated object",
77
+ suggestion: "Eager load :#{method_name} in the controller before decorating the collection"
78
+ )
79
+ elsif model_query_method?(method_name)
80
+ issues << create_issue(
81
+ file_path: file_path,
82
+ line_number: node.loc.line,
83
+ message: "N+1 in decorator: `#{ref}.#{method_name}` calls a query method defined in the model",
84
+ suggestion: "Preload the data in the controller before decorating the collection"
85
+ )
86
+ end
77
87
  end
78
88
  end
79
89
 
80
90
  def object_reference?(node)
81
91
  node&.type == :send && node.children[0].nil? && OBJECT_REFS.include?(node.children[1])
82
92
  end
93
+
94
+ def model_query_method?(method_name)
95
+ @method_queries&.any? { |_model, methods| methods.include?(method_name) }
96
+ end
83
97
  end
84
98
  end
85
99
  end
@@ -4,6 +4,7 @@ module EagerEye
4
4
  module Detectors
5
5
  class LoopAssociation < Base
6
6
  ITERATION_METHODS = %i[each map collect select find find_all reject filter filter_map flat_map
7
+ each_with_index each_with_object reduce inject
7
8
  find_each find_in_batches in_batches].freeze
8
9
  PRELOAD_METHODS = %i[includes preload eager_load].freeze
9
10
  SINGLE_RECORD_METHODS = %i[find find_by find_by! first first! last last! take take! second third fourth fifth
@@ -30,18 +31,19 @@ module EagerEye
30
31
  :loop_association
31
32
  end
32
33
 
33
- def detect(ast, file_path, association_preloads = {}, association_names = Set.new)
34
+ def detect(ast, file_path, association_preloads = {}, association_names = Set.new, method_queries = {})
34
35
  return [] unless ast
35
36
 
36
37
  issues = []
37
38
  @association_preloads = association_preloads
38
39
  @dynamic_associations = association_names
40
+ @method_queries = method_queries
39
41
  build_variable_maps(ast)
40
42
 
41
43
  traverse_ast(ast) do |node|
42
44
  next unless iteration_block?(node)
43
45
 
44
- block_var = extract_block_variable(node)
46
+ block_var = extract_iteration_variable(node)
45
47
  next unless block_var
46
48
 
47
49
  block_body = node.children[2]
@@ -157,15 +159,23 @@ module EagerEye
157
159
  def find_association_calls(node, block_var, file_path, issues, included_associations = Set.new)
158
160
  reported = Set.new
159
161
  traverse_ast(node) do |child|
160
- next unless reportable_association_call?(child, block_var, reported, included_associations)
161
-
162
- method = child.children[1]
163
- issues << create_issue(
164
- file_path: file_path,
165
- line_number: child.loc.line,
166
- message: "Potential N+1 query: `#{block_var}.#{method}` called inside loop",
167
- suggestion: "Use `includes(:#{method})` before iterating"
168
- )
162
+ if reportable_association_call?(child, block_var, reported, included_associations)
163
+ method = child.children[1]
164
+ issues << create_issue(
165
+ file_path: file_path,
166
+ line_number: child.loc.line,
167
+ message: "Potential N+1 query: `#{block_var}.#{method}` called inside loop",
168
+ suggestion: "Use `includes(:#{method})` before iterating"
169
+ )
170
+ elsif reportable_method_query_call?(child, block_var, reported)
171
+ method = child.children[1]
172
+ issues << create_issue(
173
+ file_path: file_path,
174
+ line_number: child.loc.line,
175
+ message: "Potential N+1 query: `#{block_var}.#{method}` calls a query method defined in the model",
176
+ suggestion: "Preload the data or restructure to avoid per-record queries"
177
+ )
178
+ end
169
179
  end
170
180
  end
171
181
 
@@ -189,6 +199,18 @@ module EagerEye
189
199
  def known_association?(method)
190
200
  ASSOCIATION_NAMES.include?(method.to_s) || @dynamic_associations.include?(method)
191
201
  end
202
+
203
+ def reportable_method_query_call?(node, block_var, reported)
204
+ return false unless block_var_send?(node, block_var)
205
+ return false if EXCLUDED_METHODS.include?(node.children[1])
206
+ return false unless @method_queries&.any? { |_, ms| ms.include?(node.children[1]) }
207
+
208
+ reported.add?("#{node.loc.line}:#{node.children[1]}")
209
+ end
210
+
211
+ def block_var_send?(node, block_var)
212
+ node.type == :send && node.children[0]&.type == :lvar && node.children[0].children[0] == block_var
213
+ end
192
214
  end
193
215
  end
194
216
  end
@@ -15,10 +15,11 @@ module EagerEye
15
15
  :serializer_nesting
16
16
  end
17
17
 
18
- def detect(ast, file_path, association_names = Set.new)
18
+ def detect(ast, file_path, association_names = Set.new, method_queries = {})
19
19
  return [] unless ast
20
20
 
21
21
  @dynamic_associations = association_names
22
+ @method_queries = method_queries
22
23
  issues = []
23
24
  traverse_ast(ast) do |node|
24
25
  next unless node.type == :class && serializer_class?(node)
@@ -86,14 +87,23 @@ module EagerEye
86
87
 
87
88
  receiver = node.children[0]
88
89
  method_name = node.children[1]
89
- next unless object_reference?(receiver) && likely_association?(method_name)
90
-
91
- issues << create_issue(
92
- file_path: file_path,
93
- line_number: node.loc.line,
94
- message: "Nested association `#{receiver_name(receiver)}.#{method_name}` in serializer attribute",
95
- suggestion: "Eager load :#{method_name} in controller or use association serializer"
96
- )
90
+ next unless object_reference?(receiver)
91
+
92
+ if likely_association?(method_name)
93
+ issues << create_issue(
94
+ file_path: file_path,
95
+ line_number: node.loc.line,
96
+ message: "Nested association `#{receiver_name(receiver)}.#{method_name}` in serializer attribute",
97
+ suggestion: "Eager load :#{method_name} in controller or use association serializer"
98
+ )
99
+ elsif model_query_method?(method_name)
100
+ issues << create_issue(
101
+ file_path: file_path,
102
+ line_number: node.loc.line,
103
+ message: "Model method `#{receiver_name(receiver)}.#{method_name}` contains a query in serializer",
104
+ suggestion: "This method executes a query per serialized object. Preload or cache the data."
105
+ )
106
+ end
97
107
  end
98
108
  end
99
109
 
@@ -114,6 +124,10 @@ module EagerEye
114
124
  else "object"
115
125
  end
116
126
  end
127
+
128
+ def model_query_method?(method_name)
129
+ @method_queries&.any? { |_model, methods| methods.include?(method_name) }
130
+ end
117
131
  end
118
132
  end
119
133
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EagerEye
4
+ class MethodQueryParser
5
+ QUERY_METHODS = %i[where find find_by find_by! exists? any? none? many? one?
6
+ count sum average minimum maximum
7
+ pluck ids select
8
+ update_all delete_all destroy_all
9
+ create create! save save!
10
+ first last take].freeze
11
+
12
+ ASSOCIATION_METHODS = %i[has_many has_one belongs_to has_and_belongs_to_many].freeze
13
+
14
+ attr_reader :method_queries
15
+
16
+ def initialize
17
+ @method_queries = {}
18
+ end
19
+
20
+ def parse_model(ast, model_name)
21
+ return unless ast
22
+
23
+ traverse(ast, model_name)
24
+ end
25
+
26
+ private
27
+
28
+ def traverse(node, model_name)
29
+ return unless node.is_a?(Parser::AST::Node)
30
+
31
+ check_method(node, model_name)
32
+ node.children.each { |child| traverse(child, model_name) }
33
+ end
34
+
35
+ def check_method(node, model_name)
36
+ return unless instance_method_def?(node)
37
+
38
+ method_name = node.children[0]
39
+ body = node.children[2]
40
+ return unless body && contains_query?(body)
41
+
42
+ @method_queries[model_name] ||= Set.new
43
+ @method_queries[model_name] << method_name
44
+ end
45
+
46
+ def instance_method_def?(node)
47
+ node.type == :def
48
+ end
49
+
50
+ def contains_query?(node)
51
+ return false unless node.is_a?(Parser::AST::Node)
52
+ return true if query_call?(node)
53
+
54
+ node.children.any? { |child| contains_query?(child) }
55
+ end
56
+
57
+ def query_call?(node)
58
+ node.type == :send && QUERY_METHODS.include?(node.children[1])
59
+ end
60
+ end
61
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EagerEye
4
- VERSION = "1.2.9"
4
+ VERSION = "1.2.11"
5
5
  end
data/lib/eager_eye.rb CHANGED
@@ -8,6 +8,7 @@ require_relative "eager_eye/association_parser"
8
8
  require_relative "eager_eye/delegation_parser"
9
9
  require_relative "eager_eye/scope_parser"
10
10
  require_relative "eager_eye/validation_parser"
11
+ require_relative "eager_eye/method_query_parser"
11
12
  require_relative "eager_eye/detectors/base"
12
13
  require_relative "eager_eye/detectors/loop_association"
13
14
  require_relative "eager_eye/detectors/serializer_nesting"
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.9
4
+ version: 1.2.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - hamzagedikkaya
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-03-16 00:00:00.000000000 Z
11
+ date: 2026-03-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ast
@@ -84,6 +84,7 @@ files:
84
84
  - lib/eager_eye/fixers/pluck_to_select.rb
85
85
  - lib/eager_eye/generators/install_generator.rb
86
86
  - lib/eager_eye/issue.rb
87
+ - lib/eager_eye/method_query_parser.rb
87
88
  - lib/eager_eye/railtie.rb
88
89
  - lib/eager_eye/reporters/base.rb
89
90
  - lib/eager_eye/reporters/console.rb