eager_eye 1.2.5 → 1.2.7

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: 6fa0e6e3acb2c37751e7ab49a58826a016a68c7a33cc6305ddb6b82d6787c462
4
- data.tar.gz: af81d8c0cfde13914b00a9f64b4b1bef6731b12902a1358c7f27473c2ab9a68d
3
+ metadata.gz: dd5177a12d64b54cc6cd426440f79018da9ccc7dd931db70c491c9154bb385a1
4
+ data.tar.gz: dec16deb3b55ab821225d5d0e06a6fa308c41fd472dad405bf125c7e5839f2e0
5
5
  SHA512:
6
- metadata.gz: a0e233e5c53c6ee3c7a32239ca20625a58deb1b385af022cb582433d02070f7b861873f44cef5fe786ede0307caee4105202e49844902e8d75e14039623bdc74
7
- data.tar.gz: 238ca3ba9a777686867752bc4abbf1d10081d73215e3c0f7ed0e9360f7c380298b4736177c703f1c921fb22b4296e7544379bef69021408f9e9ad86280a3716c
6
+ metadata.gz: 4336421ff71269732f594163dc3dbe29ca70d1b10c19dd333d972ea63bff9da36a4e83a041263fa6f61d758c8b0551912e075e00ce8f05978ab59c7db698af41
7
+ data.tar.gz: 51de528f0dca6b3279c0cb1fe6f21e5c35592dda81220419c209d8d43faf35b41375cbfffd33a463546f5e81e4807f8c48268f618d8383c82a570f81da823efa
data/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.7] - 2026-03-10
11
+
12
+ ### Added
13
+
14
+ - **New Detector: `ScopeChainNPlusOne`** - Detects scope calls on associations inside iterations
15
+ - Catches `post.comments.recent`, `post.comments.approved.count` patterns
16
+ - Parses model files for `scope :name, -> { ... }` declarations
17
+ - Flags known scope names called on association chains inside loops
18
+ - Each scope call executes a new query per iteration — suggests preloading or joined queries
19
+ - **New Parser: `ScopeParser`** - Collects scope definitions from model files for cross-file detection
20
+
21
+ ## [1.2.6] - 2026-02-25
22
+
23
+ ### Changed
24
+
25
+ - Extract shared class-inspection logic into `ClassInspector` concern to reduce duplication across detectors
26
+ - Refactor all detectors to use `ClassInspector` for parent class and naming convention checks
27
+ - Simplify `Analyzer` by removing redundant delegation and streamlining detector orchestration
28
+ - Clean up `DelegationParser` with leaner parsing logic
29
+ - Improve `Base` detector with consolidated helper methods
30
+ - Streamline reporter classes (`Base`, `Console`) for clarity and consistency
31
+
32
+ ### Removed
33
+
34
+ - Remove duplicated class-matching code from `CallbackQuery`, `CountInIteration`, `CustomMethodQuery`, `DecoratorNPlusOne`, `DelegationNPlusOne`, `LoopAssociation`, and `SerializerNesting`
35
+
10
36
  ## [1.2.5] - 2026-02-21
11
37
 
12
38
  ### 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.5-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.7-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>
@@ -42,7 +42,7 @@
42
42
 
43
43
  ## Features
44
44
 
45
- ✨ **Detects 9 types of N+1 problems:**
45
+ ✨ **Detects 10 types of N+1 problems:**
46
46
  - Loop associations (queries in iterations)
47
47
  - Serializer nesting issues
48
48
  - Missing counter caches
@@ -52,6 +52,7 @@
52
52
  - Pluck to array misuse
53
53
  - Delegation N+1s (hidden via `delegate :method, to: :association`)
54
54
  - Decorator N+1s (Draper, SimpleDelegator, Presenter, ViewObject)
55
+ - Scope chain N+1s (named scopes on associations in loops)
55
56
 
56
57
  🔧 **Developer-friendly:**
57
58
  - Inline suppression (like RuboCop)
@@ -398,6 +399,33 @@ Supports the following object references inside decorators:
398
399
  - `__getobj__` — SimpleDelegator standard
399
400
  - `source`, `model` — alternative Draper aliases
400
401
 
402
+ ### 10. Scope Chain N+1
403
+
404
+ Detects named scope calls on associations inside iterations. Unlike explicit query methods (`.where`, `.find_by`) caught by `CustomMethodQuery`, named scopes (`.recent`, `.active`, `.published`) are invisible query triggers.
405
+
406
+ ```ruby
407
+ # Model
408
+ class Comment < ApplicationRecord
409
+ scope :recent, -> { where("created_at > ?", 1.week.ago) }
410
+ scope :approved, -> { where(approved: true) }
411
+ end
412
+
413
+ # Bad - scope call per iteration
414
+ posts.each do |post|
415
+ post.comments.recent # Query for each post!
416
+ post.comments.approved.count # Query for each post!
417
+ end
418
+
419
+ # Good - preload and filter in Ruby
420
+ posts.includes(:comments).each do |post|
421
+ post.comments.select { |c| c.created_at > 1.week.ago }
422
+ end
423
+ ```
424
+
425
+ EagerEye detects these by:
426
+ 1. Scanning model files for `scope :name, -> { ... }` declarations
427
+ 2. Flagging known scope names called on association chains inside iteration blocks
428
+
401
429
  ## Inline Suppression
402
430
 
403
431
  Suppress false positives using inline comments (RuboCop-style):
@@ -442,6 +470,7 @@ Both CamelCase and snake_case formats are accepted:
442
470
  | Pluck to Array | `PluckToArray` | `pluck_to_array` |
443
471
  | Delegation N+1 | `DelegationNPlusOne` | `delegation_n_plus_one` |
444
472
  | Decorator N+1 | `DecoratorNPlusOne` | `decorator_n_plus_one` |
473
+ | Scope Chain N+1 | `ScopeChainNPlusOne` | `scope_chain_n_plus_one` |
445
474
  | All Detectors | `all` | `all` |
446
475
 
447
476
  ## Auto-fix (Experimental)
@@ -546,18 +575,20 @@ enabled_detectors:
546
575
  - pluck_to_array
547
576
  - delegation_n_plus_one
548
577
  - decorator_n_plus_one
578
+ - scope_chain_n_plus_one
549
579
 
550
580
  # Severity levels per detector (error, warning, info)
551
581
  severity_levels:
552
- loop_association: error # Definite N+1
582
+ loop_association: error # Definite N+1
553
583
  serializer_nesting: warning
554
584
  custom_method_query: warning
555
585
  count_in_iteration: warning
556
586
  callback_query: warning
557
- pluck_to_array: warning # Optimization
558
- delegation_n_plus_one: warning # Hidden delegation N+1
559
- decorator_n_plus_one: warning # Decorator/Presenter N+1
560
- missing_counter_cache: info # Suggestion
587
+ pluck_to_array: warning # Optimization
588
+ delegation_n_plus_one: warning # Hidden delegation N+1
589
+ decorator_n_plus_one: warning # Decorator/Presenter N+1
590
+ scope_chain_n_plus_one: warning # Scope chain on association
591
+ missing_counter_cache: info # Suggestion
561
592
 
562
593
  # Minimum severity to report (default: info)
563
594
  min_severity: warning
@@ -13,52 +13,50 @@ module EagerEye
13
13
  callback_query: Detectors::CallbackQuery,
14
14
  pluck_to_array: Detectors::PluckToArray,
15
15
  delegation_n_plus_one: Detectors::DelegationNPlusOne,
16
- decorator_n_plus_one: Detectors::DecoratorNPlusOne
16
+ decorator_n_plus_one: Detectors::DecoratorNPlusOne,
17
+ scope_chain_n_plus_one: Detectors::ScopeChainNPlusOne
17
18
  }.freeze
18
19
 
19
- attr_reader :paths, :issues, :association_preloads, :delegation_maps
20
+ attr_reader :paths, :issues, :association_preloads, :delegation_maps, :scope_maps
20
21
 
21
22
  def initialize(paths: nil)
22
23
  @paths = Array(paths || EagerEye.configuration.app_path)
23
24
  @issues = []
24
25
  @association_preloads = {}
25
26
  @delegation_maps = {}
27
+ @scope_maps = {}
26
28
  end
27
29
 
28
30
  def run
29
31
  @issues = []
30
- collect_association_preloads
31
- collect_delegation_maps
32
- analyze_files
32
+ collect_model_metadata
33
+ ruby_files.each { |file_path| analyze_file(file_path) }
33
34
  @issues
34
35
  end
35
36
 
36
37
  private
37
38
 
38
- def collect_association_preloads
39
+ def collect_model_metadata
39
40
  model_files.each do |file_path|
40
41
  ast = parse_source(File.read(file_path))
41
42
  next unless ast
42
43
 
43
- parser = AssociationParser.new
44
- parser.parse_model(ast, extract_model_name(file_path))
45
- @association_preloads.merge!(parser.preloaded_associations)
46
- end
47
- rescue StandardError
48
- nil
49
- end
44
+ model_name = extract_model_name(file_path)
50
45
 
51
- def collect_delegation_maps
52
- model_files.each do |file_path|
53
- ast = parse_source(File.read(file_path))
54
- next unless ast
46
+ assoc_parser = AssociationParser.new
47
+ assoc_parser.parse_model(ast, model_name)
48
+ @association_preloads.merge!(assoc_parser.preloaded_associations)
55
49
 
56
- parser = DelegationParser.new
57
- parser.parse_model(ast, extract_model_name(file_path))
58
- @delegation_maps.merge!(parser.delegation_maps)
50
+ deleg_parser = DelegationParser.new
51
+ deleg_parser.parse_model(ast, model_name)
52
+ @delegation_maps.merge!(deleg_parser.delegation_maps)
53
+
54
+ scope_parser = ScopeParser.new
55
+ scope_parser.parse_model(ast, model_name)
56
+ @scope_maps.merge!(scope_parser.scope_maps)
57
+ rescue Errno::ENOENT, Errno::EACCES
58
+ next
59
59
  end
60
- rescue StandardError
61
- nil
62
60
  end
63
61
 
64
62
  def model_files
@@ -66,25 +64,19 @@ module EagerEye
66
64
  end
67
65
 
68
66
  def extract_model_name(file_path)
69
- File.basename(file_path, ".rb").camelize
67
+ name = File.basename(file_path, ".rb")
68
+ name.respond_to?(:camelize) ? name.camelize : name.split("_").map(&:capitalize).join
70
69
  end
71
70
 
72
- def analyze_files
73
- ruby_files.each { |file_path| analyze_file(file_path) }
71
+ def ruby_files
72
+ paths.flat_map { |path| resolve_path(path) }.reject { |file| excluded?(file) }
74
73
  end
75
74
 
76
- def ruby_files
77
- all_files = paths.flat_map do |path|
78
- if File.file?(path)
79
- [path]
80
- elsif File.directory?(path)
81
- Dir.glob(File.join(path, "**", "*.rb"))
82
- else
83
- Dir.glob(path)
84
- end
85
- end
75
+ def resolve_path(path)
76
+ return [path] if File.file?(path)
77
+ return Dir.glob(File.join(path, "**", "*.rb")) if File.directory?(path)
86
78
 
87
- all_files.reject { |file| excluded?(file) }
79
+ Dir.glob(path)
88
80
  end
89
81
 
90
82
  def excluded?(file_path)
@@ -103,9 +95,10 @@ module EagerEye
103
95
 
104
96
  enabled_detectors.each do |detector|
105
97
  file_issues = detector.detect(*detector_args(detector, ast, file_path))
106
- file_issues.reject! { |issue| comment_parser.disabled_at?(issue.line_number, issue.detector) }
107
- file_issues.select! { |issue| issue.meets_minimum_severity?(min_severity) }
108
- @issues.concat(file_issues)
98
+ @issues.concat(file_issues.select do |issue|
99
+ !comment_parser.disabled_at?(issue.line_number, issue.detector) &&
100
+ issue.meets_minimum_severity?(min_severity)
101
+ end)
109
102
  end
110
103
  rescue Errno::ENOENT, Errno::EACCES => e
111
104
  warn "EagerEye: Could not read file #{file_path}: #{e.message}"
@@ -121,13 +114,13 @@ module EagerEye
121
114
  args = [ast, file_path]
122
115
  args << @association_preloads if detector.is_a?(Detectors::LoopAssociation)
123
116
  args << @delegation_maps if detector.is_a?(Detectors::DelegationNPlusOne)
117
+ args << @scope_maps if detector.is_a?(Detectors::ScopeChainNPlusOne)
124
118
  args
125
119
  end
126
120
 
127
121
  def enabled_detectors
128
122
  @enabled_detectors ||= EagerEye.configuration.enabled_detectors.filter_map do |name|
129
- detector_class = DETECTOR_CLASSES[name]
130
- detector_class&.new
123
+ DETECTOR_CLASSES[name]&.new
131
124
  end
132
125
  end
133
126
  end
@@ -5,7 +5,6 @@ module EagerEye
5
5
  FILE_DISABLE_PATTERN = /eager_eye:disable-file\s+(.+?)(?:\s+--|$)/i
6
6
  NEXT_LINE_PATTERN = /eager_eye:disable-next-line(?:\s+(.+?))?(?:\s+--|$)/i
7
7
  BLOCK_START_PATTERN = /eager_eye:disable-block(?:\s+(.+?))?(?:\s+--|$)/i
8
- BLOCK_END_PATTERN = /eager_eye:enable-block(?:\s+(.+?))?(?:\s+--|$)/i
9
8
  INLINE_DISABLE_PATTERN = /eager_eye:disable\s+(.+?)(?:\s+--|$)/i
10
9
  ENABLE_PATTERN = /eager_eye:enable(?:\s+(.+?))?(?:\s+--|$)/i
11
10
 
@@ -9,6 +9,7 @@ module EagerEye
9
9
  loop_association serializer_nesting missing_counter_cache
10
10
  custom_method_query count_in_iteration callback_query
11
11
  pluck_to_array delegation_n_plus_one decorator_n_plus_one
12
+ scope_chain_n_plus_one
12
13
  ].freeze
13
14
 
14
15
  DEFAULT_SEVERITY_LEVELS = {
@@ -20,7 +21,8 @@ module EagerEye
20
21
  callback_query: :warning,
21
22
  pluck_to_array: :warning,
22
23
  delegation_n_plus_one: :warning,
23
- decorator_n_plus_one: :warning
24
+ decorator_n_plus_one: :warning,
25
+ scope_chain_n_plus_one: :warning
24
26
  }.freeze
25
27
 
26
28
  VALID_SEVERITIES = %i[info warning error].freeze
@@ -24,50 +24,42 @@ module EagerEye
24
24
  end
25
25
 
26
26
  def check_delegate(node, model_name)
27
- return unless bare_delegate_call?(node)
27
+ return unless delegate_call?(node)
28
28
 
29
29
  args = node.children[2..]
30
- methods = delegate_methods(args)
30
+ methods = extract_sym_args(args)
31
31
  return if methods.empty?
32
32
 
33
33
  to_target = extract_to_target(args)
34
34
  return unless to_target
35
35
 
36
- register_delegates(model_name, methods, to_target)
36
+ @delegation_maps[model_name] ||= {}
37
+ methods.each { |m| @delegation_maps[model_name][m] = to_target }
37
38
  end
38
39
 
39
- def bare_delegate_call?(node)
40
+ def delegate_call?(node)
40
41
  node.type == :send && node.children[0].nil? && node.children[1] == :delegate
41
42
  end
42
43
 
43
- def delegate_methods(args)
44
+ def extract_sym_args(args)
44
45
  args.select { |a| a&.type == :sym }.map { |a| a.children[0] }
45
46
  end
46
47
 
47
- def register_delegates(model_name, methods, to_target)
48
- @delegation_maps[model_name] ||= {}
49
- methods.each { |m| @delegation_maps[model_name][m] = to_target }
50
- end
51
-
52
48
  def extract_to_target(args)
53
49
  hash_arg = args.find { |a| a&.type == :hash }
54
50
  return unless hash_arg
55
51
 
56
- to_pair = hash_arg.children.find { |p| to_key_pair?(p) }
57
- extract_sym_value(to_pair)
58
- end
59
-
60
- def extract_sym_value(node)
61
- return unless node
52
+ to_pair = find_to_pair(hash_arg)
53
+ return unless to_pair
62
54
 
63
- value = node.children[1]
55
+ value = to_pair.children[1]
64
56
  value.children[0] if value&.type == :sym
65
57
  end
66
58
 
67
- def to_key_pair?(pair)
68
- pair.type == :pair &&
69
- pair.children[0]&.type == :sym &&
70
- pair.children[0].children[0] == :to
59
+ def find_to_pair(hash_node)
60
+ hash_node.children.find do |p|
61
+ p.type == :pair && p.children[0]&.type == :sym && p.children[0].children[0] == :to
62
+ end
71
63
  end
72
64
  end
73
65
  end
@@ -22,13 +22,12 @@ module EagerEye
22
22
  protected
23
23
 
24
24
  def create_issue(file_path:, line_number:, message:, severity: nil, suggestion: nil)
25
- resolved_severity = severity || configured_severity
26
25
  Issue.new(
27
26
  detector: self.class.detector_name,
28
27
  file_path: file_path,
29
28
  line_number: line_number,
30
29
  message: message,
31
- severity: resolved_severity,
30
+ severity: severity || configured_severity,
32
31
  suggestion: suggestion
33
32
  )
34
33
  end
@@ -73,6 +72,34 @@ module EagerEye
73
72
  symbols.add(key.children[0]) if key&.type == :sym
74
73
  end
75
74
  end
75
+
76
+ def extract_block_variable(block_node)
77
+ args = block_node&.children&.[](1)
78
+ first_arg = args&.children&.first
79
+ first_arg&.type == :arg ? first_arg.children[0] : nil
80
+ end
81
+
82
+ def receiver_chain_starts_with?(node, target_var)
83
+ return false unless node.is_a?(Parser::AST::Node)
84
+
85
+ case node.type
86
+ when :lvar then node.children[0] == target_var
87
+ when :send then receiver_chain_starts_with?(node.children[0], target_var)
88
+ else false
89
+ end
90
+ end
91
+
92
+ def reconstruct_chain(node)
93
+ return "" unless node.is_a?(Parser::AST::Node)
94
+
95
+ case node.type
96
+ when :lvar then node.children[0].to_s
97
+ when :send
98
+ receiver_str = reconstruct_chain(node.children[0])
99
+ receiver_str.empty? ? node.children[1].to_s : "#{receiver_str}.#{node.children[1]}"
100
+ else ""
101
+ end
102
+ end
76
103
  end
77
104
  end
78
105
  end
@@ -41,7 +41,6 @@ module EagerEye
41
41
  @issues = []
42
42
  @file_path = file_path
43
43
  @callback_methods = {}
44
-
45
44
  return @issues unless ast
46
45
 
47
46
  find_callback_definitions(ast)
@@ -67,9 +66,7 @@ module EagerEye
67
66
  node.children[2..].each do |arg|
68
67
  next unless arg.is_a?(Parser::AST::Node) && arg.type == :sym
69
68
 
70
- method_name = arg.children[0]
71
- callback_type = node.children[1]
72
- @callback_methods[method_name] = callback_type
69
+ @callback_methods[arg.children[0]] = node.children[1]
73
70
  end
74
71
  end
75
72
 
@@ -77,9 +74,8 @@ module EagerEye
77
74
  return unless node.is_a?(Parser::AST::Node)
78
75
 
79
76
  if node.type == :def && @callback_methods.key?(node.children[0])
80
- method_name = node.children[0]
81
77
  body = node.children[2]
82
- find_iterations_with_queries(body, method_name, @callback_methods[method_name]) if body
78
+ find_iterations_with_queries(body, node.children[0], @callback_methods[node.children[0]]) if body
83
79
  end
84
80
 
85
81
  node.children.each { |child| check_callback_methods(child) }
@@ -131,7 +127,6 @@ module EagerEye
131
127
  end
132
128
 
133
129
  def add_query_issue(node, method_name, callback_type)
134
- query_method = node.children[1]
135
130
  suggestion = if transactional_callback?(callback_type)
136
131
  "Callbacks run on every save/create/update. Move the query outside the iteration or preload data"
137
132
  else
@@ -141,7 +136,7 @@ module EagerEye
141
136
  @issues << create_issue(
142
137
  file_path: @file_path,
143
138
  line_number: node.loc.line,
144
- message: "Query method `.#{query_method}` found in `#{callback_type}` callback `:#{method_name}`",
139
+ message: "Query method `.#{node.children[1]}` found in `#{callback_type}` callback `:#{method_name}`",
145
140
  severity: :warning,
146
141
  suggestion: suggestion
147
142
  )
@@ -167,29 +162,6 @@ module EagerEye
167
162
  TRANSACTIONAL_CALLBACKS.include?(callback_type)
168
163
  end
169
164
 
170
- def extract_block_variable(block_node)
171
- args_node = block_node.children[1]
172
- return nil unless args_node&.type == :args
173
-
174
- first_arg = args_node.children[0]
175
- return nil unless first_arg&.type == :arg
176
-
177
- first_arg.children[0]
178
- end
179
-
180
- def receiver_chain_starts_with?(node, block_var)
181
- return false unless node.is_a?(Parser::AST::Node)
182
-
183
- case node.type
184
- when :lvar
185
- node.children[0] == block_var
186
- when :send
187
- receiver_chain_starts_with?(node.children[0], block_var)
188
- else
189
- false
190
- end
191
- end
192
-
193
165
  def non_ar_collection?(node)
194
166
  ns = root_namespace(node)
195
167
  ns && NON_AR_NAMESPACES.include?(ns)
@@ -199,10 +171,8 @@ module EagerEye
199
171
  return nil unless node.is_a?(Parser::AST::Node)
200
172
 
201
173
  case node.type
202
- when :const
203
- node.children[0].nil? ? node.children[1].to_s : root_namespace(node.children[0])
204
- when :send, :block
205
- root_namespace(node.children[0])
174
+ when :const then node.children[0].nil? ? node.children[1].to_s : root_namespace(node.children[0])
175
+ when :send, :block then root_namespace(node.children[0])
206
176
  end
207
177
  end
208
178
 
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EagerEye
4
+ module Detectors
5
+ module Concerns
6
+ module ClassInspector
7
+ HAS_MANY_ASSOCIATIONS = %w[
8
+ authors users owners creators admins members customers clients
9
+ posts articles comments categories tags children companies organizations
10
+ projects tasks items orders products accounts profiles settings
11
+ images avatars photos attachments documents
12
+ ].freeze
13
+
14
+ ACTIVE_STORAGE_METHODS = %i[
15
+ attached? attach attachment attachments blob blobs purge purge_later variant preview
16
+ ].freeze
17
+
18
+ private
19
+
20
+ def const_to_string(node)
21
+ return nil unless node&.type == :const
22
+
23
+ parts = []
24
+ current = node
25
+ while current&.type == :const
26
+ parts.unshift(current.children[1].to_s)
27
+ current = current.children[0]
28
+ end
29
+ parts.join("::")
30
+ end
31
+
32
+ def extract_class_name(class_node)
33
+ name_node = class_node.children[0]
34
+ name_node.children[1].to_s if name_node&.type == :const
35
+ end
36
+
37
+ def likely_association?(method_name)
38
+ HAS_MANY_ASSOCIATIONS.include?(method_name.to_s)
39
+ end
40
+
41
+ def collect_active_storage_lines(body)
42
+ lines = Set.new
43
+ traverse_ast(body) do |node|
44
+ lines << node.loc.line if node.type == :send && ACTIVE_STORAGE_METHODS.include?(node.children[1])
45
+ end
46
+ lines
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -15,7 +15,6 @@ module EagerEye
15
15
  def detect(ast, file_path)
16
16
  @issues = []
17
17
  @file_path = file_path
18
-
19
18
  return @issues unless ast
20
19
 
21
20
  find_iteration_blocks(ast) do |block_body, block_var|
@@ -60,64 +59,23 @@ module EagerEye
60
59
  def array_returning_method?(node)
61
60
  return false unless node.is_a?(Parser::AST::Node) && node.type == :send
62
61
 
63
- method_name = node.children[1].to_s
64
- ARRAY_METHOD_SUFFIXES.any? { |suffix| method_name.end_with?(suffix) }
62
+ ARRAY_METHOD_SUFFIXES.any? { |suffix| node.children[1].to_s.end_with?(suffix) }
65
63
  end
66
64
 
67
65
  def association_call_on_block_var?(node, block_var)
68
- return false unless node.is_a?(Parser::AST::Node) && node.type == :send
69
-
70
- receiver = node.children[0]
71
- return false unless receiver.is_a?(Parser::AST::Node)
72
-
73
- return true if receiver.type == :lvar && receiver.children[0] == block_var
74
-
75
- receiver.type == :send && chain_starts_with_block_var?(receiver, block_var)
76
- end
77
-
78
- def chain_starts_with_block_var?(node, block_var)
79
- return false unless node.is_a?(Parser::AST::Node)
80
-
81
- case node.type
82
- when :lvar then node.children[0] == block_var
83
- when :send then chain_starts_with_block_var?(node.children[0], block_var)
84
- else false
85
- end
86
- end
87
-
88
- def extract_block_variable(block_node)
89
- args_node = block_node.children[1]
90
- return nil unless args_node&.type == :args
91
-
92
- first_arg = args_node.children[0]
93
- first_arg&.type == :arg ? first_arg.children[0] : nil
66
+ node.is_a?(Parser::AST::Node) && node.type == :send &&
67
+ receiver_chain_starts_with?(node.children[0], block_var)
94
68
  end
95
69
 
96
70
  def add_issue(node)
97
- receiver_chain = reconstruct_chain(node.children[0])
98
-
71
+ chain = reconstruct_chain(node.children[0])
99
72
  @issues << create_issue(
100
73
  file_path: @file_path,
101
74
  line_number: node.loc.line,
102
- message: "`.count` called on `#{receiver_chain}` inside iteration always executes a COUNT query",
75
+ message: "`.count` called on `#{chain}` inside iteration always executes a COUNT query",
103
76
  suggestion: "Use `.size` instead (uses loaded collection) or add `counter_cache: true`"
104
77
  )
105
78
  end
106
-
107
- def reconstruct_chain(node)
108
- return "" unless node.is_a?(Parser::AST::Node)
109
-
110
- case node.type
111
- when :lvar
112
- node.children[0].to_s
113
- when :send
114
- receiver_str = reconstruct_chain(node.children[0])
115
- method = node.children[1]
116
- receiver_str.empty? ? method.to_s : "#{receiver_str}.#{method}"
117
- else
118
- ""
119
- end
120
- end
121
79
  end
122
80
  end
123
81
  end