eager_eye 1.2.13 → 1.2.15

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: c9b4195fa833057ee04ef87d148e78de8463fdbca97e234ba9d5b08ecaab9afc
4
- data.tar.gz: f5c45a109aba0f563866669c4b6a32ce0823990fe5de467d65f6baea8d405197
3
+ metadata.gz: 84a1f7a4156669772686b2138d5015bd82e5859ec09d5b1cce55b795a896c8fe
4
+ data.tar.gz: bc45520cd998ab972ee86c6654cd6ec111846eb417512eae4d588ace8135cab0
5
5
  SHA512:
6
- metadata.gz: 8312903e2476381ad7d9fe8a5a22feb74eb97937c5d488efc96448df9b0f5973f9d612c35cccd5235900e2eb25011694c862bb1a45a5bdeb70b2a8e17be155e2
7
- data.tar.gz: cc7b4336007a72b0d4131b311a2897d4dae9cf0b4ba18ceb4826f25931875f418067bd5a16e6d1fec472cbb29abd7d1f2d2f0b58cc943b871fdea4e9991fa623
6
+ metadata.gz: c3a621919af0c95f005ba215d6db440504fa235e33ede88326ad1813f826f851e77bf20a7066200df993a18bc535c024142cc79930aab714b7db80995f2578f4
7
+ data.tar.gz: 86702c9c9ad9b27dcd4fb0a581bdab8864f7eda953b983b4bb7c3a07e6d5f581f39544c2ddfdc0fdb3f4b7c71d84be73df608d02302df7e1db51e4c4eaa4294e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.15] - 2026-05-01
11
+
12
+ ### Fixed
13
+
14
+ - **Per-method scope for variable preload tracking** — `LoopAssociation` and
15
+ `CustomMethodQuery` now process each `:def`/`:defs` body as an independent
16
+ scope. Previously a later assignment in another method (e.g.
17
+ `invoices = Invoice.includes(:invoice_items).where(...)` inside one action)
18
+ could overwrite the preload set for the same variable name in an earlier
19
+ action, producing false N+1 warnings on associations that were actually
20
+ preloaded. Each scope now inherits a snapshot from the enclosing scope but
21
+ its own writes do not leak back out.
22
+ - **Caller-method preload tracking** — `LoopAssociation` and
23
+ `CustomMethodQuery` now propagate preload/model context across method calls
24
+ within the same class. When `def index` does
25
+ `items = Foo.includes(:bar); prepare_data(items)` and `def prepare_data(items)`
26
+ iterates the parameter, the `:bar` association is now correctly recognized
27
+ as preloaded. Previously every helper method that received a relation by
28
+ parameter produced false positives because the parameter had no preload
29
+ context.
30
+ - Sibling defs in the same scope are processed in two passes: first to capture
31
+ each `self`-call's argument context, then to seed the callee's parameters
32
+ before analyzing its body. Multiple call sites are merged permissively (any
33
+ caller preloading suppresses the warning) to avoid false positives at the
34
+ expense of potentially missing helpers that have BOTH preloaded and
35
+ unpreloaded callers.
36
+
37
+ ## [1.2.14] - 2026-05-01
38
+
39
+ ### Fixed (false-positive reduction pass)
40
+
41
+ - **Pagy / multi-assignment preload tracking** — `LoopAssociation` and `CustomMethodQuery` now follow preloads through `@pagy, items = pagy(query)` patterns and through wrapper calls like `pagy(...)`, `paginate(...)`, etc. Previously the `.includes(...)` on the wrapped query was lost across the multi-assign, generating false N+1 warnings on every preloaded association.
42
+ - **Preloads through conditional / variable chains** — `extract_included_associations_deep` now walks ternary branches and inlined begin-blocks, and resolves `:lvar`/`:ivar` receivers via the variable preload map. `base = cond ? Q.includes(...).where(...) : Q.none` no longer drops preloads.
43
+ - **Per-model association scoping** — `LoopAssociation` now infers the model class of the iteration variable and, when known, only flags methods that are actually associations on that model. This eliminates false positives where column accessors collide with hardcoded names (e.g. `log.tag`, `log.image`) or with associations defined on unrelated models.
44
+ - **Per-model `method_queries` lookup** — `LoopAssociation` and `CustomMethodQuery` no longer flag `obj.foo` just because *some other* model defines a `def foo` containing a query. The match is scoped to the receiver's model when known, and association names on that model are excluded from the query-method check.
45
+ - **`update_all` / `delete_all` / `destroy_all` chains** — `LoopAssociation` no longer flags association access whose only purpose is a non-loading terminal write (e.g. `avm.merchant_branches.update_all(...)`), since these don't trigger a SELECT for the association.
46
+ - **`PluckToArray` `.where(...).all.pluck(:id)` mis-classification** — only an *unscoped* `.all.pluck(:id)` is escalated to error severity ("loads entire table"); chains scoped by `where`/`joins`/`limit`/etc. are no longer misreported as table-scans.
47
+
48
+ ### Changed
49
+
50
+ - `AssociationParser` now exposes `associations_by_model` (a per-model `Set` of association names). Models with no declared associations are still registered, so detectors can distinguish "no associations" from "model unknown".
51
+ - Detector signatures: `LoopAssociation#detect` and `CustomMethodQuery#detect` accept an `associations_by_model` argument (defaults to `{}`).
52
+
53
+ ## [1.2.13] - 2026-03-23
54
+
55
+ ### Fixed
56
+
57
+ - Fixed gem packaging issue where `CountToSize` and `AddIncludes` auto-fixer files were missing from the published gem
58
+
10
59
  ## [1.2.12] - 2026-03-20
11
60
 
12
61
  ### 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.12-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.15-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>
@@ -19,24 +19,25 @@ module EagerEye
19
19
  }.freeze
20
20
 
21
21
  DETECTOR_EXTRA_ARGS = {
22
- Detectors::LoopAssociation => %i[association_preloads association_names method_queries],
22
+ Detectors::LoopAssociation => %i[association_preloads association_names method_queries associations_by_model],
23
23
  Detectors::SerializerNesting => %i[association_names method_queries],
24
24
  Detectors::MissingCounterCache => %i[association_names],
25
25
  Detectors::DecoratorNPlusOne => %i[association_names method_queries],
26
- Detectors::CustomMethodQuery => %i[method_queries],
26
+ Detectors::CustomMethodQuery => %i[method_queries associations_by_model],
27
27
  Detectors::DelegationNPlusOne => %i[delegation_maps],
28
28
  Detectors::ScopeChainNPlusOne => %i[scope_maps],
29
29
  Detectors::ValidationNPlusOne => %i[uniqueness_models]
30
30
  }.freeze
31
31
 
32
32
  attr_reader :paths, :issues, :association_preloads, :association_names, :method_queries, :delegation_maps,
33
- :scope_maps, :uniqueness_models
33
+ :scope_maps, :uniqueness_models, :associations_by_model
34
34
 
35
35
  def initialize(paths: nil)
36
36
  @paths = Array(paths || EagerEye.configuration.app_path)
37
37
  @issues = []
38
38
  @association_preloads = {}
39
39
  @association_names = Set.new
40
+ @associations_by_model = {}
40
41
  @method_queries = {}
41
42
  @delegation_maps = {}
42
43
  @scope_maps = {}
@@ -63,6 +64,9 @@ module EagerEye
63
64
  assoc_parser.parse_model(ast, model_name)
64
65
  @association_preloads.merge!(assoc_parser.preloaded_associations)
65
66
  @association_names.merge(assoc_parser.association_names)
67
+ assoc_parser.associations_by_model.each do |m, set|
68
+ (@associations_by_model[m] ||= Set.new).merge(set)
69
+ end
66
70
 
67
71
  deleg_parser = DelegationParser.new
68
72
  deleg_parser.parse_model(ast, model_name)
@@ -4,16 +4,20 @@ module EagerEye
4
4
  class AssociationParser
5
5
  ASSOCIATION_METHODS = %i[has_many has_one belongs_to has_and_belongs_to_many].freeze
6
6
 
7
- attr_reader :preloaded_associations, :association_names
7
+ attr_reader :preloaded_associations, :association_names, :associations_by_model
8
8
 
9
9
  def initialize
10
10
  @preloaded_associations = {}
11
11
  @association_names = Set.new
12
+ @associations_by_model = {}
12
13
  end
13
14
 
14
15
  def parse_model(ast, model_name)
15
16
  return unless ast
16
17
 
18
+ # Register the model even if it declares no associations, so callers can
19
+ # distinguish "model has no associations" from "model unknown to parser".
20
+ @associations_by_model[model_name] ||= Set.new
17
21
  traverse(ast, model_name)
18
22
  end
19
23
 
@@ -37,6 +41,7 @@ module EagerEye
37
41
  return unless association_name
38
42
 
39
43
  @association_names << association_name
44
+ (@associations_by_model[model_name] ||= Set.new) << association_name
40
45
 
41
46
  preloaded = extract_preloaded_associations(node)
42
47
  return if preloaded.empty?
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EagerEye
4
+ module Detectors
5
+ module Concerns
6
+ # Tracks the inferred model class behind a local/instance variable, by
7
+ # walking AST value-nodes down to a `:const` (e.g. `User.where(...)` →
8
+ # `"User"`). Recurses into argument lists of relation-wrapper methods
9
+ # (pagy, paginate, ...) so that `pagy(User.includes(...))` is still
10
+ # recognized as a User-relation.
11
+ module VariableModelInference
12
+ # Methods whose first positional argument is the underlying relation
13
+ # (Pagy/Kaminari/etc.). Walking into those args lets us see through
14
+ # the wrapper to the source query.
15
+ RELATION_WRAPPERS = %i[pagy paginate page kaminari with_pagy].freeze
16
+
17
+ # LHS names in `@pagy, items = pagy(query)`-style assignments that
18
+ # should not inherit the relation's model — they hold pagination
19
+ # metadata, not records.
20
+ PAGINATION_META_NAMES = %i[pagy paginator meta page_info pagination].freeze
21
+
22
+ private
23
+
24
+ def infer_model_from_value(node, depth = 0) # rubocop:disable Metrics/CyclomaticComplexity
25
+ return nil if depth > 10 || !node.is_a?(Parser::AST::Node)
26
+
27
+ case node.type
28
+ when :const then node.children[1].to_s
29
+ when :send then infer_model_from_send(node, depth)
30
+ when :lvar, :ivar then variable_model_for(node)
31
+ when :if then infer_model_from_branches(node, depth)
32
+ when :begin then infer_model_from_first(node.children, depth)
33
+ end
34
+ end
35
+
36
+ def infer_model_from_send(node, depth)
37
+ method = node.children[1]
38
+ if RELATION_WRAPPERS.include?(method) && node.children[2]
39
+ from_arg = infer_model_from_value(node.children[2], depth + 1)
40
+ return from_arg if from_arg
41
+ end
42
+ infer_model_from_value(node.children[0], depth + 1)
43
+ end
44
+
45
+ def infer_model_from_branches(node, depth)
46
+ infer_model_from_value(node.children[1], depth + 1) ||
47
+ infer_model_from_value(node.children[2], depth + 1)
48
+ end
49
+
50
+ def infer_model_from_first(children, depth)
51
+ children.each do |child|
52
+ result = infer_model_from_value(child, depth + 1)
53
+ return result if result
54
+ end
55
+ nil
56
+ end
57
+
58
+ def variable_model_for(node)
59
+ @variable_models&.[]([node.type == :ivar ? :ivar : :lvar, node.children[0]])
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -1,8 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "concerns/variable_model_inference"
4
+
3
5
  module EagerEye
4
6
  module Detectors
5
- class CustomMethodQuery < Base
7
+ class CustomMethodQuery < Base # rubocop:disable Metrics/ClassLength
8
+ include Concerns::VariableModelInference
9
+
6
10
  QUERY_METHODS = %i[where find_by find_by! exists? find first last take pluck ids count sum average minimum
7
11
  maximum].freeze
8
12
  SAFE_QUERY_METHODS = %i[first last take count sum find size length ids].freeze
@@ -16,34 +20,222 @@ module EagerEye
16
20
  :custom_method_query
17
21
  end
18
22
 
19
- def detect(ast, file_path, method_queries = {})
23
+ def detect(ast, file_path, method_queries = {}, associations_by_model = {})
20
24
  return [] unless ast
21
25
 
22
26
  @issues = []
23
27
  @file_path = file_path
24
28
  @method_queries = method_queries
29
+ @associations_by_model = associations_by_model
25
30
 
26
- find_iteration_blocks(ast) do |block_body, block_var, collection, definitions|
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)
29
- end
31
+ # Process each method def as its own scope so variable models from one
32
+ # method don't bleed into another (e.g. `orders = Order.all` in #index
33
+ # vs `orders = Foo.where(...).all` in #report — global tracking would
34
+ # mis-attribute the iteration variable's class across scopes).
35
+ process_scope(ast, {})
30
36
 
31
37
  @issues
32
38
  end
33
39
 
34
40
  private
35
41
 
36
- def find_iteration_blocks(node, definitions = {}, &block)
42
+ def process_scope(scope_node, definitions)
43
+ @variable_models ||= {}
44
+ scope_body = scope_body_for(scope_node)
45
+ return unless scope_body
46
+
47
+ find_iteration_blocks_in_scope(scope_body, definitions)
48
+
49
+ process_nested_defs(scope_body, definitions)
50
+ end
51
+
52
+ def process_nested_defs(scope_body, definitions)
53
+ nested_defs = []
54
+ each_nested_def(scope_body) { |d| nested_defs << d }
55
+ return if nested_defs.empty?
56
+
57
+ call_sites_by_callee = collect_sibling_call_sites(nested_defs)
58
+
59
+ nested_defs.each do |nested_def|
60
+ with_scope_snapshot do
61
+ seed_params_from_callers(nested_def, call_sites_by_callee)
62
+ process_scope(nested_def, definitions.dup)
63
+ end
64
+ end
65
+ end
66
+
67
+ # See LoopAssociation#collect_sibling_call_sites for the rationale.
68
+ def collect_sibling_call_sites(nested_defs)
69
+ sibling_names = nested_defs.filter_map { |d| def_name(d) }.to_set
70
+ result = Hash.new { |h, k| h[k] = [] }
71
+
72
+ nested_defs.each do |def_node|
73
+ def_body = scope_body_for(def_node)
74
+ next unless def_body
75
+
76
+ with_scope_snapshot do
77
+ each_node_in_scope(def_body) do |node|
78
+ record_definition(node, {})
79
+ next unless self_send_to_sibling?(node, sibling_names)
80
+
81
+ result[node.children[1]] << {
82
+ args: node.children[2..],
83
+ models: @variable_models.dup
84
+ }
85
+ end
86
+ end
87
+ end
88
+
89
+ result
90
+ end
91
+
92
+ def self_send_to_sibling?(node, sibling_names)
93
+ return false unless node.type == :send
94
+
95
+ receiver = node.children[0]
96
+ return false unless receiver.nil? || (receiver.is_a?(Parser::AST::Node) && receiver.type == :self)
97
+
98
+ sibling_names.include?(node.children[1])
99
+ end
100
+
101
+ def def_name(def_node)
102
+ case def_node.type
103
+ when :def then def_node.children[0]
104
+ when :defs then def_node.children[1]
105
+ end
106
+ end
107
+
108
+ def seed_params_from_callers(def_node, call_sites_by_callee)
109
+ return unless def_node.type == :def
110
+
111
+ call_sites = call_sites_by_callee[def_name(def_node)]
112
+ return if call_sites.nil? || call_sites.empty?
113
+
114
+ extract_param_names(def_node).each_with_index do |param_name, idx|
115
+ model = first_arg_model(call_sites, idx)
116
+ @variable_models[[:lvar, param_name]] = model if model
117
+ end
118
+ end
119
+
120
+ def first_arg_model(call_sites, idx)
121
+ call_sites.each do |site|
122
+ arg = site[:args][idx]
123
+ next unless arg
124
+
125
+ saved = @variable_models
126
+ @variable_models = site[:models]
127
+ model = infer_model_from_value(arg)
128
+ @variable_models = saved
129
+ return model if model
130
+ end
131
+ nil
132
+ end
133
+
134
+ def extract_param_names(def_node)
135
+ args_node = def_node.children[1]
136
+ return [] unless args_node.is_a?(Parser::AST::Node) && args_node.type == :args
137
+
138
+ args_node.children.filter_map do |arg|
139
+ next unless arg.is_a?(Parser::AST::Node) && %i[arg optarg kwarg kwoptarg].include?(arg.type)
140
+
141
+ arg.children[0]
142
+ end
143
+ end
144
+
145
+ def scope_body_for(node)
146
+ return node unless node.is_a?(Parser::AST::Node)
147
+
148
+ case node.type
149
+ when :def then node.children[2]
150
+ when :defs then node.children[3]
151
+ else node
152
+ end
153
+ end
154
+
155
+ def with_scope_snapshot
156
+ saved_models = @variable_models.dup
157
+ yield
158
+ ensure
159
+ @variable_models = saved_models
160
+ end
161
+
162
+ def each_node_in_scope(node, &block)
37
163
  return unless node.is_a?(Parser::AST::Node)
38
164
 
39
- definitions[node.children[0]] = node.children[1] if node.type == :lvasgn
165
+ yield node
166
+
167
+ node.children.each do |child|
168
+ next unless child.is_a?(Parser::AST::Node)
169
+ next if %i[def defs].include?(child.type)
170
+
171
+ each_node_in_scope(child, &block)
172
+ end
173
+ end
174
+
175
+ def each_nested_def(node, &block)
176
+ return unless node.is_a?(Parser::AST::Node)
177
+
178
+ node.children.each do |child|
179
+ next unless child.is_a?(Parser::AST::Node)
180
+
181
+ if %i[def defs].include?(child.type)
182
+ yield child
183
+ else
184
+ each_nested_def(child, &block)
185
+ end
186
+ end
187
+ end
188
+
189
+ def find_iteration_blocks_in_scope(scope_body, definitions)
190
+ each_node_in_scope(scope_body) do |node|
191
+ record_definition(node, definitions)
192
+ next unless iteration_block?(node)
40
193
 
41
- if iteration_block?(node)
42
194
  block_var = extract_iteration_variable(node)
43
195
  block_body = node.children[2]
44
- yield(block_body, block_var, node.children[0], definitions) if block_var && block_body
196
+ next unless block_var && block_body
197
+
198
+ model_name = infer_model_from_value(node.children[0])
199
+ check_block_for_query_methods(block_body, block_var, collection_is_array?(node.children[0], definitions))
200
+ check_block_for_model_query_methods(block_body, block_var, model_name)
201
+ end
202
+ end
203
+
204
+ def record_definition(node, definitions)
205
+ case node.type
206
+ when :lvasgn then record_simple_definition(node, :lvar, definitions)
207
+ when :ivasgn then record_simple_definition(node, :ivar, nil)
208
+ when :masgn then record_multi_definition(node, definitions)
45
209
  end
46
- node.children.each { |child| find_iteration_blocks(child, definitions, &block) }
210
+ end
211
+
212
+ def record_simple_definition(node, var_type, definitions)
213
+ name = node.children[0]
214
+ value = node.children[1]
215
+ return unless name && value
216
+
217
+ definitions[name] = value if definitions
218
+ model = infer_model_from_value(value)
219
+ @variable_models[[var_type, name]] = model if model
220
+ end
221
+
222
+ def record_multi_definition(node, definitions)
223
+ mlhs, rhs = node.children
224
+ return unless mlhs && rhs
225
+
226
+ model = infer_model_from_value(rhs)
227
+ mlhs.children.each { |target| record_multi_target(target, rhs, model, definitions) }
228
+ end
229
+
230
+ def record_multi_target(target, rhs, model, definitions)
231
+ return unless %i[lvasgn ivasgn].include?(target&.type)
232
+
233
+ tname = target.children[0]
234
+ return if PAGINATION_META_NAMES.include?(tname)
235
+
236
+ var_type = target.type == :lvasgn ? :lvar : :ivar
237
+ @variable_models[[var_type, tname]] = model if model
238
+ definitions[tname] = rhs if target.type == :lvasgn
47
239
  end
48
240
 
49
241
  def iteration_block?(node)
@@ -115,10 +307,10 @@ module EagerEye
115
307
  node.is_a?(Parser::AST::Node) && node.type == :send && QUERY_METHODS.include?(node.children[1])
116
308
  end
117
309
 
118
- def check_block_for_model_query_methods(node, block_var)
310
+ def check_block_for_model_query_methods(node, block_var, model_name)
119
311
  return unless node.is_a?(Parser::AST::Node)
120
312
 
121
- if model_query_call?(node, block_var)
313
+ if model_query_call?(node, block_var, model_name)
122
314
  method = node.children[1]
123
315
  @issues << create_issue(
124
316
  file_path: @file_path,
@@ -127,17 +319,38 @@ module EagerEye
127
319
  suggestion: "This method executes a query on each iteration. Preload data or move the query outside."
128
320
  )
129
321
  end
130
- node.children.each { |child| check_block_for_model_query_methods(child, block_var) }
322
+ node.children.each { |child| check_block_for_model_query_methods(child, block_var, model_name) }
131
323
  end
132
324
 
133
- def model_query_call?(node, block_var)
134
- return false unless node.type == :send
325
+ def model_query_call?(node, block_var, model_name)
326
+ return false unless block_var_immediate_send?(node, block_var)
135
327
 
136
- receiver = node.children[0]
137
328
  method = node.children[1]
138
- return false unless receiver&.type == :lvar && receiver.children[0] == block_var
329
+ # If we know the receiver model AND the method is one of its
330
+ # associations, treat it as an association access — not a query method.
331
+ return false if association_on_model?(method, model_name)
332
+
333
+ method_defined_as_query?(method, model_name)
334
+ end
335
+
336
+ def block_var_immediate_send?(node, block_var)
337
+ node.type == :send &&
338
+ (receiver = node.children[0])&.type == :lvar &&
339
+ receiver.children[0] == block_var
340
+ end
341
+
342
+ def association_on_model?(method, model_name)
343
+ model_name && @associations_by_model&.dig(model_name)&.include?(method)
344
+ end
139
345
 
140
- @method_queries&.any? { |_model, methods| methods.include?(method) }
346
+ def method_defined_as_query?(method, model_name)
347
+ return false unless @method_queries
348
+
349
+ if model_name
350
+ @method_queries[model_name]&.include?(method) || false
351
+ else
352
+ @method_queries.any? { |_model, methods| methods.include?(method) }
353
+ end
141
354
  end
142
355
 
143
356
  def add_issue(node)
@@ -1,21 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "concerns/variable_model_inference"
4
+
3
5
  module EagerEye
4
6
  module Detectors
5
- class LoopAssociation < Base
7
+ class LoopAssociation < Base # rubocop:disable Metrics/ClassLength
8
+ include Concerns::VariableModelInference
9
+
6
10
  ITERATION_METHODS = %i[each map collect select find find_all reject filter filter_map flat_map
7
11
  each_with_index each_with_object reduce inject
8
12
  find_each find_in_batches in_batches array!].freeze
9
13
  PRELOAD_METHODS = %i[includes preload eager_load].freeze
10
14
  SINGLE_RECORD_METHODS = %i[find find_by find_by! first first! last last! take take! second third fourth fifth
11
15
  forty_two sole find_sole_by].freeze
16
+ # Terminal methods on an association that do NOT trigger a SELECT for
17
+ # loading the association — they translate directly to UPDATE/DELETE SQL
18
+ # against the association's foreign key.
19
+ NON_LOADING_TERMINAL_METHODS = %i[update_all delete_all destroy_all touch_all
20
+ increment_counter decrement_counter].freeze
12
21
  ASSOCIATION_NAMES = Set.new(%w[
13
- author user owner creator admin member customer client post article comment category tag
14
- parent company organization project task item order product account profile setting image
15
- avatar photo attachment document authors users owners creators admins members customers
16
- clients posts articles comments categories tags children companies organizations projects
17
- tasks items orders products accounts profiles settings images avatars photos attachments
18
- documents
22
+ author user owner creator admin member customer client post article comment category
23
+ parent company organization project task item order product account profile
24
+ avatar photo authors users owners creators admins members customers
25
+ clients posts articles comments categories children companies organizations projects
26
+ tasks items orders products accounts profiles avatars photos
19
27
  ]).freeze
20
28
  EXCLUDED_METHODS = %i[
21
29
  id to_s to_h to_a to_json to_xml inspect class object_id nil? blank? present? empty?
@@ -24,96 +32,381 @@ module EagerEye
24
32
  description value key type status state created_at updated_at deleted_at origin
25
33
  priority level kind label code reason amount price quantity url path email phone
26
34
  address notes memo data metadata position rank score rating enabled disabled active
27
- published draft archived locked visible hidden
35
+ published draft archived locked visible hidden tag image attachment document setting
28
36
  ].freeze
29
37
 
30
38
  def self.detector_name
31
39
  :loop_association
32
40
  end
33
41
 
34
- def detect(ast, file_path, association_preloads = {}, association_names = Set.new, method_queries = {})
42
+ def detect(ast, file_path, association_preloads = {}, association_names = Set.new, # rubocop:disable Metrics/ParameterLists
43
+ method_queries = {}, associations_by_model = {})
35
44
  return [] unless ast
36
45
 
37
- issues = []
46
+ @issues = []
47
+ @file_path = file_path
38
48
  @association_preloads = association_preloads
39
49
  @dynamic_associations = association_names
40
50
  @method_queries = method_queries
41
- build_variable_maps(ast)
42
-
43
- traverse_ast(ast) do |node|
44
- next unless iteration_block?(node)
45
-
46
- block_var = extract_iteration_variable(node)
47
- next unless block_var
48
-
49
- block_body = node.children[2]
50
- next unless block_body
51
+ @associations_by_model = associations_by_model
51
52
 
52
- collection_node = node.children[0]
53
- next if single_record_iteration?(collection_node)
53
+ # Variable preloads/models leak across methods if tracked globally
54
+ # (e.g. controller#index sets `invoices = Invoice.includes(...)`, then
55
+ # controller#auto_match overwrites with `invoices = Invoice.where(...)`
56
+ # — the second assignment would erase the first method's preload data).
57
+ # Process each method scope independently and inherit a snapshot from
58
+ # the enclosing scope (top-level / outer class body).
59
+ process_scope(ast)
54
60
 
55
- included = extract_included_associations(collection_node)
56
- included.merge(extract_variable_preloads(collection_node))
57
- included.merge(get_association_preloads(infer_model_name_from_collection(collection_node)))
58
-
59
- find_association_calls(block_body, block_var, file_path, issues, included)
60
- end
61
-
62
- issues
61
+ @issues
63
62
  end
64
63
 
65
64
  private
66
65
 
66
+ def collect_included_associations(collection_node)
67
+ included = extract_included_associations_deep(collection_node)
68
+ included.merge(extract_variable_preloads(collection_node))
69
+ included.merge(get_association_preloads(infer_model_from_value(collection_node)))
70
+ included
71
+ end
72
+
67
73
  def get_association_preloads(model_name)
68
74
  preloaded = Set.new
75
+ return preloaded unless model_name
76
+
69
77
  @association_preloads&.each do |key, assocs|
70
78
  preloaded.merge(assocs) if key.start_with?("#{model_name}#")
71
79
  end
72
80
  preloaded
73
81
  end
74
82
 
75
- def infer_model_name_from_collection(node)
76
- return nil unless node&.type == :send
77
-
78
- receiver = node.children[0]
79
- receiver.children[1].to_s if receiver&.type == :const
80
- end
81
-
82
83
  def iteration_block?(node)
83
84
  node.type == :block && node.children[0]&.type == :send &&
84
85
  ITERATION_METHODS.include?(node.children[0].children[1])
85
86
  end
86
87
 
87
- def extract_included_associations(collection_node)
88
+ # Extract :includes/:preload/:eager_load arguments from a value node,
89
+ # walking BOTH the receiver chain and any method arguments. The arg
90
+ # recursion is what lets us see through wrappers like pagy(query, ...).
91
+ def extract_included_associations_deep(value_node, depth = 0)
88
92
  included = Set.new
89
- return included unless collection_node&.type == :send
93
+ return included if depth > 10 || !value_node.is_a?(Parser::AST::Node)
90
94
 
91
- current = collection_node
92
- while current&.type == :send
95
+ current = walk_send_chain_for_preloads(value_node, included, depth)
96
+ merge_preloads_from_non_send(current, included, depth)
97
+ included
98
+ end
99
+
100
+ def walk_send_chain_for_preloads(node, included, depth)
101
+ current = node
102
+ while current.is_a?(Parser::AST::Node) && current.type == :send
93
103
  extract_includes_from_method(current, included) if PRELOAD_METHODS.include?(current.children[1])
104
+ current.children[2..].each do |arg|
105
+ included.merge(extract_included_associations_deep(arg, depth + 1))
106
+ end
94
107
  current = current.children[0]
95
108
  end
96
- included
109
+ current
97
110
  end
98
111
 
99
- def build_variable_maps(ast)
100
- @variable_preloads = {}
101
- @single_record_variables = Set.new
112
+ def merge_preloads_from_non_send(node, included, depth)
113
+ case node&.type
114
+ when :if then merge_branch_preloads(node, included, depth)
115
+ when :begin then node.children.each do |c|
116
+ included.merge(extract_included_associations_deep(c, depth + 1))
117
+ end
118
+ when :lvar, :ivar then merge_variable_preloads(node, included)
119
+ end
120
+ end
121
+
122
+ def merge_branch_preloads(node, included, depth)
123
+ included.merge(extract_included_associations_deep(node.children[1], depth + 1))
124
+ included.merge(extract_included_associations_deep(node.children[2], depth + 1))
125
+ end
126
+
127
+ def merge_variable_preloads(node, included)
128
+ key = [node.type == :ivar ? :ivar : :lvar, node.children[0]]
129
+ included.merge(@variable_preloads[key]) if @variable_preloads&.key?(key)
130
+ end
102
131
 
103
- traverse_ast(ast) { |node| process_variable_assignment(node) }
132
+ # Walk this scope's body, then recurse into nested method defs as fresh
133
+ # scopes. A method def inherits a snapshot of the enclosing scope's
134
+ # variable state (so top-level lets/instance vars stay visible), but its
135
+ # own changes do not leak back out. Nested defs are processed in two
136
+ # passes: first to collect call sites between siblings (so we know which
137
+ # arguments each method receives at call time), then to actually analyze
138
+ # each def with its parameters seeded from caller context.
139
+ def process_scope(scope_node)
140
+ @variable_preloads ||= {}
141
+ @variable_models ||= {}
142
+ @single_record_variables ||= Set.new
143
+
144
+ scope_body = scope_body_for(scope_node)
145
+ return unless scope_body
146
+
147
+ build_variable_maps_in_scope(scope_body)
148
+ find_iterations_in_scope(scope_body)
149
+
150
+ process_nested_defs(scope_body)
151
+ end
152
+
153
+ def process_nested_defs(scope_body)
154
+ nested_defs = collect_nested_defs(scope_body)
155
+ return if nested_defs.empty?
156
+
157
+ call_sites_by_callee = collect_sibling_call_sites(nested_defs)
158
+
159
+ nested_defs.each do |nested_def|
160
+ with_scope_snapshot do
161
+ seed_params_from_callers(nested_def, call_sites_by_callee)
162
+ process_scope(nested_def)
163
+ end
164
+ end
165
+ end
166
+
167
+ def collect_nested_defs(scope_body)
168
+ defs = []
169
+ each_nested_def(scope_body) { |d| defs << d }
170
+ defs
171
+ end
172
+
173
+ # For each sibling def in this class/module body, build its variable map
174
+ # in isolation and capture every self-send to ANOTHER sibling. The
175
+ # captured snapshot is the caller's variable state at the call site —
176
+ # later we re-evaluate the call's arg expressions against this snapshot
177
+ # to derive the callee's parameter contexts.
178
+ def collect_sibling_call_sites(nested_defs)
179
+ sibling_names = nested_defs.filter_map { |d| def_name(d) }.to_set
180
+ result = Hash.new { |h, k| h[k] = [] }
181
+
182
+ nested_defs.each do |def_node|
183
+ def_body = scope_body_for(def_node)
184
+ next unless def_body
185
+
186
+ with_scope_snapshot do
187
+ build_variable_maps_in_scope(def_body)
188
+ capture_calls_to_siblings(def_body, sibling_names, result)
189
+ end
190
+ end
191
+
192
+ result
193
+ end
194
+
195
+ def capture_calls_to_siblings(def_body, sibling_names, result)
196
+ each_node_in_scope(def_body) do |node|
197
+ next unless self_send_to_sibling?(node, sibling_names)
198
+
199
+ callee = node.children[1]
200
+ result[callee] << {
201
+ args: node.children[2..],
202
+ preloads: @variable_preloads.dup,
203
+ models: @variable_models.dup
204
+ }
205
+ end
206
+ end
207
+
208
+ def self_send_to_sibling?(node, sibling_names)
209
+ return false unless node.type == :send
210
+
211
+ receiver = node.children[0]
212
+ return false unless receiver.nil? || (receiver.is_a?(Parser::AST::Node) && receiver.type == :self)
213
+
214
+ sibling_names.include?(node.children[1])
215
+ end
216
+
217
+ def def_name(def_node)
218
+ case def_node.type
219
+ when :def then def_node.children[0]
220
+ when :defs then def_node.children[1]
221
+ end
222
+ end
223
+
224
+ # If `def_node` is called by sibling defs in this class, evaluate each
225
+ # call site's arguments in that caller's context and bind the resulting
226
+ # preloads/model to the callee's parameter names. This is what eliminates
227
+ # the "helper method receives a preloaded relation" false positive.
228
+ def seed_params_from_callers(def_node, call_sites_by_callee)
229
+ return unless def_node.type == :def
230
+
231
+ name = def_name(def_node)
232
+ call_sites = call_sites_by_callee[name]
233
+ return if call_sites.nil? || call_sites.empty?
234
+
235
+ param_names = extract_param_names(def_node)
236
+ param_names.each_with_index do |param_name, idx|
237
+ seed_single_param(param_name, idx, call_sites)
238
+ end
239
+ end
240
+
241
+ def seed_single_param(param_name, idx, call_sites)
242
+ merged_preloads = Set.new
243
+ chosen_model = nil
244
+
245
+ call_sites.each do |site|
246
+ arg = site[:args][idx]
247
+ next unless arg
248
+
249
+ with_call_site_context(site) do
250
+ merged_preloads.merge(extract_included_associations_deep(arg))
251
+ chosen_model ||= infer_model_from_value(arg)
252
+ end
253
+ end
254
+
255
+ key = [:lvar, param_name]
256
+ @variable_preloads[key] = merged_preloads unless merged_preloads.empty?
257
+ @variable_models[key] = chosen_model if chosen_model
258
+ end
259
+
260
+ def with_call_site_context(site)
261
+ saved_preloads = @variable_preloads
262
+ saved_models = @variable_models
263
+ @variable_preloads = site[:preloads]
264
+ @variable_models = site[:models]
265
+ yield
266
+ ensure
267
+ @variable_preloads = saved_preloads
268
+ @variable_models = saved_models
269
+ end
270
+
271
+ def extract_param_names(def_node)
272
+ args_node = def_node.children[1]
273
+ return [] unless args_node.is_a?(Parser::AST::Node) && args_node.type == :args
274
+
275
+ args_node.children.filter_map do |arg|
276
+ next unless arg.is_a?(Parser::AST::Node)
277
+ # Skip blockarg/restarg/kwrestarg etc. — only positional/optional/kwarg names.
278
+ next unless %i[arg optarg kwarg kwoptarg].include?(arg.type)
279
+
280
+ arg.children[0]
281
+ end
282
+ end
283
+
284
+ def scope_body_for(node)
285
+ return node unless node.is_a?(Parser::AST::Node)
286
+
287
+ case node.type
288
+ when :def then node.children[2]
289
+ when :defs then node.children[3]
290
+ else node
291
+ end
292
+ end
293
+
294
+ def with_scope_snapshot
295
+ saved_preloads = @variable_preloads.dup
296
+ saved_models = @variable_models.dup
297
+ saved_single = @single_record_variables.dup
298
+ yield
299
+ ensure
300
+ @variable_preloads = saved_preloads
301
+ @variable_models = saved_models
302
+ @single_record_variables = saved_single
303
+ end
304
+
305
+ # Yields every node inside `scope_body` but stops at any :def/:defs —
306
+ # those subtrees represent fresh scopes and are visited separately.
307
+ def each_node_in_scope(node, &block)
308
+ return unless node.is_a?(Parser::AST::Node)
309
+
310
+ yield node
311
+
312
+ node.children.each do |child|
313
+ next unless child.is_a?(Parser::AST::Node)
314
+ next if %i[def defs].include?(child.type)
315
+
316
+ each_node_in_scope(child, &block)
317
+ end
318
+ end
319
+
320
+ # Yields each immediately-nested :def/:defs (not deeper-nested ones —
321
+ # those are visited via that def's own process_scope call).
322
+ def each_nested_def(node, &block)
323
+ return unless node.is_a?(Parser::AST::Node)
324
+
325
+ node.children.each do |child|
326
+ next unless child.is_a?(Parser::AST::Node)
327
+
328
+ if %i[def defs].include?(child.type)
329
+ yield child
330
+ else
331
+ each_nested_def(child, &block)
332
+ end
333
+ end
334
+ end
335
+
336
+ def build_variable_maps_in_scope(scope_body)
337
+ each_node_in_scope(scope_body) { |node| process_variable_assignment(node) }
338
+ end
339
+
340
+ def find_iterations_in_scope(scope_body)
341
+ each_node_in_scope(scope_body) do |node|
342
+ process_iteration_block(node) if iteration_block?(node)
343
+ end
344
+ end
345
+
346
+ def process_iteration_block(node)
347
+ block_var = extract_iteration_variable(node)
348
+ return unless block_var
349
+
350
+ block_body = node.children[2]
351
+ return unless block_body
352
+
353
+ collection_node = node.children[0]
354
+ return if single_record_iteration?(collection_node)
355
+
356
+ included = collect_included_associations(collection_node)
357
+ model_name = infer_model_from_value(collection_node)
358
+ skip_nodes = collect_non_loading_skip_set(block_body)
359
+
360
+ find_association_calls(block_body, block_var, @file_path, @issues, included, model_name, skip_nodes)
104
361
  end
105
362
 
106
363
  def process_variable_assignment(node)
107
- return unless %i[lvasgn ivasgn].include?(node.type)
364
+ case node.type
365
+ when :lvasgn, :ivasgn then process_simple_assignment(node)
366
+ when :masgn then process_multi_assignment(node)
367
+ end
368
+ end
108
369
 
370
+ def process_simple_assignment(node)
109
371
  var_type = node.type == :lvasgn ? :lvar : :ivar
110
372
  var_name = node.children[0]
111
373
  value_node = node.children[1]
112
- return unless value_node
374
+ return unless value_node && var_name
375
+
376
+ record_variable(var_type, var_name, value_node)
377
+ end
113
378
 
379
+ def process_multi_assignment(node)
380
+ mlhs, rhs = node.children
381
+ return unless mlhs && rhs
382
+
383
+ # mlhs.children is a list of lvasgn/ivasgn (LHS targets). RHS is
384
+ # typically a method call like `pagy(query)` returning [meta, records],
385
+ # or an array literal. We can't statically know which target gets which
386
+ # slot, so apply preloads/model to every LHS — except names that look
387
+ # like pagination metadata.
388
+ mlhs.children.each { |target| record_multi_target(target, rhs) }
389
+ end
390
+
391
+ def record_multi_target(target, rhs)
392
+ return unless %i[lvasgn ivasgn].include?(target&.type)
393
+
394
+ name = target.children[0]
395
+ return if PAGINATION_META_NAMES.include?(name)
396
+
397
+ var_type = target.type == :lvasgn ? :lvar : :ivar
398
+ record_variable(var_type, name, rhs)
399
+ end
400
+
401
+ def record_variable(var_type, var_name, value_node)
114
402
  key = [var_type, var_name]
115
- preloaded = extract_included_associations(value_node)
403
+
404
+ preloaded = extract_included_associations_deep(value_node)
116
405
  @variable_preloads[key] = preloaded unless preloaded.empty?
406
+
407
+ model = infer_model_from_value(value_node)
408
+ @variable_models[key] = model if model
409
+
117
410
  @single_record_variables.add(key) if single_record_query?(value_node)
118
411
  end
119
412
 
@@ -156,10 +449,33 @@ module EagerEye
156
449
  included_set.merge(extract_symbols_from_args(extract_method_args(method_node)))
157
450
  end
158
451
 
159
- def find_association_calls(node, block_var, file_path, issues, included_associations = Set.new)
452
+ # Pre-scan: when an inner send like `block_var.assoc` is the receiver of a
453
+ # NON_LOADING_TERMINAL_METHODS call (e.g. `.update_all`), the assoc access
454
+ # does not trigger a SELECT. Track those receiver nodes so we don't flag them.
455
+ def collect_non_loading_skip_set(block_body)
456
+ skip = Set.new
457
+ traverse_ast(block_body) do |node|
458
+ next unless node.type == :send && NON_LOADING_TERMINAL_METHODS.include?(node.children[1])
459
+
460
+ receiver = node.children[0]
461
+ collect_chain_node_ids(receiver, skip)
462
+ end
463
+ skip
464
+ end
465
+
466
+ def collect_chain_node_ids(node, set)
467
+ return unless node.is_a?(Parser::AST::Node) && node.type == :send
468
+
469
+ set.add(node.object_id)
470
+ collect_chain_node_ids(node.children[0], set)
471
+ end
472
+
473
+ def find_association_calls(node, block_var, file_path, issues, included_associations, model_name, skip_nodes) # rubocop:disable Metrics/ParameterLists
160
474
  reported = Set.new
161
475
  traverse_ast(node) do |child|
162
- if reportable_association_call?(child, block_var, reported, included_associations)
476
+ next if skip_nodes.include?(child.object_id)
477
+
478
+ if reportable_association_call?(child, block_var, reported, included_associations, model_name)
163
479
  method = child.children[1]
164
480
  issues << create_issue(
165
481
  file_path: file_path,
@@ -167,7 +483,7 @@ module EagerEye
167
483
  message: "Potential N+1 query: `#{block_var}.#{method}` called inside loop",
168
484
  suggestion: "Use `includes(:#{method})` before iterating"
169
485
  )
170
- elsif reportable_method_query_call?(child, block_var, reported)
486
+ elsif reportable_method_query_call?(child, block_var, reported, model_name)
171
487
  method = child.children[1]
172
488
  issues << create_issue(
173
489
  file_path: file_path,
@@ -179,35 +495,56 @@ module EagerEye
179
495
  end
180
496
  end
181
497
 
182
- def reportable_association_call?(node, block_var, reported, included)
498
+ def reportable_association_call?(node, block_var, reported, included, model_name)
183
499
  return false unless node.type == :send
184
500
 
185
501
  receiver = node.children[0]
186
502
  method = node.children[1]
187
503
  return false unless receiver&.type == :lvar && receiver.children[0] == block_var
188
- return false if excluded_method?(method, included)
504
+ return false if excluded_method?(method, included, model_name)
189
505
 
190
506
  reported.add?("#{node.loc.line}:#{method}")
191
507
  end
192
508
 
193
- def excluded_method?(method, included)
509
+ def excluded_method?(method, included, model_name)
194
510
  EXCLUDED_METHODS.include?(method) ||
195
- !known_association?(method) ||
196
- included.include?(method)
511
+ included.include?(method) ||
512
+ !known_association?(method, model_name)
197
513
  end
198
514
 
199
- def known_association?(method)
515
+ # When we know the iteration variable's model AND have parsed that model's
516
+ # associations, trust that map exclusively — methods not in it are columns
517
+ # or scalar accessors, not associations. Otherwise fall back to heuristics
518
+ # (hardcoded common names + globally collected association names).
519
+ def known_association?(method, model_name)
520
+ if model_name && @associations_by_model&.key?(model_name)
521
+ return @associations_by_model[model_name].include?(method)
522
+ end
523
+
200
524
  ASSOCIATION_NAMES.include?(method.to_s) || @dynamic_associations.include?(method)
201
525
  end
202
526
 
203
- def reportable_method_query_call?(node, block_var, reported)
527
+ def reportable_method_query_call?(node, block_var, reported, model_name)
204
528
  return false unless block_var_send?(node, block_var)
205
529
  return false if EXCLUDED_METHODS.include?(node.children[1])
206
- return false unless @method_queries&.any? { |_, ms| ms.include?(node.children[1]) }
530
+ return false unless method_known_to_query?(node.children[1], model_name)
207
531
 
208
532
  reported.add?("#{node.loc.line}:#{node.children[1]}")
209
533
  end
210
534
 
535
+ # When the receiver model is known, only consider methods defined as a
536
+ # query on THAT model — not any model in the project. Without a known
537
+ # model, fall back to the global "any model has this method" heuristic.
538
+ def method_known_to_query?(method, model_name)
539
+ return false unless @method_queries
540
+
541
+ if model_name
542
+ @method_queries[model_name]&.include?(method) || false
543
+ else
544
+ @method_queries.any? { |_, ms| ms.include?(method) }
545
+ end
546
+ end
547
+
211
548
  def block_var_send?(node, block_var)
212
549
  node.type == :send && node.children[0]&.type == :lvar && node.children[0].children[0] == block_var
213
550
  end
@@ -4,10 +4,14 @@ require_relative "concerns/non_ar_source_detector"
4
4
 
5
5
  module EagerEye
6
6
  module Detectors
7
- class PluckToArray < Base
7
+ class PluckToArray < Base # rubocop:disable Metrics/ClassLength
8
8
  include Concerns::NonArSourceDetector
9
9
 
10
10
  SMALL_COLLECTIONS = %w[tags settings options categories roles permissions statuses types priorities].freeze
11
+ SCOPING_METHODS = %i[where not limit offset order group having distinct
12
+ joins left_joins left_outer_joins includes preload eager_load
13
+ lock select from references unscope merge or rewhere reorder regroup
14
+ in_order_of].freeze
11
15
 
12
16
  def self.detector_name
13
17
  :pluck_to_array
@@ -117,9 +121,31 @@ module EagerEye
117
121
  node.is_a?(Parser::AST::Node) && node.type == :send && %i[pluck ids].include?(node.children[1])
118
122
  end
119
123
 
124
+ # `.all.pluck(:id)` is "critical" only when the `.all` is unscoped — i.e.
125
+ # there's no `.where`/`.limit`/etc. earlier in the chain. Otherwise the
126
+ # query is already filtered and isn't loading the entire table.
120
127
  def all_pluck_call?(node)
121
- pluck_call?(node) && node.children[0].is_a?(Parser::AST::Node) &&
122
- node.children[0].type == :send && node.children[0].children[1] == :all
128
+ return false unless pluck_call?(node)
129
+
130
+ receiver = node.children[0]
131
+ return false unless receiver.is_a?(Parser::AST::Node) && receiver.type == :send
132
+ return false unless receiver.children[1] == :all
133
+
134
+ unscoped_all?(receiver)
135
+ end
136
+
137
+ def unscoped_all?(all_node)
138
+ receiver = all_node.children[0]
139
+ # `.all` with no receiver (just `all` in a model context) — treat as unscoped.
140
+ return true if receiver.nil?
141
+
142
+ while receiver.is_a?(Parser::AST::Node) && receiver.type == :send
143
+ return false if SCOPING_METHODS.include?(receiver.children[1])
144
+
145
+ receiver = receiver.children[0]
146
+ end
147
+
148
+ true
123
149
  end
124
150
 
125
151
  def small_collection_pluck?(node)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EagerEye
4
- VERSION = "1.2.13"
4
+ VERSION = "1.2.15"
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.13
4
+ version: 1.2.15
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-23 00:00:00.000000000 Z
11
+ date: 2026-05-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ast
@@ -69,6 +69,7 @@ files:
69
69
  - lib/eager_eye/detectors/callback_query.rb
70
70
  - lib/eager_eye/detectors/concerns/class_inspector.rb
71
71
  - lib/eager_eye/detectors/concerns/non_ar_source_detector.rb
72
+ - lib/eager_eye/detectors/concerns/variable_model_inference.rb
72
73
  - lib/eager_eye/detectors/count_in_iteration.rb
73
74
  - lib/eager_eye/detectors/custom_method_query.rb
74
75
  - lib/eager_eye/detectors/decorator_n_plus_one.rb