eager_eye 1.1.0 → 1.1.2

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: 03025eeca47525318e9dbd1ffe0046bcd6647c16bc5696a1fd0b7906906ebc9d
4
- data.tar.gz: c9fa809e3bcc95a6ea20d6f9ef18ca45241ad96e325fef76b0aa90e79b19e3a3
3
+ metadata.gz: 22770991f73a5bc9ca8694ae4e45212fc6c0517036dad5a8734a11af90aa4496
4
+ data.tar.gz: bcdbdf3076b011a7a4eb83d62d34b4173d7f6c5cb1dff3c0ac767bbd0f47c316
5
5
  SHA512:
6
- metadata.gz: c3faea6fb82cda3d118584970dba7bb88fa2fa1024dd6906d03f89699ff93b5f4d2a487b6c598eb9ee6bc18ae222b308f959d3cefbaed7d54c808aba93bc2fb2
7
- data.tar.gz: 355d9b1c678b755ff679492183e7046b3edb511bff6e8fe3ff0990f091274917877cf8495984cb8315fe1096b0308a3528aff8c56fc6f1ed22270aa4257b0d03
6
+ metadata.gz: fcf06b3db031246fcd6549bff94a13f5e30b29da2d72a6e3c2d233fcb9740cf4a60f807e190e3f082620eda08755e90a01e96915ac873f4205f3938c44de1e8a
7
+ data.tar.gz: 93a926cb2d961f1b5c8341df4c7ac0dbcb3d482e9bbc00b61b7d84ed3c457432611792f999d9c02b7dcb164cf3d238c561d89392b5cb67bb287e57dcbb008a12
data/.rubocop.yml CHANGED
@@ -20,7 +20,7 @@ Metrics/BlockLength:
20
20
  - "lib/eager_eye/railtie.rb"
21
21
 
22
22
  Metrics/ClassLength:
23
- Max: 165
23
+ Max: 155
24
24
 
25
25
  Metrics/ParameterLists:
26
26
  Max: 6
data/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.1.2] - 2026-01-04
11
+
12
+ ### Fixed
13
+
14
+ - **CallbackQuery False Positive** - Only flag iterations that contain actual AR query methods
15
+ - Non-AR iterations (Redis, Sidekiq, mailers) are no longer flagged
16
+ - `Sidekiq::ScheduledSet.new.select { |job| ... }.each(&:delete)` no longer triggers warning
17
+
18
+ ## [1.1.1] - 2026-01-03
19
+
20
+ ### Fixed
21
+
22
+ - **SerializerNesting False Positive** - No longer flags `belongs_to` associations
23
+ - `user.author`, `subscription.user` etc. (singular) are now ignored
24
+ - Only `has_many` associations (plural names) are flagged as potential N+1
25
+
10
26
  ## [1.1.0] - 2025-12-28
11
27
 
12
28
  ### 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.1.0-red.svg" alt="Gem Version"></a>
13
+ <a href="https://rubygems.org/gems/eager_eye"><img src="https://img.shields.io/badge/gem-v1.1.2-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>
data/SECURITY.md CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  | Version | Supported |
6
6
  | ------- | ------------------ |
7
+ | 1.1.x | :white_check_mark: |
7
8
  | 1.0.x | :white_check_mark: |
8
9
  | < 1.0 | :x: |
9
10
 
@@ -33,17 +33,15 @@ module EagerEye
33
33
 
34
34
  def collect_association_preloads
35
35
  model_files.each do |file_path|
36
- source = File.read(file_path)
37
- ast = parse_source(source)
36
+ ast = parse_source(File.read(file_path))
38
37
  next unless ast
39
38
 
40
- model_name = extract_model_name(file_path)
41
39
  parser = AssociationParser.new
42
- parser.parse_model(ast, model_name)
40
+ parser.parse_model(ast, extract_model_name(file_path))
43
41
  @association_preloads.merge!(parser.preloaded_associations)
44
42
  end
45
43
  rescue StandardError
46
- # Silently skip errors in association parsing
44
+ nil
47
45
  end
48
46
 
49
47
  def model_files
@@ -84,22 +82,15 @@ module EagerEye
84
82
  return unless ast
85
83
 
86
84
  comment_parser = CommentParser.new(source)
85
+ min_severity = EagerEye.configuration.min_severity
87
86
 
88
87
  enabled_detectors.each do |detector|
89
88
  args = [ast, file_path]
90
89
  args << @association_preloads if detector.is_a?(Detectors::LoopAssociation)
91
90
 
92
91
  file_issues = detector.detect(*args)
93
-
94
- # Filter suppressed issues
95
- file_issues.reject! do |issue|
96
- comment_parser.disabled_at?(issue.line_number, issue.detector)
97
- end
98
-
99
- # Filter by minimum severity
100
- min_severity = EagerEye.configuration.min_severity
92
+ file_issues.reject! { |issue| comment_parser.disabled_at?(issue.line_number, issue.detector) }
101
93
  file_issues.select! { |issue| issue.meets_minimum_severity?(min_severity) }
102
-
103
94
  @issues.concat(file_issues)
104
95
  end
105
96
  rescue Errno::ENOENT, Errno::EACCES => e
@@ -42,33 +42,17 @@ module EagerEye
42
42
  end
43
43
 
44
44
  def extract_association_name(node)
45
- args = node.children[2..]
46
- return nil if args.empty?
47
-
48
- first_arg = args[0]
49
- return nil unless first_arg&.type == :sym
50
-
51
- first_arg.children[0]
45
+ first_arg = node.children[2]
46
+ first_arg&.type == :sym ? first_arg.children[0] : nil
52
47
  end
53
48
 
54
49
  def extract_preloaded_associations(node)
55
50
  preloaded = Set.new
56
- args = node.children[2..]
57
- return preloaded if args.empty?
58
-
59
- # Check for block with includes/preload/eager_load
60
- block_node = args.find { |arg| arg&.type == :block }
61
- return preloaded unless block_node
62
-
63
- extract_from_block(block_node, preloaded)
51
+ block_node = node.children[2..].find { |arg| arg&.type == :block }
52
+ traverse_for_preloads(block_node&.children&.[](2), preloaded) if block_node
64
53
  preloaded
65
54
  end
66
55
 
67
- def extract_from_block(block_node, preloaded)
68
- block_body = block_node.children[2]
69
- traverse_for_preloads(block_body, preloaded)
70
- end
71
-
72
56
  def traverse_for_preloads(node, preloaded)
73
57
  return unless node.is_a?(Parser::AST::Node)
74
58
 
@@ -78,17 +62,11 @@ module EagerEye
78
62
  end
79
63
 
80
64
  def preload_call?(node)
81
- return false unless node.type == :send
82
-
83
- method = node.children[1]
84
- %i[includes preload eager_load].include?(method)
65
+ node.type == :send && %i[includes preload eager_load].include?(node.children[1])
85
66
  end
86
67
 
87
68
  def extract_includes_from_method(node, included)
88
- args = node.children[2..]
89
- return if args.empty?
90
-
91
- args.each { |arg| add_included_sym(arg, included) }
69
+ node.children[2..].each { |arg| add_included_sym(arg, included) }
92
70
  end
93
71
 
94
72
  def add_included_sym(arg, included)
@@ -12,11 +12,7 @@ module EagerEye
12
12
  fixes = collect_fixes
13
13
  return puts "No auto-fixable issues found." if fixes.empty?
14
14
 
15
- if @interactive
16
- apply_interactively(fixes)
17
- else
18
- apply_all(fixes)
19
- end
15
+ @interactive ? apply_interactively(fixes) : apply_all(fixes)
20
16
  end
21
17
 
22
18
  def suggest
@@ -37,12 +33,9 @@ module EagerEye
37
33
 
38
34
  def collect_fixes
39
35
  @issues.filter_map do |issue|
40
- source = read_file(issue.file_path)
41
- fixer = FixerRegistry.fixer_for(issue, source)
42
- next unless fixer&.fixable?
43
-
44
- fixer.diff
45
- end.compact
36
+ fixer = FixerRegistry.fixer_for(issue, read_file(issue.file_path))
37
+ fixer&.fixable? ? fixer.diff : nil
38
+ end
46
39
  end
47
40
 
48
41
  def read_file(path)
@@ -71,7 +64,6 @@ module EagerEye
71
64
  end
72
65
 
73
66
  def apply_all(fixes)
74
- # Group by file to minimize file operations
75
67
  fixes.group_by { |f| f[:file] }.each do |file, file_fixes|
76
68
  lines = File.readlines(file)
77
69
 
data/lib/eager_eye/cli.rb CHANGED
@@ -137,9 +137,7 @@ module EagerEye
137
137
 
138
138
  def analyze
139
139
  configure_from_options!
140
-
141
- analyzer = Analyzer.new(paths: options[:paths])
142
- analyzer.run
140
+ Analyzer.new(paths: options[:paths]).run
143
141
  end
144
142
 
145
143
  def configure_from_options!
@@ -165,10 +163,7 @@ module EagerEye
165
163
  end
166
164
 
167
165
  def exit_code(issues)
168
- return 0 unless options[:fail_on_issues]
169
- return 0 if issues.empty?
170
-
171
- 1
166
+ options[:fail_on_issues] && issues.any? ? 1 : 0
172
167
  end
173
168
  end
174
169
  end
@@ -131,11 +131,6 @@ module EagerEye
131
131
  end
132
132
  end
133
133
 
134
- def inline_disable?(line)
135
- code_part = line.split("#").first
136
- code_part && !code_part.strip.empty?
137
- end
138
-
139
134
  def code_before_comment?(line)
140
135
  code_part = line.split("#").first
141
136
  code_part && !code_part.strip.empty?
@@ -41,10 +41,7 @@ module EagerEye
41
41
  return unless node.is_a?(Parser::AST::Node)
42
42
 
43
43
  yield node
44
-
45
- node.children.each do |child|
46
- traverse_ast(child, &block)
47
- end
44
+ node.children.each { |child| traverse_ast(child, &block) }
48
45
  end
49
46
 
50
47
  def parse_source(source)
@@ -61,14 +58,10 @@ module EagerEye
61
58
 
62
59
  def extract_symbols_from_args(args)
63
60
  symbols = Set.new
64
- return symbols if args.empty?
65
-
66
61
  args.each do |arg|
67
62
  case arg&.type
68
- when :sym
69
- symbols.add(arg.children[0])
70
- when :hash
71
- extract_symbols_from_hash(arg, symbols)
63
+ when :sym then symbols.add(arg.children[0])
64
+ when :hash then extract_symbols_from_hash(arg, symbols)
72
65
  end
73
66
  end
74
67
  symbols
@@ -51,18 +51,11 @@ module EagerEye
51
51
  return unless node.is_a?(Parser::AST::Node)
52
52
 
53
53
  extract_callback_method_name(node) if callback_definition?(node)
54
-
55
- node.children.each do |child|
56
- find_callback_definitions(child)
57
- end
54
+ node.children.each { |child| find_callback_definitions(child) }
58
55
  end
59
56
 
60
57
  def callback_definition?(node)
61
- return false unless node.type == :send
62
- return false unless node.children[0].nil?
63
-
64
- method_name = node.children[1]
65
- CALLBACK_METHODS.include?(method_name)
58
+ node.type == :send && node.children[0].nil? && CALLBACK_METHODS.include?(node.children[1])
66
59
  end
67
60
 
68
61
  def extract_callback_method_name(node)
@@ -78,28 +71,13 @@ module EagerEye
78
71
  def check_callback_methods(node)
79
72
  return unless node.is_a?(Parser::AST::Node)
80
73
 
81
- if method_definition?(node)
74
+ if node.type == :def && @callback_methods.key?(node.children[0])
82
75
  method_name = node.children[0]
83
- if @callback_methods.key?(method_name)
84
- callback_type = @callback_methods[method_name]
85
- check_method_body_for_queries(node, method_name, callback_type)
86
- end
87
- end
88
-
89
- node.children.each do |child|
90
- check_callback_methods(child)
76
+ body = node.children[2]
77
+ find_iterations_with_queries(body, method_name, @callback_methods[method_name]) if body
91
78
  end
92
- end
93
-
94
- def method_definition?(node)
95
- node.type == :def
96
- end
97
-
98
- def check_method_body_for_queries(method_node, method_name, callback_type)
99
- method_body = method_node.children[2]
100
- return unless method_body
101
79
 
102
- find_iterations_with_queries(method_body, method_name, callback_type)
80
+ node.children.each { |child| check_callback_methods(child) }
103
81
  end
104
82
 
105
83
  def find_iterations_with_queries(node, method_name, callback_type)
@@ -107,13 +85,13 @@ module EagerEye
107
85
 
108
86
  if iteration_block?(node)
109
87
  block_var = extract_block_variable(node)
110
- add_iteration_issue(node, method_name, callback_type)
111
- find_query_calls_in_block(node, method_name, callback_type, block_var) if block_var
88
+ if block_var && contains_ar_query_on_variable?(node, block_var)
89
+ add_iteration_issue(node, method_name, callback_type)
90
+ find_query_calls_in_block(node, method_name, callback_type, block_var)
91
+ end
112
92
  end
113
93
 
114
- node.children.each do |child|
115
- find_iterations_with_queries(child, method_name, callback_type)
116
- end
94
+ node.children.each { |child| find_iterations_with_queries(child, method_name, callback_type) }
117
95
  end
118
96
 
119
97
  def find_query_calls_in_block(node, method_name, callback_type, block_var)
@@ -123,26 +101,16 @@ module EagerEye
123
101
  add_query_issue(node, method_name, callback_type)
124
102
  end
125
103
 
126
- node.children.each do |child|
127
- find_query_calls_in_block(child, method_name, callback_type, block_var)
128
- end
104
+ node.children.each { |child| find_query_calls_in_block(child, method_name, callback_type, block_var) }
129
105
  end
130
106
 
131
107
  def query_call?(node)
132
- return false unless node.type == :send
133
-
134
- method = node.children[1]
135
- QUERY_INDICATORS.include?(method)
108
+ node.type == :send && QUERY_INDICATORS.include?(node.children[1])
136
109
  end
137
110
 
138
111
  def iteration_block?(node)
139
- return false unless node.type == :block
140
-
141
- send_node = node.children[0]
142
- return false unless send_node&.type == :send
143
-
144
- method_name = send_node.children[1]
145
- ITERATION_METHODS.include?(method_name)
112
+ node.type == :block && node.children[0]&.type == :send &&
113
+ ITERATION_METHODS.include?(node.children[0].children[1])
146
114
  end
147
115
 
148
116
  def add_query_issue(node, method_name, callback_type)
@@ -189,6 +157,13 @@ module EagerEye
189
157
  false
190
158
  end
191
159
  end
160
+
161
+ def contains_ar_query_on_variable?(node, block_var)
162
+ return false unless node.is_a?(Parser::AST::Node)
163
+ return true if query_call?(node) && receiver_chain_starts_with?(node.children[0], block_var)
164
+
165
+ node.children.any? { |child| contains_ar_query_on_variable?(child, block_var) }
166
+ end
192
167
  end
193
168
  end
194
169
  end
@@ -3,14 +3,8 @@
3
3
  module EagerEye
4
4
  module Detectors
5
5
  class CountInIteration < Base
6
- # count always executes a COUNT query
7
- # size and length use memory when collection is loaded
8
6
  COUNT_METHODS = %i[count].freeze
9
-
10
- ITERATION_METHODS = %i[
11
- each map select find_all reject collect
12
- each_with_index each_with_object flat_map
13
- ].freeze
7
+ ITERATION_METHODS = %i[each map select find_all reject collect each_with_index each_with_object flat_map].freeze
14
8
 
15
9
  def self.detector_name
16
10
  :count_in_iteration
@@ -36,74 +30,48 @@ module EagerEye
36
30
 
37
31
  if iteration_block?(node)
38
32
  block_var = extract_block_variable(node)
39
- block_body = extract_block_body(node)
33
+ block_body = node.children[2]
40
34
  yield(block_body, block_var) if block_var && block_body
41
35
  end
42
36
 
43
- node.children.each do |child|
44
- find_iteration_blocks(child, &block)
45
- end
37
+ node.children.each { |child| find_iteration_blocks(child, &block) }
46
38
  end
47
39
 
48
40
  def iteration_block?(node)
49
- return false unless node.type == :block
50
-
51
- send_node = node.children[0]
52
- return false unless send_node&.type == :send
53
-
54
- method_name = send_node.children[1]
55
- ITERATION_METHODS.include?(method_name)
41
+ node.type == :block && node.children[0]&.type == :send &&
42
+ ITERATION_METHODS.include?(node.children[0].children[1])
56
43
  end
57
44
 
58
45
  def check_for_count_calls(node, block_var)
59
46
  return unless node.is_a?(Parser::AST::Node)
60
47
 
61
48
  add_issue(node) if count_on_association?(node, block_var)
62
-
63
- node.children.each do |child|
64
- check_for_count_calls(child, block_var)
65
- end
49
+ node.children.each { |child| check_for_count_calls(child, block_var) }
66
50
  end
67
51
 
68
52
  def count_on_association?(node, block_var)
69
- return false unless node.type == :send
70
-
71
- method_name = node.children[1]
72
- return false unless COUNT_METHODS.include?(method_name)
73
-
74
- receiver = node.children[0]
75
- association_call_on_block_var?(receiver, block_var)
53
+ node.type == :send && COUNT_METHODS.include?(node.children[1]) &&
54
+ association_call_on_block_var?(node.children[0], block_var)
76
55
  end
77
56
 
78
57
  def association_call_on_block_var?(node, block_var)
79
- return false unless node.is_a?(Parser::AST::Node)
80
- return false unless node.type == :send
58
+ return false unless node.is_a?(Parser::AST::Node) && node.type == :send
81
59
 
82
60
  receiver = node.children[0]
83
61
  return false unless receiver.is_a?(Parser::AST::Node)
84
62
 
85
- # post.comments.count -> receiver is post.comments
86
- # post.comments -> receiver is post (lvar)
87
- if receiver.type == :lvar && receiver.children[0] == block_var
88
- true
89
- elsif receiver.type == :send
90
- # Nested: post.author.posts.count
91
- chain_starts_with_block_var?(receiver, block_var)
92
- else
93
- false
94
- end
63
+ return true if receiver.type == :lvar && receiver.children[0] == block_var
64
+
65
+ receiver.type == :send && chain_starts_with_block_var?(receiver, block_var)
95
66
  end
96
67
 
97
68
  def chain_starts_with_block_var?(node, block_var)
98
69
  return false unless node.is_a?(Parser::AST::Node)
99
70
 
100
71
  case node.type
101
- when :lvar
102
- node.children[0] == block_var
103
- when :send
104
- chain_starts_with_block_var?(node.children[0], block_var)
105
- else
106
- false
72
+ when :lvar then node.children[0] == block_var
73
+ when :send then chain_starts_with_block_var?(node.children[0], block_var)
74
+ else false
107
75
  end
108
76
  end
109
77
 
@@ -112,13 +80,7 @@ module EagerEye
112
80
  return nil unless args_node&.type == :args
113
81
 
114
82
  first_arg = args_node.children[0]
115
- return nil unless first_arg&.type == :arg
116
-
117
- first_arg.children[0]
118
- end
119
-
120
- def extract_block_body(block_node)
121
- block_node.children[2]
83
+ first_arg&.type == :arg ? first_arg.children[0] : nil
122
84
  end
123
85
 
124
86
  def add_issue(node)