eager_eye 1.0.8 → 1.0.10

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: 0b2e83de186394077d36507bd9c14dbb356777bddff49689c602b43dc8d74b80
4
- data.tar.gz: 5ee79306b8bd9f602e9f09ed1c62bf5adb5920d57104c486d8799a757458eb6f
3
+ metadata.gz: 27aed443ecc5e83aeb4fe0baa9aa9ccbcec462df45767c8294d867562c9066b5
4
+ data.tar.gz: f2a9f245154a8fe043d9a9f0f68edaf940a5c3f8e2bd3f0255962603ef48d4d3
5
5
  SHA512:
6
- metadata.gz: 36d9dabec10f32dcf21c88efdd5125e5f7146cf61e0e500adc4f3c5b57608303cf0002d7ac48c32eb87b8f163fa6c3125d53bce4e052e9c1dd178f244b69e2a3
7
- data.tar.gz: 6bc77a99dd29179c9179010ff1c6bb195048115b3b72e545ec51d1af1d286a430a35b223a138fc78e66dbb0172d9e2ff2f39c9f9639be9edc97a47eb928e3df6
6
+ metadata.gz: f6f769f5d65827fd4059671674f8947f172b1ef9ff72a82375c25ac3e31c35c81099384e79de97036b8c867613343fd2e078f7c23f59fd68bf5daac8cd8a8a19
7
+ data.tar.gz: f6c0743373d769ec6d147ccb1045b6a667d6015551a705b31eaefe6fc511568ecf06683cb99d7034f2d59cd83997732096d8e7c12b732b42c6ee460b9d152b5c
data/CHANGELOG.md CHANGED
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.10] - 2025-12-26
11
+
12
+ ### Changed
13
+
14
+ - **PluckToArray Severity Levels** - Differentiate between scoped and unscoped pluck usage
15
+ - Scoped `.pluck(:id)` (e.g., `User.active.pluck(:id)`) → **Warning** (default)
16
+ - Unscoped `.all.pluck(:id)` → **Error** (loads entire table into memory)
17
+ - Small arrays with scoped pluck may be acceptable - users can suppress with comments
18
+
19
+ ### Added
20
+
21
+ - **Improved PluckToArray Detection** - Now detects and prioritizes `.all.pluck` patterns
22
+ - Tracks `.all.pluck(:id)` as critical issues (error severity)
23
+ - Detects both variable assignments and inline usage
24
+ - Better suggestions for high-priority cases
25
+
26
+ ## [1.0.9] - 2025-12-26
27
+
28
+ ### Added
29
+
30
+ - **Inline Suppression Comments** - Suppress specific warnings with RuboCop-style inline comments
31
+ - `# eager_eye:disable-next-line` - Suppress next line
32
+ - `# eager_eye:disable CallbackQuery` - Suppress specific detector inline
33
+ - `# eager_eye:disable-block` / `# eager_eye:enable-block` - Suppress block of code
34
+ - `# eager_eye:disable-file DetectorName` - Suppress entire file
35
+ - Supports both CamelCase and snake_case detector names
36
+ - Can disable all detectors with `all` keyword
37
+
10
38
  ## [1.0.8] - 2025-12-25
11
39
 
12
40
  ### 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.0.8-red.svg" alt="Gem Version"></a>
13
+ <a href="https://rubygems.org/gems/eager_eye"><img src="https://img.shields.io/badge/gem-v1.0.10-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>
@@ -23,23 +23,44 @@
23
23
 
24
24
  ---
25
25
 
26
- ## Why EagerEye?
27
-
28
- Unlike runtime tools like Bullet, EagerEye:
29
-
30
- - **Runs without executing code** - Works in CI pipelines without a test suite
31
- - **Catches more patterns** - Detects serializer N+1s, missing counter caches, and query methods in loops
32
- - **Proactive detection** - Finds issues at code review time, not after deployment
33
-
34
- | Feature | EagerEye | Bullet |
35
- |---------|----------|--------|
36
- | Detection method | Static analysis | Runtime |
37
- | Requires test suite | No | Yes |
38
- | Serializer N+1 detection | Yes | Limited |
39
- | Counter cache suggestions | Yes | No |
40
- | Query methods in loops | Yes | No |
41
- | CI integration | Native | Requires tests |
42
- | False positive rate | Higher | Lower |
26
+ ## Table of Contents
27
+ - [Features](#features)
28
+ - [Installation](#installation)
29
+ - [Quick Start](#quick-start)
30
+ - [Detected Issues](#detected-issues)
31
+ - [Inline Suppression](#inline-suppression)
32
+ - [Auto-fix](#auto-fix-experimental)
33
+ - [RSpec Integration](#rspec-integration)
34
+ - [Configuration](#configuration)
35
+ - [CI Integration](#ci-integration)
36
+ - [CLI Reference](#cli-reference)
37
+ - [Output Formats](#output-formats)
38
+ - [Limitations](#limitations)
39
+ - [VS Code Extension](#vs-code-extension)
40
+ - [Development](#development)
41
+ - [Contributing](#contributing)
42
+
43
+ ## Features
44
+
45
+ ✨ **Detects 7 types of N+1 problems:**
46
+ - Loop associations (queries in iterations)
47
+ - Serializer nesting issues
48
+ - Missing counter caches
49
+ - Custom method queries
50
+ - Count in iteration patterns
51
+ - Callback query N+1s
52
+ - Pluck to array misuse
53
+
54
+ 🔧 **Developer-friendly:**
55
+ - Inline suppression (like RuboCop)
56
+ - Auto-fix support (experimental)
57
+ - JSON/Console output formats
58
+ - RSpec integration
59
+
60
+ 🚀 **CI-ready:**
61
+ - No test suite required
62
+ - GitHub Actions examples included
63
+ - Severity levels and filtering
43
64
 
44
65
  ## Installation
45
66
 
@@ -186,10 +207,10 @@ end
186
207
 
187
208
  ### 4. Custom Method Query (N+1 in query methods)
188
209
 
189
- Detects query methods (`.where`, `.find_by`, `.exists?`, etc.) called on associations inside loops. **These patterns are invisible to Bullet.**
210
+ Detects query methods (`.where`, `.find_by`, `.exists?`, etc.) called on associations inside loops.
190
211
 
191
212
  ```ruby
192
- # Bad - Bullet CANNOT catch this
213
+ # Bad - where inside loop
193
214
  class User < ApplicationRecord
194
215
  def supports?(team_name)
195
216
  teams.where(name: team_name).exists?
@@ -296,6 +317,10 @@ user_ids = User.active.pluck(:id) # Query 1: SELECT id FROM users
296
317
  Post.where(user_id: user_ids) # Query 2: SELECT * FROM posts WHERE user_id IN (1,2,3...)
297
318
  # Also holds potentially thousands of IDs in memory
298
319
 
320
+ # Worse - Loads entire table into memory! (REPORTED AS ERROR)
321
+ user_ids = User.all.pluck(:id) # Query 1: SELECT id FROM users -- ENTIRE TABLE!
322
+ Post.where(user_id: user_ids) # Query 2: SELECT * FROM posts WHERE user_id IN (...)
323
+
299
324
  # Bad - Same problem with map
300
325
  user_ids = users.map(&:id)
301
326
  Post.where(user_id: user_ids)
@@ -305,11 +330,22 @@ Post.where(user_id: User.active.select(:id))
305
330
  # Single query: SELECT * FROM posts WHERE user_id IN (SELECT id FROM users WHERE active = true)
306
331
  ```
307
332
 
333
+ **Severity Levels:**
334
+
335
+ - ⚠️ **Warning (default)** - Scoped `.pluck(:id)` (e.g., `User.active.pluck(:id)`)
336
+ - Two queries and memory overhead with moderately-sized arrays
337
+ - Small arrays may be acceptable
338
+
339
+ - 🔴 **Error** - Unscoped `.all.pluck(:id)` (e.g., `User.all.pluck(:id)`)
340
+ - Loads entire table into memory
341
+ - Highly inefficient and should always be refactored
342
+
308
343
  **Performance comparison with 10,000 users:**
309
344
 
310
345
  | Approach | Queries | Memory | Time |
311
346
  |----------|---------|--------|------|
312
347
  | `pluck` + `where` | 2 | ~80KB for IDs | ~45ms |
348
+ | `.all.pluck` + `where` | 2 | ~40KB+ for all IDs | ~100ms+ |
313
349
  | `select` subquery | 1 | None | ~20ms |
314
350
 
315
351
  ## Inline Suppression
@@ -2,12 +2,12 @@
2
2
 
3
3
  module EagerEye
4
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
5
  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
6
+ NEXT_LINE_PATTERN = /eager_eye:disable-next-line(?:\s+(.+?))?(?:\s+--|$)/i
7
+ BLOCK_START_PATTERN = /eager_eye:disable-block(?:\s+(.+?))?(?:\s+--|$)/i
8
+ BLOCK_END_PATTERN = /eager_eye:enable-block(?:\s+(.+?))?(?:\s+--|$)/i
9
+ INLINE_DISABLE_PATTERN = /eager_eye:disable\s+(.+?)(?:\s+--|$)/i
10
+ ENABLE_PATTERN = /eager_eye:enable(?:\s+(.+?))?(?:\s+--|$)/i
11
11
 
12
12
  def initialize(source_code)
13
13
  @source_code = source_code.encode("UTF-8", invalid: :replace, undef: :replace)
@@ -46,39 +46,46 @@ module EagerEye
46
46
  def detect_directive(line, line_num)
47
47
  detect_file_directive(line, line_num) ||
48
48
  detect_next_line_directive(line) ||
49
- detect_block_directive(line) ||
50
- detect_enable_directive(line) ||
51
- detect_inline_directive(line)
49
+ detect_block_end_directive(line) ||
50
+ detect_block_or_inline_directive(line)
52
51
  end
53
52
 
54
53
  def detect_file_directive(line, line_num)
55
54
  return unless line_num <= 5 && line =~ FILE_DISABLE_PATTERN
56
55
 
57
- { type: :file, detectors: parse_detector_names(::Regexp.last_match(1)) }
56
+ detectors = parse_detector_names(::Regexp.last_match(1) || "all")
57
+ { type: :file, detectors: detectors }
58
58
  end
59
59
 
60
60
  def detect_next_line_directive(line)
61
61
  return unless line =~ NEXT_LINE_PATTERN
62
62
 
63
- { type: :next_line, detectors: parse_detector_names(::Regexp.last_match(1)) }
63
+ detectors = parse_detector_names(::Regexp.last_match(1) || "all")
64
+ { type: :next_line, detectors: detectors }
64
65
  end
65
66
 
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)
67
+ def detect_block_end_directive(line)
73
68
  return unless line =~ ENABLE_PATTERN
74
69
 
75
- { type: :block_end, detectors: parse_detector_names(::Regexp.last_match(1)) }
70
+ detectors = parse_detector_names(::Regexp.last_match(1) || "all")
71
+ { type: :block_end, detectors: detectors }
76
72
  end
77
73
 
78
- def detect_inline_directive(line)
74
+ def detect_block_or_inline_directive(line)
75
+ if line =~ BLOCK_START_PATTERN && !code_before_comment?(line)
76
+ detectors = parse_detector_names(::Regexp.last_match(1) || "all")
77
+ return { type: :block_start, detectors: detectors }
78
+ end
79
+
79
80
  return unless line =~ INLINE_DISABLE_PATTERN
80
81
 
81
- { type: :inline, detectors: parse_detector_names(::Regexp.last_match(1)) }
82
+ detectors = parse_detector_names(::Regexp.last_match(1) || "all")
83
+
84
+ if code_before_comment?(line)
85
+ { type: :inline, detectors: detectors }
86
+ else
87
+ { type: :block_start, detectors: detectors }
88
+ end
82
89
  end
83
90
 
84
91
  def apply_directive(directive, line_num)
@@ -129,7 +136,14 @@ module EagerEye
129
136
  code_part && !code_part.strip.empty?
130
137
  end
131
138
 
139
+ def code_before_comment?(line)
140
+ code_part = line.split("#").first
141
+ code_part && !code_part.strip.empty?
142
+ end
143
+
132
144
  def parse_detector_names(str)
145
+ return ["all"] if str.nil? || str.strip.empty?
146
+
133
147
  str.split(/[,\s]+/).map(&:strip).reject(&:empty?).map do |name|
134
148
  normalize_detector_name(name)
135
149
  end
@@ -12,126 +12,136 @@ module EagerEye
12
12
  @file_path = file_path
13
13
  @pluck_variables = {}
14
14
  @map_id_variables = {}
15
+ @critical_pluck_variables = {}
15
16
 
16
17
  return @issues unless ast
17
18
 
18
- collect_pluck_assignments(ast)
19
- collect_map_id_assignments(ast)
20
- find_where_with_pluck_var(ast)
21
-
19
+ visit(ast)
22
20
  @issues
23
21
  end
24
22
 
25
23
  private
26
24
 
27
- def collect_pluck_assignments(node)
25
+ def visit(node)
28
26
  return unless node.is_a?(Parser::AST::Node)
29
27
 
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
28
+ collect_assignments(node)
29
+ check_where_calls(node)
36
30
 
37
- node.children.each do |child|
38
- collect_pluck_assignments(child)
39
- end
31
+ node.children.each { |child| visit(child) }
40
32
  end
41
33
 
42
- def collect_map_id_assignments(node)
43
- return unless node.is_a?(Parser::AST::Node)
34
+ def collect_assignments(node)
35
+ return unless local_variable_assignment?(node)
44
36
 
45
- if local_variable_assignment?(node)
46
- var_name = node.children[0]
47
- value = node.children[1]
37
+ var_name = node.children[0]
38
+ value = node.children[1]
48
39
 
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
40
+ @critical_pluck_variables[var_name] = node.loc.line if all_pluck_call?(value)
41
+ @pluck_variables[var_name] = node.loc.line if pluck_call?(value)
42
+ @map_id_variables[var_name] = node.loc.line if map_id_call?(value)
55
43
  end
56
44
 
57
- def find_where_with_pluck_var(node)
58
- return unless node.is_a?(Parser::AST::Node)
45
+ def check_where_calls(node)
46
+ return unless where_call?(node)
59
47
 
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
48
+ add_critical_issue(node) if critical_pluck?(node)
49
+ add_issue(node) if regular_pluck?(node)
65
50
  end
66
51
 
67
52
  def local_variable_assignment?(node)
68
53
  node.type == :lvasgn
69
54
  end
70
55
 
56
+ def where_call?(node)
57
+ node.type == :send && node.children[1] == :where
58
+ end
59
+
71
60
  def pluck_call?(node)
72
- return false unless node.is_a?(Parser::AST::Node)
73
- return false unless node.type == :send
61
+ return false unless node.is_a?(Parser::AST::Node) && node.type == :send
74
62
 
75
- method_name = node.children[1]
76
- %i[pluck ids].include?(method_name)
63
+ method = node.children[1]
64
+ %i[pluck ids].include?(method)
65
+ end
66
+
67
+ def all_pluck_call?(node)
68
+ return false unless pluck_call?(node)
69
+
70
+ receiver = node.children[0]
71
+ receiver.is_a?(Parser::AST::Node) && receiver.type == :send &&
72
+ receiver.children[1] == :all
77
73
  end
78
74
 
79
75
  def map_id_call?(node)
80
76
  return false unless node.is_a?(Parser::AST::Node)
81
77
 
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
78
+ block_map?(node) || send_map?(node)
87
79
  end
88
80
 
89
- def block_map_call?(node)
90
- send_node = node.children[0]
91
- return false unless send_node&.type == :send
81
+ def block_map?(node)
82
+ return false unless node.type == :block
92
83
 
93
- %i[map collect].include?(send_node.children[1])
84
+ send_node = node.children[0]
85
+ send_node&.type == :send && %i[map collect].include?(send_node.children[1])
94
86
  end
95
87
 
96
- def send_map_id_call?(node)
97
- method_name = node.children[1]
98
- return false unless %i[map collect].include?(method_name)
88
+ def send_map?(node)
89
+ return false unless node.type == :send
99
90
 
100
- node.children[2..].any? { |arg| symbol_to_proc_id?(arg) }
91
+ method = node.children[1]
92
+ %i[map collect].include?(method) &&
93
+ node.children[2..].any? { |arg| symbol_to_proc_id?(arg) }
101
94
  end
102
95
 
103
96
  def symbol_to_proc_id?(node)
104
- return false unless node.is_a?(Parser::AST::Node)
105
- return false unless node.type == :block_pass
97
+ return false unless node.is_a?(Parser::AST::Node) && node.type == :block_pass
106
98
 
107
- sym_node = node.children[0]
108
- return false unless sym_node&.type == :sym
99
+ sym = node.children[0]
100
+ sym&.type == :sym && %i[id to_i].include?(sym.children[0])
101
+ end
109
102
 
110
- %i[id to_i].include?(sym_node.children[0])
103
+ def regular_pluck?(node)
104
+ where_args = node.children[2..]
105
+ where_args.any? { |arg| pluck_var_in_hash?(arg) }
111
106
  end
112
107
 
113
- def where_call_with_pluck_var?(node)
114
- return false unless node.type == :send
115
- return false unless node.children[1] == :where
108
+ def critical_pluck?(node)
109
+ where_args = node.children[2..]
110
+ where_args.any? { |arg| critical_pluck_in_hash?(arg) }
111
+ end
112
+
113
+ def pluck_var_in_hash?(node)
114
+ return false unless node.is_a?(Parser::AST::Node) && node.type == :hash
116
115
 
117
- args = node.children[2..]
118
- args.any? { |arg| hash_with_pluck_var?(arg) }
116
+ node.children.any? do |pair|
117
+ next false unless pair.type == :pair
118
+
119
+ pluck_value?(pair.children[1])
120
+ end
119
121
  end
120
122
 
121
- def hash_with_pluck_var?(node)
122
- return false unless node.is_a?(Parser::AST::Node)
123
- return false unless node.type == :hash
123
+ def critical_pluck_in_hash?(node)
124
+ return false unless node.is_a?(Parser::AST::Node) && node.type == :hash
124
125
 
125
126
  node.children.any? do |pair|
126
127
  next false unless pair.type == :pair
127
128
 
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
129
+ critical_value?(pair.children[1])
130
+ end
131
+ end
132
+
133
+ def pluck_value?(value)
134
+ return false unless value.type == :lvar
135
+
136
+ var_name = value.children[0]
137
+ @pluck_variables.key?(var_name) || @map_id_variables.key?(var_name)
138
+ end
139
+
140
+ def critical_value?(value)
141
+ if value.type == :lvar
142
+ @critical_pluck_variables.key?(value.children[0])
143
+ else
144
+ all_pluck_call?(value)
135
145
  end
136
146
  end
137
147
 
@@ -140,7 +150,18 @@ module EagerEye
140
150
  file_path: @file_path,
141
151
  line_number: node.loc.line,
142
152
  message: "Using plucked/mapped array in `where` causes two queries and holds IDs in memory",
143
- suggestion: "Use `.select(:id)` subquery: `Model.where(col: OtherModel.condition.select(:id))`"
153
+ suggestion: "Use `.select(:id)` subquery: `Model.where(col: OtherModel.condition.select(:id))`",
154
+ severity: :warning
155
+ )
156
+ end
157
+
158
+ def add_critical_issue(node)
159
+ @issues << create_issue(
160
+ file_path: @file_path,
161
+ line_number: node.loc.line,
162
+ message: "Using `.all.pluck(:id)` in `where` loads entire table into memory and is highly inefficient",
163
+ suggestion: "Use `.select(:id)` subquery instead: `Model.where(col: OtherModel.select(:id))`",
164
+ severity: :error
144
165
  )
145
166
  end
146
167
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EagerEye
4
- VERSION = "1.0.8"
4
+ VERSION = "1.0.10"
5
5
  end
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.0.8
4
+ version: 1.0.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - hamzagedikkaya
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-12-25 00:00:00.000000000 Z
11
+ date: 2025-12-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ast