eager_eye 1.2.9 → 1.2.10

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: 428a53d534da23f40ea61c600afe71dab1b3463eab4a0640a78502eb35dd2ebc
4
+ data.tar.gz: 004edfb6e51aa7e4708ff1731d1205fa336722995f440ac438e49535be7934b8
5
5
  SHA512:
6
- metadata.gz: 64d29ba207011df8fee0e11e6557de652ad5e3ed87c140e68cbedebb44988a477da0e2d5842fe28460811ebcc53db974f5403f4d73dd9de2653980a329e9a10c
7
- data.tar.gz: f1dc481bc8b85c6e03443edee9508216ffd111d49c09c0aabf021ef9250e42bc8a84ca95ab7fa6afb808ab9c041736fb75af40bbb8acdd2efd4f5ac060f879b7
6
+ metadata.gz: 3e1e172cc60a07b7f031be8236b626c4b1fc5d081ef201d952d6e366ffddeddbcc102332cd281714529a0f51b3a35df5195a567b32d596ccf9af97aff93eb28a
7
+ data.tar.gz: c52ac7fe9047f9cdb3cf8ba040cea3faf0f910f50e6f36561d20fc2728da1a41c0e442efbc7af4511e68744071c005fd1dd328cf5cac68bb4807e5220bfcbc51
data/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.10] - 2026-03-16
11
+
12
+ ### Added
13
+
14
+ - **Cross-File Analysis** - Model methods containing queries are now detected when called from other files
15
+ - New `MethodQueryParser` scans model files for methods that execute queries (`.where`, `.find_by`, `.count`, `.pluck`, etc.)
16
+ - `LoopAssociation` detects cross-file query methods called inside loops (e.g. `user.active_orders` in a controller)
17
+ - `CustomMethodQuery` detects model query methods called inside iterations
18
+ - `SerializerNesting` detects model query methods called in serializer attribute blocks
19
+ - `DecoratorNPlusOne` detects model query methods called in decorator methods
20
+ - Two-pass architecture: first pass collects method signatures, second pass detects cross-file N+1 patterns
21
+
10
22
  ## [1.2.9] - 2026-03-13
11
23
 
12
24
  ### 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.10-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
@@ -15,14 +15,16 @@ module EagerEye
15
15
  :custom_method_query
16
16
  end
17
17
 
18
- def detect(ast, file_path)
18
+ def detect(ast, file_path, method_queries = {})
19
19
  return [] unless ast
20
20
 
21
21
  @issues = []
22
22
  @file_path = file_path
23
+ @method_queries = method_queries
23
24
 
24
25
  find_iteration_blocks(ast) do |block_body, block_var, collection, definitions|
25
26
  check_block_for_query_methods(block_body, block_var, collection_is_array?(collection, definitions))
27
+ check_block_for_model_query_methods(block_body, block_var)
26
28
  end
27
29
 
28
30
  @issues
@@ -112,6 +114,31 @@ module EagerEye
112
114
  node.is_a?(Parser::AST::Node) && node.type == :send && QUERY_METHODS.include?(node.children[1])
113
115
  end
114
116
 
117
+ def check_block_for_model_query_methods(node, block_var)
118
+ return unless node.is_a?(Parser::AST::Node)
119
+
120
+ if model_query_call?(node, block_var)
121
+ method = node.children[1]
122
+ @issues << create_issue(
123
+ file_path: @file_path,
124
+ line_number: node.loc.line,
125
+ message: "Model method `.#{method}` contains a query and is called inside iteration",
126
+ suggestion: "This method executes a query on each iteration. Preload data or move the query outside."
127
+ )
128
+ end
129
+ node.children.each { |child| check_block_for_model_query_methods(child, block_var) }
130
+ end
131
+
132
+ def model_query_call?(node, block_var)
133
+ return false unless node.type == :send
134
+
135
+ receiver = node.children[0]
136
+ method = node.children[1]
137
+ return false unless receiver&.type == :lvar && receiver.children[0] == block_var
138
+
139
+ @method_queries&.any? { |_model, methods| methods.include?(method) }
140
+ end
141
+
115
142
  def add_issue(node)
116
143
  chain = reconstruct_chain(node.children[0])
117
144
  @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
@@ -30,12 +30,13 @@ module EagerEye
30
30
  :loop_association
31
31
  end
32
32
 
33
- def detect(ast, file_path, association_preloads = {}, association_names = Set.new)
33
+ def detect(ast, file_path, association_preloads = {}, association_names = Set.new, method_queries = {})
34
34
  return [] unless ast
35
35
 
36
36
  issues = []
37
37
  @association_preloads = association_preloads
38
38
  @dynamic_associations = association_names
39
+ @method_queries = method_queries
39
40
  build_variable_maps(ast)
40
41
 
41
42
  traverse_ast(ast) do |node|
@@ -157,15 +158,23 @@ module EagerEye
157
158
  def find_association_calls(node, block_var, file_path, issues, included_associations = Set.new)
158
159
  reported = Set.new
159
160
  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
- )
161
+ if reportable_association_call?(child, block_var, reported, included_associations)
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
+ )
169
+ elsif reportable_method_query_call?(child, block_var, reported)
170
+ method = child.children[1]
171
+ issues << create_issue(
172
+ file_path: file_path,
173
+ line_number: child.loc.line,
174
+ message: "Potential N+1 query: `#{block_var}.#{method}` calls a query method defined in the model",
175
+ suggestion: "Preload the data or restructure to avoid per-record queries"
176
+ )
177
+ end
169
178
  end
170
179
  end
171
180
 
@@ -189,6 +198,18 @@ module EagerEye
189
198
  def known_association?(method)
190
199
  ASSOCIATION_NAMES.include?(method.to_s) || @dynamic_associations.include?(method)
191
200
  end
201
+
202
+ def reportable_method_query_call?(node, block_var, reported)
203
+ return false unless block_var_send?(node, block_var)
204
+ return false if EXCLUDED_METHODS.include?(node.children[1])
205
+ return false unless @method_queries&.any? { |_, ms| ms.include?(node.children[1]) }
206
+
207
+ reported.add?("#{node.loc.line}:#{node.children[1]}")
208
+ end
209
+
210
+ def block_var_send?(node, block_var)
211
+ node.type == :send && node.children[0]&.type == :lvar && node.children[0].children[0] == block_var
212
+ end
192
213
  end
193
214
  end
194
215
  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.10"
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,7 +1,7 @@
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.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - hamzagedikkaya
@@ -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