eager_eye 1.3.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b938ac5233658e3dcec2b8e6a45a9f33248673e8a0a615d52dbbdbcb8700a9b9
4
- data.tar.gz: dc0dff8a86a8381236e355bc65b00c589483272e3dbef07f9d8053ed2428f452
3
+ metadata.gz: edf21044e597735154b0b1acdcda9230749bf027e8b8acafe70c6aaa1da22b62
4
+ data.tar.gz: b7d6b716ff42f9e160c292a54ecf0344027c73e24eae29f6aecd34aae27d60ac
5
5
  SHA512:
6
- metadata.gz: 30dbbb1acb97020776653f681407ec30de7391ad186f7d6748e3ad29f902d8370b647897641ea7835d4a0974484addc6da84f0053c945d9743e23ec340ab55d6
7
- data.tar.gz: 4e20c5b19d82859f8dba12657ebdd8eef8d8ec61b909aedf73a30268d49077bfde6bf1a3252e32393a64c66fb98af69689ad15f62c85d4387bf660764d14c680
6
+ metadata.gz: 1849caab263614bdb85ed1ddcd15f63ae4199cd978ae56e2c8a690ca0e4c0e4616851f9a09f835d95e61adb457e35d5d07fbb3cb189c6394812f7064901cfaed
7
+ data.tar.gz: fe737b046298753a2fb9739f26307d46fbe4852a47f2a147fae56169259824dff4ced9f3f4cc3c6a37c58dbaea03785d2a1003d230d6b5c7cba56c4224ad011e
data/CHANGELOG.md CHANGED
@@ -7,7 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ## [1.3.0] - 2026-05-20
10
+ ## [1.3.1] - 2026-06-12
11
+
12
+ ### Changed — precision (fewer false positives)
13
+
14
+ Validated against two large production apps (~1,080 hand-checked findings). These
15
+ changes cut false positives by ~53% (339 → 158) with near-zero recall loss
16
+ (5 findings), and lift the trustworthy detectors toward 100% precision.
17
+
18
+ - **Schema-aware column guard.** New `SchemaParser` reads `db/schema.rb` (found by
19
+ walking up from the scanned path) to learn every table's real columns. When a
20
+ receiver's model can't be inferred, a method whose name the schema knows as a
21
+ column (`comsn_rate`, `vat_rate`, `service_fee_rate`) is no longer mistaken for
22
+ an association/query method. `loop_association` and `custom_method_query` no
23
+ longer flag column reads on unresolved receivers.
24
+ - **Per-iteration association dedup.** `loop_association` reports a memoized
25
+ `belongs_to`/`has_one` read once per iteration instead of on every line — a
26
+ repeated read hits Rails' instance cache, not the database. Associations used
27
+ as a query-chain base (`x.assoc.find_by`, `x.assoc.where`) still report each
28
+ occurrence, since those re-query.
29
+ - **Serializer render-site awareness.** New `SerializerUsageParser` scans render
30
+ sites (`XxxBlueprint.render*`, AMS `(each_)serializer:`) and records, per
31
+ serializer + Blueprinter view, which associations are eager-loaded and whether
32
+ only single records are passed. `serializer_nesting` stays silent when an
33
+ association is preloaded at every render site of that view, or the serializer
34
+ is only ever handed single records. It never concludes "safe" for a view it
35
+ can't see rendered, so genuine N+1s are preserved.
36
+ - **`custom_method_query` refinements.** Skips Enumerable aggregates with a block
37
+ argument (`relation.sum(&:amount)`), per-batch queries inside
38
+ `in_batches`/`find_in_batches`, and relation query methods called directly on a
39
+ single iteration element (a SELECT alias such as `record.ids`, not a query).
40
+ - **`validation_n_plus_one`** ignores saves that skip validations
41
+ (`save(validate: false)` / `create(..., validate: false)`).
11
42
 
12
43
  ### Added
13
44
 
@@ -19,18 +19,20 @@ module EagerEye
19
19
  }.freeze
20
20
 
21
21
  DETECTOR_EXTRA_ARGS = {
22
- Detectors::LoopAssociation => %i[association_preloads association_names method_queries associations_by_model],
23
- Detectors::SerializerNesting => %i[association_names method_queries],
22
+ Detectors::LoopAssociation => %i[association_preloads association_names method_queries associations_by_model
23
+ all_columns],
24
+ Detectors::SerializerNesting => %i[association_names method_queries serializer_usage all_columns],
24
25
  Detectors::MissingCounterCache => %i[association_names],
25
26
  Detectors::DecoratorNPlusOne => %i[association_names method_queries],
26
- Detectors::CustomMethodQuery => %i[method_queries associations_by_model],
27
+ Detectors::CustomMethodQuery => %i[method_queries associations_by_model all_columns],
27
28
  Detectors::DelegationNPlusOne => %i[delegation_maps],
28
29
  Detectors::ScopeChainNPlusOne => %i[scope_maps],
29
30
  Detectors::ValidationNPlusOne => %i[uniqueness_models]
30
31
  }.freeze
31
32
 
32
33
  attr_reader :paths, :issues, :association_preloads, :association_names, :method_queries, :delegation_maps,
33
- :scope_maps, :uniqueness_models, :associations_by_model
34
+ :scope_maps, :uniqueness_models, :associations_by_model, :all_columns, :columns_by_model,
35
+ :serializer_usage
34
36
 
35
37
  def initialize(paths: nil)
36
38
  @paths = Array(paths || EagerEye.configuration.app_path)
@@ -42,17 +44,42 @@ module EagerEye
42
44
  @delegation_maps = {}
43
45
  @scope_maps = {}
44
46
  @uniqueness_models = Set.new
47
+ @all_columns = Set.new
48
+ @columns_by_model = {}
49
+ @serializer_usage = SerializerUsageParser.new
45
50
  end
46
51
 
47
52
  def run
48
53
  @issues = []
54
+ collect_schema
49
55
  collect_model_metadata
56
+ collect_serializer_usage
50
57
  ruby_files.each { |file_path| analyze_file(file_path) }
51
58
  @issues
52
59
  end
53
60
 
54
61
  private
55
62
 
63
+ def collect_schema
64
+ schema = SchemaParser.new
65
+ return unless schema.parse_from_path(@paths[0])
66
+
67
+ @all_columns = schema.all_columns
68
+ @columns_by_model = schema.columns_by_model
69
+ end
70
+
71
+ # Pre-pass over every analyzed file to learn how serializers are rendered
72
+ # (eager-loaded associations, single-record vs collection). The detector uses
73
+ # this to stay silent on associations preloaded at all render sites.
74
+ def collect_serializer_usage
75
+ ruby_files.each do |file_path|
76
+ ast = parse_source(File.read(file_path))
77
+ @serializer_usage.parse_file(ast) if ast
78
+ rescue Errno::ENOENT, Errno::EACCES
79
+ next
80
+ end
81
+ end
82
+
56
83
  def collect_model_metadata
57
84
  model_files.each do |file_path|
58
85
  ast = parse_source(File.read(file_path))
@@ -20,13 +20,14 @@ module EagerEye
20
20
  :custom_method_query
21
21
  end
22
22
 
23
- def detect(ast, file_path, method_queries = {}, associations_by_model = {})
23
+ def detect(ast, file_path, method_queries = {}, associations_by_model = {}, all_columns = Set.new)
24
24
  return [] unless ast
25
25
 
26
26
  @issues = []
27
27
  @file_path = file_path
28
28
  @method_queries = method_queries
29
29
  @associations_by_model = associations_by_model
30
+ @all_columns = all_columns
30
31
 
31
32
  # Process each method def as its own scope so variable models from one
32
33
  # method don't bleed into another (e.g. `orders = Order.all` in #index
@@ -195,6 +196,11 @@ module EagerEye
195
196
  block_body = node.children[2]
196
197
  next unless block_var && block_body
197
198
 
199
+ # `x.in_batches.each { |batch| batch.pluck(...) }` — the block var is a
200
+ # batch relation and any query on it runs once per batch, which is the
201
+ # recommended fix for N+1, not a symptom. Skip those iterations.
202
+ next if batch_iteration?(node.children[0])
203
+
198
204
  model_name = infer_model_from_value(node.children[0])
199
205
  check_block_for_query_methods(block_body, block_var, collection_is_array?(node.children[0], definitions))
200
206
  check_block_for_model_query_methods(block_body, block_var, model_name)
@@ -243,6 +249,21 @@ module EagerEye
243
249
  ITERATION_METHODS.include?(node.children[0].children[1])
244
250
  end
245
251
 
252
+ BATCH_METHODS = %i[in_batches find_in_batches].freeze # rubocop:disable Lint/UselessConstantScoping
253
+
254
+ # True when the iterated collection comes from `in_batches`/`find_in_batches`
255
+ # — the block variable is a batch relation, so per-batch queries on it are
256
+ # the intended batching, not an N+1.
257
+ def batch_iteration?(node)
258
+ current = node
259
+ while current.is_a?(Parser::AST::Node) && current.type == :send
260
+ return true if BATCH_METHODS.include?(current.children[1])
261
+
262
+ current = current.children[0]
263
+ end
264
+ false
265
+ end
266
+
246
267
  def check_block_for_query_methods(node, block_var, is_array_collection = false) # rubocop:disable Style/OptionalBooleanParameter
247
268
  return unless node.is_a?(Parser::AST::Node)
248
269
 
@@ -255,12 +276,31 @@ module EagerEye
255
276
 
256
277
  method_name = node.children[1]
257
278
  return false unless QUERY_METHODS.include?(method_name)
279
+ return false if enumerable_form?(node)
280
+ return false if direct_block_var_receiver?(node, block_var, is_array_collection)
258
281
  return false if skip_array_method?(node, block_var, is_array_collection)
259
282
  return false if receiver_is_query_chain?(node.children[0])
260
283
 
261
284
  receiver_chain_starts_with?(node.children[0], block_var)
262
285
  end
263
286
 
287
+ # A relation query method called straight on the iteration element
288
+ # (`s_eod.ids`, `s_eod.pluck`) — with no association in between — is not an
289
+ # N+1: on a single record those names resolve to a column or a SELECT alias
290
+ # (e.g. `array_agg(...) AS ids`), not a relation query. The real N+1 shape
291
+ # is `element.association.query`, where the receiver is a send, not the bare
292
+ # block var. (When the element is itself a known array, the safe-method path
293
+ # handles it instead.)
294
+ def direct_block_var_receiver?(node, block_var, is_array_collection)
295
+ !is_array_collection && direct_block_var?(node.children[0], block_var)
296
+ end
297
+
298
+ # `relation.sum(&:amount)` / `relation.max(&:x)` run in Ruby over the loaded
299
+ # records (Enumerable), not as SQL — passing a block argument is the tell.
300
+ def enumerable_form?(node)
301
+ node.children[2..].any? { |arg| arg.is_a?(Parser::AST::Node) && arg.type == :block_pass }
302
+ end
303
+
264
304
  def skip_array_method?(node, block_var, is_array_collection)
265
305
  return true if receiver_ends_with_safe_transform_method?(node.children[0])
266
306
 
@@ -343,16 +383,27 @@ module EagerEye
343
383
  model_name && @associations_by_model&.dig(model_name)&.include?(method)
344
384
  end
345
385
 
386
+ # With a known receiver model, only flag a method defined as a query on
387
+ # THAT model. Without an inferred model the global "some model defines this
388
+ # query-method" fallback would fire on a same-named DB column (e.g. an
389
+ # EodItem's `comsn_rate` float that Wallet also exposes as a query method),
390
+ # so a name the schema knows as a column is never treated as a query.
346
391
  def method_defined_as_query?(method, model_name)
347
392
  return false unless @method_queries
348
393
 
349
394
  if model_name
350
395
  @method_queries[model_name]&.include?(method) || false
396
+ elsif known_column?(method)
397
+ false
351
398
  else
352
399
  @method_queries.any? { |_model, methods| methods.include?(method) }
353
400
  end
354
401
  end
355
402
 
403
+ def known_column?(method)
404
+ @all_columns&.include?(method) || false
405
+ end
406
+
356
407
  def add_issue(node)
357
408
  chain = reconstruct_chain(node.children[0])
358
409
  @issues << create_issue(
@@ -40,7 +40,7 @@ module EagerEye
40
40
  end
41
41
 
42
42
  def detect(ast, file_path, association_preloads = {}, association_names = Set.new, # rubocop:disable Metrics/ParameterLists
43
- method_queries = {}, associations_by_model = {})
43
+ method_queries = {}, associations_by_model = {}, all_columns = Set.new)
44
44
  return [] unless ast
45
45
 
46
46
  @issues = []
@@ -49,6 +49,7 @@ module EagerEye
49
49
  @dynamic_associations = association_names
50
50
  @method_queries = method_queries
51
51
  @associations_by_model = associations_by_model
52
+ @all_columns = all_columns
52
53
 
53
54
  # Variable preloads/models leak across methods if tracked globally
54
55
  # (e.g. controller#index sets `invoices = Invoice.includes(...)`, then
@@ -472,6 +473,7 @@ module EagerEye
472
473
 
473
474
  def find_association_calls(node, block_var, file_path, issues, included_associations, model_name, skip_nodes) # rubocop:disable Metrics/ParameterLists
474
475
  reported = Set.new
476
+ @requeried_methods = collect_requeried_methods(node, block_var)
475
477
  traverse_ast(node) do |child|
476
478
  next if skip_nodes.include?(child.object_id)
477
479
 
@@ -495,7 +497,7 @@ module EagerEye
495
497
  end
496
498
  end
497
499
 
498
- def reportable_association_call?(node, block_var, reported, included, model_name)
500
+ def reportable_association_call?(node, block_var, reported, included, model_name) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
499
501
  return false unless node.type == :send
500
502
 
501
503
  receiver = node.children[0]
@@ -503,7 +505,40 @@ module EagerEye
503
505
  return false unless receiver&.type == :lvar && receiver.children[0] == block_var
504
506
  return false if excluded_method?(method, included, model_name)
505
507
 
506
- reported.add?("#{node.loc.line}:#{method}")
508
+ # A plain association read memoizes on the instance, so reading it more
509
+ # than once in one iteration only queries on the FIRST access — dedup the
510
+ # repeats. But an association used as a query-chain base
511
+ # (`x.assoc.find_by`, `x.assoc.where`) re-queries every time, so those
512
+ # occurrences are each real and are reported per line.
513
+ if @requeried_methods&.include?(method)
514
+ reported.add?("#{node.loc.line}:#{method}")
515
+ else
516
+ reported.add?(method.to_s)
517
+ end
518
+ end
519
+
520
+ # Methods M where `block_var.M` is the receiver of a SQL-issuing query
521
+ # method (`.find_by`, `.where`, `.exists?`, `.count`, …) somewhere in the
522
+ # block. Those re-query on every call (they bypass the memoized association
523
+ # cache), so M's accesses must not be deduped. A plain chained association
524
+ # read (`x.prize.badges`) is not a re-query — `prize` stays memoized.
525
+ # rubocop:disable Lint/UselessConstantScoping
526
+ REQUERYING_METHODS = %i[where find_by find_by! exists? find first last take
527
+ pluck ids count sum average minimum maximum any? none? many? one?].freeze
528
+ # rubocop:enable Lint/UselessConstantScoping
529
+
530
+ def collect_requeried_methods(block_body, block_var) # rubocop:disable Metrics/CyclomaticComplexity
531
+ requeried = Set.new
532
+ traverse_ast(block_body) do |node|
533
+ next unless node.type == :send && REQUERYING_METHODS.include?(node.children[1])
534
+
535
+ inner = node.children[0]
536
+ next unless inner.is_a?(Parser::AST::Node) && inner.type == :send
537
+ next unless inner.children[0]&.type == :lvar && inner.children[0].children[0] == block_var
538
+
539
+ requeried << inner.children[1]
540
+ end
541
+ requeried
507
542
  end
508
543
 
509
544
  def excluded_method?(method, included, model_name)
@@ -514,14 +549,31 @@ module EagerEye
514
549
 
515
550
  # When we know the iteration variable's model AND have parsed that model's
516
551
  # 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).
552
+ # or scalar accessors, not associations.
553
+ #
554
+ # When the model CANNOT be inferred (workers, lib/, service objects, blocks
555
+ # over params / method return values), fall back to the association-name
556
+ # heuristic — but never flag a name that the schema says is a real DB
557
+ # column. A column named like an association (`comsn_rate`, `vat_rate`) is
558
+ # the single largest false-positive source; the schema lets us exclude it
559
+ # while still catching genuine association access (`prize_point`, `user`).
519
560
  def known_association?(method, model_name)
520
561
  if model_name && @associations_by_model&.key?(model_name)
521
562
  return @associations_by_model[model_name].include?(method)
522
563
  end
523
564
 
524
- ASSOCIATION_NAMES.include?(method.to_s) || @dynamic_associations.include?(method)
565
+ # Receiver model unknown: a name the schema knows as a DB column is not
566
+ # an association access. Some names are BOTH a column and an association
567
+ # elsewhere (`vat_rate`, `currency`); without the model we can't tell, so
568
+ # we err toward silence (no false positive) and skip them. Names that are
569
+ # only ever associations (`prize_point`, `user`) are still flagged.
570
+ return false if known_column?(method)
571
+
572
+ @dynamic_associations.include?(method) || ASSOCIATION_NAMES.include?(method.to_s)
573
+ end
574
+
575
+ def known_column?(method)
576
+ @all_columns&.include?(method) || false
525
577
  end
526
578
 
527
579
  def reportable_method_query_call?(node, block_var, reported, model_name)
@@ -529,17 +581,21 @@ module EagerEye
529
581
  return false if EXCLUDED_METHODS.include?(node.children[1])
530
582
  return false unless method_known_to_query?(node.children[1], model_name)
531
583
 
532
- reported.add?("#{node.loc.line}:#{node.children[1]}")
584
+ reported.add?("q:#{node.children[1]}")
533
585
  end
534
586
 
535
587
  # 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.
588
+ # query on THAT model. Without a known model the global "any model has this
589
+ # method" heuristic can fire on a same-named DB column (e.g. a `comsn_rate`
590
+ # float that some other model also exposes as a query method), so a name
591
+ # the schema knows as a column is never treated as a query method.
538
592
  def method_known_to_query?(method, model_name)
539
593
  return false unless @method_queries
540
594
 
541
595
  if model_name
542
596
  @method_queries[model_name]&.include?(method) || false
597
+ elsif known_column?(method)
598
+ false
543
599
  else
544
600
  @method_queries.any? { |_, ms| ms.include?(method) }
545
601
  end
@@ -15,11 +15,14 @@ module EagerEye
15
15
  :serializer_nesting
16
16
  end
17
17
 
18
- def detect(ast, file_path, association_names = Set.new, method_queries = {})
18
+ def detect(ast, file_path, association_names = Set.new, method_queries = {}, serializer_usage = nil, # rubocop:disable Metrics/ParameterLists
19
+ all_columns = Set.new)
19
20
  return [] unless ast
20
21
 
21
22
  @dynamic_associations = association_names
22
23
  @method_queries = method_queries
24
+ @serializer_usage = serializer_usage
25
+ @all_columns = all_columns
23
26
  issues = []
24
27
  traverse_ast(ast) do |node|
25
28
  next unless node.type == :class && serializer_class?(node)
@@ -66,11 +69,33 @@ module EagerEye
66
69
  body = class_node.children[2]
67
70
  return unless body
68
71
 
69
- traverse_ast(body) do |node|
70
- next unless attribute_block?(node) && node.children[2]
72
+ serializer = extract_class_name(class_node)
73
+ walk_with_view(body, nil) do |attr_block, view|
74
+ find_association_in_block(attr_block.children[2], file_path, issues, serializer, view)
75
+ end
76
+ end
77
+
78
+ # Walk the class body, tracking the enclosing Blueprinter `view :name do ...
79
+ # end` so each attribute block is tagged with the view it belongs to (nil =
80
+ # a base/default field rendered by every view). Yields attribute blocks.
81
+ def walk_with_view(node, view, &block) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
82
+ return unless node.is_a?(Parser::AST::Node)
71
83
 
72
- find_association_in_block(node.children[2], file_path, issues)
84
+ if view_block?(node)
85
+ inner_view = node.children[0].children[2]&.children&.first
86
+ node.children[2..].each { |c| walk_with_view(c, inner_view, &block) }
87
+ return
73
88
  end
89
+
90
+ yield(node, view) if attribute_block?(node) && node.children[2]
91
+
92
+ node.children.each { |c| walk_with_view(c, view, &block) }
93
+ end
94
+
95
+ def view_block?(node)
96
+ node.type == :block && node.children[0]&.type == :send &&
97
+ node.children[0].children[1] == :view &&
98
+ node.children[0].children[2]&.type == :sym
74
99
  end
75
100
 
76
101
  def attribute_block?(node)
@@ -78,7 +103,7 @@ module EagerEye
78
103
  ATTRIBUTE_METHODS.include?(node.children[0].children[1])
79
104
  end
80
105
 
81
- def find_association_in_block(block_body, file_path, issues)
106
+ def find_association_in_block(block_body, file_path, issues, serializer, view)
82
107
  storage_lines = collect_active_storage_lines(block_body)
83
108
 
84
109
  traverse_ast(block_body) do |node|
@@ -88,6 +113,7 @@ module EagerEye
88
113
  receiver = node.children[0]
89
114
  method_name = node.children[1]
90
115
  next unless object_reference?(receiver)
116
+ next if suppressed?(serializer, view, method_name)
91
117
 
92
118
  if likely_association?(method_name)
93
119
  issues << create_issue(
@@ -107,6 +133,22 @@ module EagerEye
107
133
  end
108
134
  end
109
135
 
136
+ # Stay silent when the access is provably safe:
137
+ # * the name is a plain DB column (not a declared association anywhere), or
138
+ # * the render-site index shows that everywhere this serializer/view is
139
+ # rendered the association is eager-loaded or only single records are
140
+ # passed (so there is no collection to multiply into an N+1).
141
+ def suppressed?(serializer, view, method_name)
142
+ return true if column_not_association?(method_name)
143
+ return false unless @serializer_usage&.known_serializer?(serializer)
144
+
145
+ @serializer_usage.safe_access?(serializer, view, method_name)
146
+ end
147
+
148
+ def column_not_association?(method_name)
149
+ @all_columns&.include?(method_name) && !@dynamic_associations&.include?(method_name)
150
+ end
151
+
110
152
  def object_reference?(node)
111
153
  return false unless node
112
154
 
@@ -72,6 +72,7 @@ module EagerEye
72
72
 
73
73
  def check_create_call(node)
74
74
  return unless CREATE_METHODS.include?(node.children[1])
75
+ return if validations_skipped?(node)
75
76
 
76
77
  receiver = node.children[0]
77
78
  return unless receiver&.type == :const
@@ -82,6 +83,7 @@ module EagerEye
82
83
 
83
84
  def check_save_call(node, new_model_vars)
84
85
  return unless SAVE_METHODS.include?(node.children[1])
86
+ return if validations_skipped?(node)
85
87
 
86
88
  receiver = node.children[0]
87
89
  return unless receiver&.type == :lvar
@@ -90,6 +92,29 @@ module EagerEye
90
92
  add_issue(node, model_name, node.children[1]) if model_name
91
93
  end
92
94
 
95
+ # `save(validate: false)` / `create(..., validate: false)` skip ALL
96
+ # validations, so the uniqueness SELECT never runs — not an N+1.
97
+ def validations_skipped?(node)
98
+ node.children[2..].any? { |arg| hash_with_validate_false?(arg) }
99
+ end
100
+
101
+ def hash_with_validate_false?(arg)
102
+ return false unless arg.is_a?(Parser::AST::Node) && arg.type == :hash
103
+
104
+ arg.children.any? { |pair| validate_false_pair?(pair) }
105
+ end
106
+
107
+ def validate_false_pair?(pair)
108
+ return false unless pair.type == :pair
109
+
110
+ key, value = pair.children
111
+ key&.type == :sym && key.children[0] == :validate && false_literal?(value)
112
+ end
113
+
114
+ def false_literal?(node)
115
+ node.is_a?(Parser::AST::Node) && node.type == :false # rubocop:disable Lint/BooleanSymbol
116
+ end
117
+
93
118
  def model_new_call?(node)
94
119
  return false unless node.is_a?(Parser::AST::Node) && node.type == :send
95
120
 
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parser/current"
4
+
5
+ module EagerEye
6
+ # Parses db/schema.rb to learn the real column names of each table. Columns are
7
+ # the dominant false-positive source: when a receiver's model can't be inferred,
8
+ # the name heuristics flag any method whose name collides with an association
9
+ # name — but a DB column (`comsn_rate`, `vat_rate`, `service_fee_rate`) is never
10
+ # an association. Knowing the column names lets the detectors disambiguate.
11
+ #
12
+ # Schema lookup walks up from the analyzed path so `eager_eye app/` still finds
13
+ # `<root>/db/schema.rb`. Parsing is AST-based (a `create_table "x" do |t| ... end`
14
+ # block whose body holds `t.<type> "col"` / `t.column "col"` / `t.references "y"`).
15
+ class SchemaParser
16
+ COLUMN_DEFINERS = %i[
17
+ string text integer bigint float decimal boolean date datetime time timestamp
18
+ binary json jsonb uuid inet cidr money hstore citext virtual primary_key column
19
+ ].freeze
20
+ REFERENCE_DEFINERS = %i[references belongs_to].freeze
21
+
22
+ attr_reader :columns_by_table, :all_columns
23
+
24
+ def initialize
25
+ @columns_by_table = {}
26
+ @all_columns = Set.new
27
+ end
28
+
29
+ # Returns true if a schema was found and parsed.
30
+ def parse_from_path(start_path)
31
+ schema = locate_schema(start_path)
32
+ return false unless schema
33
+
34
+ ast = parse(File.read(schema))
35
+ return false unless ast
36
+
37
+ walk(ast)
38
+ true
39
+ rescue Errno::ENOENT, Errno::EACCES
40
+ false
41
+ end
42
+
43
+ # Column names mapped onto the model a table name classifies to
44
+ # ("eod_items" -> "EodItem"). Used for per-model column resolution.
45
+ def columns_by_model
46
+ @columns_by_model ||= @columns_by_table.transform_keys { |table| classify(table) }
47
+ end
48
+
49
+ private
50
+
51
+ def locate_schema(start_path)
52
+ dir = File.expand_path(File.directory?(start_path) ? start_path : File.dirname(start_path))
53
+ 12.times do
54
+ candidate = File.join(dir, "db", "schema.rb")
55
+ return candidate if File.file?(candidate)
56
+
57
+ parent = File.dirname(dir)
58
+ break if parent == dir
59
+
60
+ dir = parent
61
+ end
62
+ nil
63
+ end
64
+
65
+ def parse(source)
66
+ Parser::CurrentRuby.parse(source)
67
+ rescue Parser::SyntaxError
68
+ nil
69
+ end
70
+
71
+ def walk(node)
72
+ return unless node.is_a?(Parser::AST::Node)
73
+
74
+ capture_create_table(node) if create_table_block?(node)
75
+ node.children.each { |child| walk(child) }
76
+ end
77
+
78
+ def create_table_block?(node)
79
+ node.type == :block && node.children[0]&.type == :send &&
80
+ node.children[0].children[1] == :create_table
81
+ end
82
+
83
+ def capture_create_table(block_node)
84
+ table = string_arg(block_node.children[0])
85
+ return unless table
86
+
87
+ cols = (@columns_by_table[table] ||= Set.new)
88
+ collect_columns(block_node.children[2], cols)
89
+ end
90
+
91
+ def collect_columns(body, cols)
92
+ return unless body.is_a?(Parser::AST::Node)
93
+
94
+ add_column_from_send(body, cols) if body.type == :send
95
+ body.children.each { |child| collect_columns(child, cols) }
96
+ end
97
+
98
+ def add_column_from_send(node, cols)
99
+ method = node.children[1]
100
+ name = string_arg(node)
101
+ return unless name
102
+
103
+ if COLUMN_DEFINERS.include?(method)
104
+ register(cols, name)
105
+ elsif REFERENCE_DEFINERS.include?(method)
106
+ register(cols, "#{name}_id")
107
+ end
108
+ end
109
+
110
+ def register(cols, name)
111
+ sym = name.to_sym
112
+ cols << sym
113
+ @all_columns << sym
114
+ end
115
+
116
+ def string_arg(node)
117
+ return unless node.is_a?(Parser::AST::Node)
118
+
119
+ node.children[2..]&.find { |a| a.is_a?(Parser::AST::Node) && a.type == :str }&.children&.first
120
+ end
121
+
122
+ def classify(table)
123
+ base = table.to_s
124
+ singular = base.respond_to?(:singularize) ? base.singularize : base.sub(/s\z/, "")
125
+ singular.split("_").map(&:capitalize).join
126
+ end
127
+ end
128
+ end
@@ -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.3.0"
4
+ VERSION = "1.3.1"
5
5
  end
data/lib/eager_eye.rb CHANGED
@@ -10,6 +10,8 @@ require_relative "eager_eye/delegation_parser"
10
10
  require_relative "eager_eye/scope_parser"
11
11
  require_relative "eager_eye/validation_parser"
12
12
  require_relative "eager_eye/method_query_parser"
13
+ require_relative "eager_eye/schema_parser"
14
+ require_relative "eager_eye/serializer_usage_parser"
13
15
  require_relative "eager_eye/detectors/base"
14
16
  require_relative "eager_eye/detectors/loop_association"
15
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.3.0
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-20 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
@@ -96,7 +96,9 @@ files:
96
96
  - lib/eager_eye/reporters/json.rb
97
97
  - lib/eager_eye/rspec.rb
98
98
  - lib/eager_eye/rspec/matchers.rb
99
+ - lib/eager_eye/schema_parser.rb
99
100
  - lib/eager_eye/scope_parser.rb
101
+ - lib/eager_eye/serializer_usage_parser.rb
100
102
  - lib/eager_eye/validation_parser.rb
101
103
  - lib/eager_eye/version.rb
102
104
  - sig/eager_eye.rbs