eager_eye 0.4.0 → 0.6.0

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: a971d7fbafebc02b51a9767987541f67fdd216025ac04aa4bdf4f304cc3abad9
4
- data.tar.gz: db6eeeb3e1f511856144c70031f63c5216c73b917c00a17a31dbcd44444757a8
3
+ metadata.gz: 34e0c9e78e18d9d8c9a6cad8ee96a91c89ac469d9dc662e43a51f09d361a3200
4
+ data.tar.gz: 90554fbbe09380116398c8139c6d09ca5851d1e12d2eb52c32cf85f8c8fdc76a
5
5
  SHA512:
6
- metadata.gz: b847b72aff2e758f0a32effdf68057c499503f97251802263d5a1109e5eac3afd8841044d5d144a48b351c4c0909789e5528574f911101f3949e8f78d015dd0a
7
- data.tar.gz: 3938004802489b63234cc75ec7cfc79c580a0cf263c004762a63fb2d4d1a09350f49f158ab9f4e99cbe4a11b9a8772766be61531c1909d958bed5b82141b407c
6
+ metadata.gz: 86605c1632ed47aaae05e26d810c6611dac2f356503502b71a94a389b1d58148cbb5146e5e6cbc5e9f809f4b6bf2b78d983cbf8484c0bab6d1e9a046ab0a242b
7
+ data.tar.gz: bb3e07e2b8e799067aa475f6b8ffea6a303e797e3eac252f46f597eec751f42451f91fd97eacf024aae8a6129267ef2b425fdd26163a6b893714cb22c2d9ddfa
data/CHANGELOG.md CHANGED
@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2025-12-15
11
+
12
+ ### Added
13
+
14
+ - **Inline Suppression Comments** - RuboCop-style comment directives for suppressing false positives
15
+ - `# eager_eye:disable DetectorName` - Disable for single line (inline) or start block
16
+ - `# eager_eye:disable-next-line DetectorName` - Disable only the next line
17
+ - `# eager_eye:disable-file DetectorName` - Disable for entire file (must be in first 5 lines)
18
+ - `# eager_eye:enable DetectorName` - End a disable block
19
+ - Support for multiple detectors: `# eager_eye:disable LoopAssociation, CountInIteration`
20
+ - Support for reason comments: `# eager_eye:disable DetectorName -- reason here`
21
+ - `all` keyword to disable all detectors at once
22
+ - Both CamelCase (`LoopAssociation`) and snake_case (`loop_association`) detector names accepted
23
+
24
+ ### Changed
25
+
26
+ - Updated README with inline suppression documentation
27
+ - Added `CommentParser` module for parsing suppression directives
28
+
29
+ ## [0.5.0] - 2025-12-15
30
+
31
+ ### Added
32
+
33
+ - **New Detector: `PluckToArray`** - Detects pluck/map results used in where clauses
34
+ - Catches `.pluck(:id)` and `.ids` results used in `where` clauses
35
+ - Catches `.map(&:id)` and `.collect(&:id)` patterns
36
+ - Suggests using `.select(:id)` subquery pattern for better performance
37
+ - Prevents two queries and memory overhead from holding IDs in arrays
38
+
39
+ ### Changed
40
+
41
+ - Updated default `enabled_detectors` to include `:pluck_to_array`
42
+ - Updated README with new detector documentation and performance comparison
43
+
10
44
  ## [0.4.0] - 2025-12-15
11
45
 
12
46
  ### Added
data/README.md CHANGED
@@ -246,6 +246,76 @@ def schedule_stats_update
246
246
  end
247
247
  ```
248
248
 
249
+ ### 7. Pluck to Array Misuse
250
+
251
+ Detects when `.pluck(:id)` or `.map(&:id)` results are used in `where` clauses instead of subqueries.
252
+
253
+ ```ruby
254
+ # Bad - Two queries + memory overhead
255
+ user_ids = User.active.pluck(:id) # Query 1: SELECT id FROM users
256
+ Post.where(user_id: user_ids) # Query 2: SELECT * FROM posts WHERE user_id IN (1,2,3...)
257
+ # Also holds potentially thousands of IDs in memory
258
+
259
+ # Bad - Same problem with map
260
+ user_ids = users.map(&:id)
261
+ Post.where(user_id: user_ids)
262
+
263
+ # Good - Single subquery, no memory overhead
264
+ Post.where(user_id: User.active.select(:id))
265
+ # Single query: SELECT * FROM posts WHERE user_id IN (SELECT id FROM users WHERE active = true)
266
+ ```
267
+
268
+ **Performance comparison with 10,000 users:**
269
+
270
+ | Approach | Queries | Memory | Time |
271
+ |----------|---------|--------|------|
272
+ | `pluck` + `where` | 2 | ~80KB for IDs | ~45ms |
273
+ | `select` subquery | 1 | None | ~20ms |
274
+
275
+ ## Inline Suppression
276
+
277
+ Suppress false positives using inline comments (RuboCop-style):
278
+
279
+ ```ruby
280
+ # Disable for single line
281
+ user.posts.count # eager_eye:disable CountInIteration
282
+
283
+ # Disable for next line
284
+ # eager_eye:disable-next-line LoopAssociation
285
+ @users.each { |u| u.profile }
286
+
287
+ # Disable block
288
+ # eager_eye:disable LoopAssociation, SerializerNesting
289
+ @users.each do |user|
290
+ user.posts.each { |p| p.author }
291
+ end
292
+ # eager_eye:enable LoopAssociation, SerializerNesting
293
+
294
+ # Disable entire file (must be in first 5 lines)
295
+ # eager_eye:disable-file CustomMethodQuery
296
+
297
+ # With reason
298
+ user.posts.count # eager_eye:disable CountInIteration -- using counter_cache
299
+
300
+ # Disable all detectors
301
+ # eager_eye:disable all
302
+ ```
303
+
304
+ ### Available Detector Names
305
+
306
+ Both CamelCase and snake_case formats are accepted:
307
+
308
+ | Detector | CamelCase | snake_case |
309
+ |----------|-----------|------------|
310
+ | Loop Association | `LoopAssociation` | `loop_association` |
311
+ | Serializer Nesting | `SerializerNesting` | `serializer_nesting` |
312
+ | Missing Counter Cache | `MissingCounterCache` | `missing_counter_cache` |
313
+ | Custom Method Query | `CustomMethodQuery` | `custom_method_query` |
314
+ | Count in Iteration | `CountInIteration` | `count_in_iteration` |
315
+ | Callback Query | `CallbackQuery` | `callback_query` |
316
+ | Pluck to Array | `PluckToArray` | `pluck_to_array` |
317
+ | All Detectors | `all` | `all` |
318
+
249
319
  ## Configuration
250
320
 
251
321
  ### Config File (.eager_eye.yml)
@@ -264,6 +334,7 @@ enabled_detectors:
264
334
  - custom_method_query
265
335
  - count_in_iteration
266
336
  - callback_query
337
+ - pluck_to_array
267
338
 
268
339
  # Base path to analyze (default: app)
269
340
  app_path: app
@@ -10,7 +10,8 @@ module EagerEye
10
10
  missing_counter_cache: Detectors::MissingCounterCache,
11
11
  custom_method_query: Detectors::CustomMethodQuery,
12
12
  count_in_iteration: Detectors::CountInIteration,
13
- callback_query: Detectors::CallbackQuery
13
+ callback_query: Detectors::CallbackQuery,
14
+ pluck_to_array: Detectors::PluckToArray
14
15
  }.freeze
15
16
 
16
17
  attr_reader :paths, :issues
@@ -57,8 +58,16 @@ module EagerEye
57
58
  ast = parse_source(source)
58
59
  return unless ast
59
60
 
61
+ comment_parser = CommentParser.new(source)
62
+
60
63
  enabled_detectors.each do |detector|
61
64
  file_issues = detector.detect(ast, file_path)
65
+
66
+ # Filter suppressed issues
67
+ file_issues.reject! do |issue|
68
+ comment_parser.disabled_at?(issue.line_number, issue.detector)
69
+ end
70
+
62
71
  @issues.concat(file_issues)
63
72
  end
64
73
  rescue Errno::ENOENT, Errno::EACCES => e
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EagerEye
4
+ class CommentParser
5
+ DISABLE_PATTERN = /eager_eye:disable(?:-next-line|-file)?\s+(.+?)(?:\s+--|$)/i
6
+ ENABLE_PATTERN = /eager_eye:enable\s+(.+?)(?:\s+--|$)/i
7
+ INLINE_DISABLE_PATTERN = /eager_eye:disable\s+(.+?)(?:\s+--|$)/i
8
+ FILE_DISABLE_PATTERN = /eager_eye:disable-file\s+(.+?)(?:\s+--|$)/i
9
+ NEXT_LINE_PATTERN = /eager_eye:disable-next-line\s+(.+?)(?:\s+--|$)/i
10
+ BLOCK_DISABLE_PATTERN = /eager_eye:disable\s+(.+?)(?:\s+--|$)/i
11
+
12
+ def initialize(source_code)
13
+ @source_code = source_code
14
+ @lines = source_code.lines
15
+ @disabled_ranges = Hash.new { |h, k| h[k] = [] }
16
+ @file_disabled = Set.new
17
+ @current_disabled = Set.new
18
+ parse_comments
19
+ end
20
+
21
+ def disabled_at?(line_number, detector_name)
22
+ return true if @file_disabled.include?(detector_name.to_s)
23
+ return true if @file_disabled.include?("all")
24
+
25
+ detector = detector_name.to_s
26
+ @disabled_ranges[detector].any? { |range| range.cover?(line_number) } ||
27
+ @disabled_ranges["all"].any? { |range| range.cover?(line_number) }
28
+ end
29
+
30
+ private
31
+
32
+ def parse_comments
33
+ @lines.each_with_index do |line, index|
34
+ line_num = index + 1
35
+ process_line(line, line_num)
36
+ end
37
+
38
+ close_unclosed_blocks
39
+ end
40
+
41
+ def process_line(line, line_num)
42
+ directive = detect_directive(line, line_num)
43
+ apply_directive(directive, line_num) if directive
44
+ end
45
+
46
+ def detect_directive(line, line_num)
47
+ detect_file_directive(line, line_num) ||
48
+ detect_next_line_directive(line) ||
49
+ detect_block_directive(line) ||
50
+ detect_enable_directive(line) ||
51
+ detect_inline_directive(line)
52
+ end
53
+
54
+ def detect_file_directive(line, line_num)
55
+ return unless line_num <= 5 && line =~ FILE_DISABLE_PATTERN
56
+
57
+ { type: :file, detectors: parse_detector_names(::Regexp.last_match(1)) }
58
+ end
59
+
60
+ def detect_next_line_directive(line)
61
+ return unless line =~ NEXT_LINE_PATTERN
62
+
63
+ { type: :next_line, detectors: parse_detector_names(::Regexp.last_match(1)) }
64
+ end
65
+
66
+ def detect_block_directive(line)
67
+ return unless line =~ BLOCK_DISABLE_PATTERN && !inline_disable?(line)
68
+
69
+ { type: :block_start, detectors: parse_detector_names(::Regexp.last_match(1)) }
70
+ end
71
+
72
+ def detect_enable_directive(line)
73
+ return unless line =~ ENABLE_PATTERN
74
+
75
+ { type: :block_end, detectors: parse_detector_names(::Regexp.last_match(1)) }
76
+ end
77
+
78
+ def detect_inline_directive(line)
79
+ return unless line =~ INLINE_DISABLE_PATTERN
80
+
81
+ { type: :inline, detectors: parse_detector_names(::Regexp.last_match(1)) }
82
+ end
83
+
84
+ def apply_directive(directive, line_num)
85
+ case directive[:type]
86
+ when :file then apply_file_disable(directive[:detectors])
87
+ when :next_line then apply_next_line_disable(directive[:detectors], line_num)
88
+ when :block_start then apply_block_start(directive[:detectors], line_num)
89
+ when :block_end then apply_block_end(directive[:detectors], line_num)
90
+ when :inline then apply_inline_disable(directive[:detectors], line_num)
91
+ end
92
+ end
93
+
94
+ def apply_file_disable(detectors)
95
+ @file_disabled.merge(detectors)
96
+ end
97
+
98
+ def apply_next_line_disable(detectors, line_num)
99
+ next_line = line_num + 1
100
+ detectors.each { |d| @disabled_ranges[d] << (next_line..next_line) }
101
+ end
102
+
103
+ def apply_block_start(detectors, line_num)
104
+ detectors.each { |d| @current_disabled << { detector: d, start: line_num } }
105
+ end
106
+
107
+ def apply_block_end(detectors, line_num)
108
+ detectors.each do |d|
109
+ entry = @current_disabled.find { |e| e[:detector] == d }
110
+ next unless entry
111
+
112
+ @disabled_ranges[d] << (entry[:start]..line_num)
113
+ @current_disabled.delete(entry)
114
+ end
115
+ end
116
+
117
+ def apply_inline_disable(detectors, line_num)
118
+ detectors.each { |d| @disabled_ranges[d] << (line_num..line_num) }
119
+ end
120
+
121
+ def close_unclosed_blocks
122
+ @current_disabled.each do |entry|
123
+ @disabled_ranges[entry[:detector]] << (entry[:start]..@lines.size)
124
+ end
125
+ end
126
+
127
+ def inline_disable?(line)
128
+ code_part = line.split("#").first
129
+ code_part && !code_part.strip.empty?
130
+ end
131
+
132
+ def parse_detector_names(str)
133
+ str.split(/[,\s]+/).map(&:strip).reject(&:empty?).map do |name|
134
+ normalize_detector_name(name)
135
+ end
136
+ end
137
+
138
+ def normalize_detector_name(name)
139
+ name.gsub(/([A-Z])/) { "_#{::Regexp.last_match(1).downcase}" }
140
+ .sub(/^_/, "")
141
+ .downcase
142
+ end
143
+ end
144
+ end
@@ -7,6 +7,7 @@ module EagerEye
7
7
  DEFAULT_DETECTORS = %i[
8
8
  loop_association serializer_nesting missing_counter_cache
9
9
  custom_method_query count_in_iteration callback_query
10
+ pluck_to_array
10
11
  ].freeze
11
12
 
12
13
  def initialize
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EagerEye
4
+ module Detectors
5
+ class PluckToArray < Base
6
+ def self.detector_name
7
+ :pluck_to_array
8
+ end
9
+
10
+ def detect(ast, file_path)
11
+ @issues = []
12
+ @file_path = file_path
13
+ @pluck_variables = {}
14
+ @map_id_variables = {}
15
+
16
+ return @issues unless ast
17
+
18
+ collect_pluck_assignments(ast)
19
+ collect_map_id_assignments(ast)
20
+ find_where_with_pluck_var(ast)
21
+
22
+ @issues
23
+ end
24
+
25
+ private
26
+
27
+ def collect_pluck_assignments(node)
28
+ return unless node.is_a?(Parser::AST::Node)
29
+
30
+ if local_variable_assignment?(node)
31
+ var_name = node.children[0]
32
+ value = node.children[1]
33
+
34
+ @pluck_variables[var_name] = node.loc.line if pluck_call?(value)
35
+ end
36
+
37
+ node.children.each do |child|
38
+ collect_pluck_assignments(child)
39
+ end
40
+ end
41
+
42
+ def collect_map_id_assignments(node)
43
+ return unless node.is_a?(Parser::AST::Node)
44
+
45
+ if local_variable_assignment?(node)
46
+ var_name = node.children[0]
47
+ value = node.children[1]
48
+
49
+ @map_id_variables[var_name] = node.loc.line if map_id_call?(value)
50
+ end
51
+
52
+ node.children.each do |child|
53
+ collect_map_id_assignments(child)
54
+ end
55
+ end
56
+
57
+ def find_where_with_pluck_var(node)
58
+ return unless node.is_a?(Parser::AST::Node)
59
+
60
+ add_issue(node) if where_call_with_pluck_var?(node)
61
+
62
+ node.children.each do |child|
63
+ find_where_with_pluck_var(child)
64
+ end
65
+ end
66
+
67
+ def local_variable_assignment?(node)
68
+ node.type == :lvasgn
69
+ end
70
+
71
+ def pluck_call?(node)
72
+ return false unless node.is_a?(Parser::AST::Node)
73
+ return false unless node.type == :send
74
+
75
+ method_name = node.children[1]
76
+ %i[pluck ids].include?(method_name)
77
+ end
78
+
79
+ def map_id_call?(node)
80
+ return false unless node.is_a?(Parser::AST::Node)
81
+
82
+ case node.type
83
+ when :block then block_map_call?(node)
84
+ when :send then send_map_id_call?(node)
85
+ else false
86
+ end
87
+ end
88
+
89
+ def block_map_call?(node)
90
+ send_node = node.children[0]
91
+ return false unless send_node&.type == :send
92
+
93
+ %i[map collect].include?(send_node.children[1])
94
+ end
95
+
96
+ def send_map_id_call?(node)
97
+ method_name = node.children[1]
98
+ return false unless %i[map collect].include?(method_name)
99
+
100
+ node.children[2..].any? { |arg| symbol_to_proc_id?(arg) }
101
+ end
102
+
103
+ def symbol_to_proc_id?(node)
104
+ return false unless node.is_a?(Parser::AST::Node)
105
+ return false unless node.type == :block_pass
106
+
107
+ sym_node = node.children[0]
108
+ return false unless sym_node&.type == :sym
109
+
110
+ %i[id to_i].include?(sym_node.children[0])
111
+ end
112
+
113
+ def where_call_with_pluck_var?(node)
114
+ return false unless node.type == :send
115
+ return false unless node.children[1] == :where
116
+
117
+ args = node.children[2..]
118
+ args.any? { |arg| hash_with_pluck_var?(arg) }
119
+ end
120
+
121
+ def hash_with_pluck_var?(node)
122
+ return false unless node.is_a?(Parser::AST::Node)
123
+ return false unless node.type == :hash
124
+
125
+ node.children.any? do |pair|
126
+ next false unless pair.type == :pair
127
+
128
+ value = pair.children[1]
129
+ if value.type == :lvar
130
+ var_name = value.children[0]
131
+ @pluck_variables.key?(var_name) || @map_id_variables.key?(var_name)
132
+ else
133
+ false
134
+ end
135
+ end
136
+ end
137
+
138
+ def add_issue(node)
139
+ @issues << create_issue(
140
+ file_path: @file_path,
141
+ line_number: node.loc.line,
142
+ message: "Using plucked/mapped array in `where` causes two queries and holds IDs in memory",
143
+ severity: :warning,
144
+ suggestion: "Use `.select(:id)` subquery: `Model.where(col: OtherModel.condition.select(:id))`"
145
+ )
146
+ end
147
+ end
148
+ end
149
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EagerEye
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/eager_eye.rb CHANGED
@@ -10,6 +10,8 @@ require_relative "eager_eye/detectors/missing_counter_cache"
10
10
  require_relative "eager_eye/detectors/custom_method_query"
11
11
  require_relative "eager_eye/detectors/count_in_iteration"
12
12
  require_relative "eager_eye/detectors/callback_query"
13
+ require_relative "eager_eye/detectors/pluck_to_array"
14
+ require_relative "eager_eye/comment_parser"
13
15
  require_relative "eager_eye/analyzer"
14
16
  require_relative "eager_eye/reporters/base"
15
17
  require_relative "eager_eye/reporters/console"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: eager_eye
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - hamzagedikkaya
@@ -59,6 +59,7 @@ files:
59
59
  - lib/eager_eye.rb
60
60
  - lib/eager_eye/analyzer.rb
61
61
  - lib/eager_eye/cli.rb
62
+ - lib/eager_eye/comment_parser.rb
62
63
  - lib/eager_eye/configuration.rb
63
64
  - lib/eager_eye/detectors/base.rb
64
65
  - lib/eager_eye/detectors/callback_query.rb
@@ -66,6 +67,7 @@ files:
66
67
  - lib/eager_eye/detectors/custom_method_query.rb
67
68
  - lib/eager_eye/detectors/loop_association.rb
68
69
  - lib/eager_eye/detectors/missing_counter_cache.rb
70
+ - lib/eager_eye/detectors/pluck_to_array.rb
69
71
  - lib/eager_eye/detectors/serializer_nesting.rb
70
72
  - lib/eager_eye/generators/install_generator.rb
71
73
  - lib/eager_eye/issue.rb