eager_eye 1.2.14 → 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: 102d5299d625a885f2916d113d68a966cfb6c261bcebb8e5caf53f7d497c2a97
4
- data.tar.gz: 64e954f730dc260001a80540e6304aeed6c0838e6f003ea6c39dd166b93850ae
3
+ metadata.gz: 84a1f7a4156669772686b2138d5015bd82e5859ec09d5b1cce55b795a896c8fe
4
+ data.tar.gz: bc45520cd998ab972ee86c6654cd6ec111846eb417512eae4d588ace8135cab0
5
5
  SHA512:
6
- metadata.gz: 39a182c18271764a2258a2d5f416cd1c44dba5c1d18c11b74fd2ab6025d36569cf28778320cf66cb2c935f501e3310f8fdae7b74e550b6bdf24f9e28e2680c83
7
- data.tar.gz: 85297df8068df3aa88d2a69be379d8139850b82841cb98f90899e418fdff93cd360ea54f5c94a0d6722ab81b315cc3805c13bb66645d7f64a58e9f9dff6e826c
6
+ metadata.gz: c3a621919af0c95f005ba215d6db440504fa235e33ede88326ad1813f826f851e77bf20a7066200df993a18bc535c024142cc79930aab714b7db80995f2578f4
7
+ data.tar.gz: 86702c9c9ad9b27dcd4fb0a581bdab8864f7eda953b983b4bb7c3a07e6d5f581f39544c2ddfdc0fdb3f4b7c71d84be73df608d02302df7e1db51e4c4eaa4294e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,33 @@ 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
+
10
37
  ## [1.2.14] - 2026-05-01
11
38
 
12
39
  ### Fixed (false-positive reduction pass)
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.14-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>
@@ -4,7 +4,7 @@ require_relative "concerns/variable_model_inference"
4
4
 
5
5
  module EagerEye
6
6
  module Detectors
7
- class CustomMethodQuery < Base
7
+ class CustomMethodQuery < Base # rubocop:disable Metrics/ClassLength
8
8
  include Concerns::VariableModelInference
9
9
 
10
10
  QUERY_METHODS = %i[where find_by find_by! exists? find first last take pluck ids count sum average minimum
@@ -27,30 +27,178 @@ module EagerEye
27
27
  @file_path = file_path
28
28
  @method_queries = method_queries
29
29
  @associations_by_model = associations_by_model
30
- @variable_models = {}
31
30
 
32
- find_iteration_blocks(ast) do |block_body, block_var, collection, definitions|
33
- model_name = infer_model_from_value(collection)
34
- check_block_for_query_methods(block_body, block_var, collection_is_array?(collection, definitions))
35
- check_block_for_model_query_methods(block_body, block_var, model_name)
36
- 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, {})
37
36
 
38
37
  @issues
39
38
  end
40
39
 
41
40
  private
42
41
 
43
- 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)
163
+ return unless node.is_a?(Parser::AST::Node)
164
+
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)
44
176
  return unless node.is_a?(Parser::AST::Node)
45
177
 
46
- record_definition(node, definitions)
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)
47
193
 
48
- if iteration_block?(node)
49
194
  block_var = extract_iteration_variable(node)
50
195
  block_body = node.children[2]
51
- 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)
52
201
  end
53
- node.children.each { |child| find_iteration_blocks(child, definitions, &block) }
54
202
  end
55
203
 
56
204
  def record_definition(node, definitions)
@@ -43,33 +43,22 @@ module EagerEye
43
43
  method_queries = {}, associations_by_model = {})
44
44
  return [] unless ast
45
45
 
46
- issues = []
46
+ @issues = []
47
+ @file_path = file_path
47
48
  @association_preloads = association_preloads
48
49
  @dynamic_associations = association_names
49
50
  @method_queries = method_queries
50
51
  @associations_by_model = associations_by_model
51
- build_variable_maps(ast)
52
52
 
53
- traverse_ast(ast) do |node|
54
- next unless iteration_block?(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)
55
60
 
56
- block_var = extract_iteration_variable(node)
57
- next unless block_var
58
-
59
- block_body = node.children[2]
60
- next unless block_body
61
-
62
- collection_node = node.children[0]
63
- next if single_record_iteration?(collection_node)
64
-
65
- included = collect_included_associations(collection_node)
66
- model_name = infer_model_from_value(collection_node)
67
- skip_nodes = collect_non_loading_skip_set(block_body)
68
-
69
- find_association_calls(block_body, block_var, file_path, issues, included, model_name, skip_nodes)
70
- end
71
-
72
- issues
61
+ @issues
73
62
  end
74
63
 
75
64
  private
@@ -140,12 +129,235 @@ module EagerEye
140
129
  included.merge(@variable_preloads[key]) if @variable_preloads&.key?(key)
141
130
  end
142
131
 
143
- def build_variable_maps(ast)
144
- @variable_preloads = {}
145
- @variable_models = {}
146
- @single_record_variables = Set.new
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)
147
359
 
148
- traverse_ast(ast) { |node| process_variable_assignment(node) }
360
+ find_association_calls(block_body, block_var, @file_path, @issues, included, model_name, skip_nodes)
149
361
  end
150
362
 
151
363
  def process_variable_assignment(node)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EagerEye
4
- VERSION = "1.2.14"
4
+ VERSION = "1.2.15"
5
5
  end
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.14
4
+ version: 1.2.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - hamzagedikkaya