eager_eye 1.2.15 → 1.3.1

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.
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parser/current"
4
+
5
+ module EagerEye
6
+ # Serializers are the worst false-positive source because the detector sees the
7
+ # serializer class in isolation: it cannot tell whether an association it flags
8
+ # is actually eager-loaded by the controller that renders it, nor whether the
9
+ # serializer is only ever handed a single record (no collection => no N+1).
10
+ #
11
+ # This parser scans the whole app for render sites — `XxxBlueprint.render*(arg,
12
+ # view: :v)`, AMS `render json: arg, (each_)serializer: XxxSerializer` — and,
13
+ # per (serializer, view), records:
14
+ # * preloaded_per_site : the associations eager-loaded on `arg` at each site
15
+ # * any_collection : was `arg` ever a collection (vs a single record)?
16
+ # An association preloaded at EVERY site, or a serializer only ever fed single
17
+ # records, cannot cause an N+1 — letting the detector stay silent there.
18
+ class SerializerUsageParser
19
+ PRELOAD_METHODS = %i[includes preload eager_load].freeze
20
+ RELATION_WRAPPERS = %i[pagy paginate page kaminari with_pagy].freeze
21
+ SINGLE_RECORD_METHODS = %i[find find_by find_by! first first! last last! take take! sole find_sole_by
22
+ new build current_user current_account].freeze
23
+ RENDER_METHODS = %i[render render_as_hash render_as_json render_as_json! serialize].freeze
24
+ SERIALIZER_SUFFIXES = %w[Blueprint Serializer Resource].freeze
25
+
26
+ # serializer_basename => [ { view: sym_or_nil, preloaded: Set, collection: bool }, ... ]
27
+ attr_reader :usages
28
+
29
+ def initialize
30
+ @usages = Hash.new { |h, k| h[k] = [] }
31
+ end
32
+
33
+ def parse_file(ast)
34
+ return unless ast
35
+
36
+ each_scope(ast) do |body|
37
+ var_values = collect_assignments(body)
38
+ find_render_sites(body, var_values)
39
+ end
40
+ end
41
+
42
+ # Whether an association read in `view` of `serializer` can be proven safe.
43
+ # `view` is the Blueprinter view the field lives in (nil = a base/default
44
+ # field, rendered by every site). The field is safe when, at every render
45
+ # site that renders it, the association is eager-loaded OR the site passes a
46
+ # single record. To avoid hiding a genuine N+1 we never conclude "safe" when
47
+ # we cannot see the render sites for a named view (it may be rendered
48
+ # dynamically) — only an EXISTING, uniformly-safe set of sites suppresses.
49
+ def safe_access?(serializer, view, association)
50
+ sites = sites_rendering(serializer, view)
51
+ return false if sites.empty?
52
+
53
+ sites.all? { |s| !s[:collection] || s[:preloaded].include?(association) }
54
+ end
55
+
56
+ def known_serializer?(serializer)
57
+ @usages.key?(serializer)
58
+ end
59
+
60
+ private
61
+
62
+ # Sites that render a field of the given view. A base field (view nil) is
63
+ # rendered by every site. A named-view field is rendered by sites that
64
+ # explicitly request that view.
65
+ def sites_rendering(serializer, view)
66
+ sites = @usages[serializer]
67
+ return [] unless sites
68
+
69
+ view.nil? ? sites : sites.select { |s| s[:view] == view }
70
+ end
71
+
72
+ def each_scope(ast)
73
+ yield ast
74
+ collect_defs(ast).each { |d| yield(def_body(d)) }
75
+ end
76
+
77
+ def collect_defs(node, acc = [])
78
+ return acc unless node.is_a?(Parser::AST::Node)
79
+
80
+ node.children.each do |child|
81
+ next unless child.is_a?(Parser::AST::Node)
82
+
83
+ acc << child if %i[def defs].include?(child.type)
84
+ collect_defs(child, acc)
85
+ end
86
+ acc
87
+ end
88
+
89
+ def def_body(node)
90
+ node.type == :def ? node.children[2] : node.children[3]
91
+ end
92
+
93
+ def collect_assignments(body)
94
+ values = {}
95
+ walk(body) do |node|
96
+ case node.type
97
+ when :lvasgn, :ivasgn
98
+ values[node.children[0]] = node.children[1] if node.children[1]
99
+ when :masgn
100
+ collect_multi_assignment(node, values)
101
+ end
102
+ end
103
+ values
104
+ end
105
+
106
+ def collect_multi_assignment(node, values)
107
+ mlhs, rhs = node.children
108
+ return unless mlhs && rhs
109
+
110
+ mlhs.children.each do |t|
111
+ next unless %i[lvasgn ivasgn].include?(t&.type)
112
+
113
+ values[t.children[0]] = rhs
114
+ end
115
+ end
116
+
117
+ def find_render_sites(body, var_values)
118
+ walk(body) do |node|
119
+ next unless node.type == :send && RENDER_METHODS.include?(node.children[1])
120
+
121
+ serializer = serializer_name(node.children[0])
122
+ next unless serializer
123
+
124
+ arg = node.children[2]
125
+ view = view_option(node)
126
+ record_site(serializer, view, arg, var_values)
127
+ end
128
+ end
129
+
130
+ # `Foo::BarBlueprint.render_as_hash` => "BarBlueprint"; for Alba, the receiver
131
+ # is `BarResource.new(arg)` so peel the `.new`.
132
+ def serializer_name(recv)
133
+ return nil unless recv.is_a?(Parser::AST::Node)
134
+
135
+ const = recv.type == :send ? recv.children[0] : recv
136
+ return nil unless const.is_a?(Parser::AST::Node) && const.type == :const
137
+
138
+ name = const.children[1].to_s
139
+ SERIALIZER_SUFFIXES.any? { |s| name.end_with?(s) } ? name : nil
140
+ end
141
+
142
+ def view_option(node) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
143
+ node.children[3..].each do |arg|
144
+ next unless arg.is_a?(Parser::AST::Node) && arg.type == :hash
145
+
146
+ arg.children.each do |pair|
147
+ k = pair.children[0]
148
+ if k&.type == :sym && k.children[0] == :view && pair.children[1]&.type == :sym
149
+ return pair.children[1].children[0]
150
+ end
151
+ end
152
+ end
153
+ nil
154
+ end
155
+
156
+ def record_site(serializer, view, arg, var_values)
157
+ resolved = resolve_value(arg, var_values)
158
+ @usages[serializer] << {
159
+ view: view,
160
+ preloaded: extract_preloads(resolved, var_values),
161
+ collection: !single_record?(resolved, var_values)
162
+ }
163
+ end
164
+
165
+ # Resolve a local/ivar render arg to the expression it was assigned, so
166
+ # `render(user_points)` after `user_points = UserPoint.includes(:point)` is
167
+ # seen as the relation, not an opaque variable.
168
+ def resolve_value(node, var_values, depth = 0)
169
+ return node if depth > 5 || !node.is_a?(Parser::AST::Node)
170
+ return node unless %i[lvar ivar].include?(node.type)
171
+
172
+ assigned = var_values[node.children[0]]
173
+ assigned ? resolve_value(assigned, var_values, depth + 1) : node
174
+ end
175
+
176
+ def extract_preloads(node, var_values, depth = 0) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
177
+ preloads = Set.new
178
+ return preloads if depth > 8 || !node.is_a?(Parser::AST::Node)
179
+
180
+ current = node
181
+ while current.is_a?(Parser::AST::Node) && current.type == :send
182
+ current.children[2..].each { |a| collect_symbols(a, preloads) } if PRELOAD_METHODS.include?(current.children[1])
183
+ current.children[2..].each do |a|
184
+ if RELATION_WRAPPERS.include?(current.children[1])
185
+ preloads.merge(extract_preloads(resolve_value(a, var_values), var_values,
186
+ depth + 1))
187
+ end
188
+ end
189
+ current = current.children[0]
190
+ end
191
+ preloads
192
+ end
193
+
194
+ def collect_symbols(arg, set) # rubocop:disable Metrics/CyclomaticComplexity
195
+ case arg&.type
196
+ when :sym then set << arg.children[0]
197
+ when :array then arg.children.each { |c| collect_symbols(c, set) }
198
+ when :hash
199
+ arg.children.each do |pair|
200
+ key = pair.children[0]
201
+ set << key.children[0] if key&.type == :sym
202
+ collect_symbols(pair.children[1], set)
203
+ end
204
+ end
205
+ end
206
+
207
+ def single_record?(node, var_values, depth = 0)
208
+ return false if depth > 6 || !node.is_a?(Parser::AST::Node)
209
+
210
+ case node.type
211
+ when :send
212
+ method = node.children[1]
213
+ return true if SINGLE_RECORD_METHODS.include?(method)
214
+
215
+ single_record?(node.children[0], var_values, depth + 1)
216
+ when :array
217
+ node.children.size == 1
218
+ else
219
+ false
220
+ end
221
+ end
222
+
223
+ def walk(node, &block)
224
+ return unless node.is_a?(Parser::AST::Node)
225
+
226
+ yield node
227
+ node.children.each { |c| walk(c, &block) }
228
+ end
229
+ end
230
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EagerEye
4
- VERSION = "1.2.15"
4
+ VERSION = "1.3.1"
5
5
  end
data/lib/eager_eye.rb CHANGED
@@ -4,11 +4,14 @@ require "set"
4
4
  require_relative "eager_eye/version"
5
5
  require_relative "eager_eye/configuration"
6
6
  require_relative "eager_eye/issue"
7
+ require_relative "eager_eye/baseline"
7
8
  require_relative "eager_eye/association_parser"
8
9
  require_relative "eager_eye/delegation_parser"
9
10
  require_relative "eager_eye/scope_parser"
10
11
  require_relative "eager_eye/validation_parser"
11
12
  require_relative "eager_eye/method_query_parser"
13
+ require_relative "eager_eye/schema_parser"
14
+ require_relative "eager_eye/serializer_usage_parser"
12
15
  require_relative "eager_eye/detectors/base"
13
16
  require_relative "eager_eye/detectors/loop_association"
14
17
  require_relative "eager_eye/detectors/serializer_nesting"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: eager_eye
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.15
4
+ version: 1.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - hamzagedikkaya
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-01 00:00:00.000000000 Z
11
+ date: 2026-06-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ast
@@ -54,6 +54,7 @@ files:
54
54
  - CONTRIBUTING.md
55
55
  - LICENSE.txt
56
56
  - README.md
57
+ - README.tr.md
57
58
  - Rakefile
58
59
  - SECURITY.md
59
60
  - exe/eager_eye
@@ -61,6 +62,7 @@ files:
61
62
  - lib/eager_eye/analyzer.rb
62
63
  - lib/eager_eye/association_parser.rb
63
64
  - lib/eager_eye/auto_fixer.rb
65
+ - lib/eager_eye/baseline.rb
64
66
  - lib/eager_eye/cli.rb
65
67
  - lib/eager_eye/comment_parser.rb
66
68
  - lib/eager_eye/configuration.rb
@@ -94,7 +96,9 @@ files:
94
96
  - lib/eager_eye/reporters/json.rb
95
97
  - lib/eager_eye/rspec.rb
96
98
  - lib/eager_eye/rspec/matchers.rb
99
+ - lib/eager_eye/schema_parser.rb
97
100
  - lib/eager_eye/scope_parser.rb
101
+ - lib/eager_eye/serializer_usage_parser.rb
98
102
  - lib/eager_eye/validation_parser.rb
99
103
  - lib/eager_eye/version.rb
100
104
  - sig/eager_eye.rbs