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 +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +1 -1
- data/lib/eager_eye/detectors/custom_method_query.rb +160 -12
- data/lib/eager_eye/detectors/loop_association.rb +238 -26
- data/lib/eager_eye/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 84a1f7a4156669772686b2138d5015bd82e5859ec09d5b1cce55b795a896c8fe
|
|
4
|
+
data.tar.gz: bc45520cd998ab972ee86c6654cd6ec111846eb417512eae4d588ace8135cab0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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)
|
data/lib/eager_eye/version.rb
CHANGED