eager_eye 1.0.9 → 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: 44b2f46a77bcc0bb742c6d169cebb7be6b9a8b722b64044b153397fcf7686872
4
- data.tar.gz: 25a589c3401fe25248b6fe429e0255e5d2d9ada9b50bd7bbf467ada161e0ca95
3
+ metadata.gz: 27aed443ecc5e83aeb4fe0baa9aa9ccbcec462df45767c8294d867562c9066b5
4
+ data.tar.gz: f2a9f245154a8fe043d9a9f0f68edaf940a5c3f8e2bd3f0255962603ef48d4d3
5
5
  SHA512:
6
- metadata.gz: 2ea1baa8c98a5362527e991d3712c0a3c0d821307a99e1b8b1a9a0ac76fbff5d4adcf4f4cd512990971956fcc3bd61ef97fb8c047d0b44609e4bd55ca385bfb4
7
- data.tar.gz: 8d1590b243420e3ea3534290f15725a0aa70f3ea537aa7da05cd4648521fe284718965d27e4b00ac77ea277d4ca400107e49a18a7759780dd532c560ef1b91b3
6
+ metadata.gz: f6f769f5d65827fd4059671674f8947f172b1ef9ff72a82375c25ac3e31c35c81099384e79de97036b8c867613343fd2e078f7c23f59fd68bf5daac8cd8a8a19
7
+ data.tar.gz: f6c0743373d769ec6d147ccb1045b6a667d6015551a705b31eaefe6fc511568ecf06683cb99d7034f2d59cd83997732096d8e7c12b732b42c6ee460b9d152b5c
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.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
+
10
26
  ## [1.0.9] - 2025-12-26
11
27
 
12
28
  ### Added
@@ -19,12 +35,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
19
35
  - Supports both CamelCase and snake_case detector names
20
36
  - Can disable all detectors with `all` keyword
21
37
 
22
- ### Improved
23
-
24
- - Enhanced README with table of contents and better organization
25
- - Improved code readability and documentation structure
26
- - Updated version badge to v1.0.9
27
-
28
38
  ## [1.0.8] - 2025-12-25
29
39
 
30
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.9-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>
@@ -24,7 +24,6 @@
24
24
  ---
25
25
 
26
26
  ## Table of Contents
27
- - [Why EagerEye?](#why-eagereye)
28
27
  - [Features](#features)
29
28
  - [Installation](#installation)
30
29
  - [Quick Start](#quick-start)
@@ -41,31 +40,13 @@
41
40
  - [Development](#development)
42
41
  - [Contributing](#contributing)
43
42
 
44
- ## Why EagerEye?
45
-
46
- Unlike runtime tools like Bullet, EagerEye:
47
-
48
- - **Runs without executing code** - Works in CI pipelines without a test suite
49
- - **Catches more patterns** - Detects serializer N+1s, missing counter caches, and query methods in loops
50
- - **Proactive detection** - Finds issues at code review time, not after deployment
51
-
52
- | Feature | EagerEye | Bullet |
53
- |---------|----------|--------|
54
- | Detection method | Static analysis | Runtime |
55
- | Requires test suite | No | Yes |
56
- | Serializer N+1 detection | Yes | Limited |
57
- | Counter cache suggestions | Yes | No |
58
- | Query methods in loops | Yes | No |
59
- | CI integration | Native | Requires tests |
60
- | False positive rate | Higher | Lower |
61
-
62
43
  ## Features
63
44
 
64
45
  ✨ **Detects 7 types of N+1 problems:**
65
46
  - Loop associations (queries in iterations)
66
47
  - Serializer nesting issues
67
48
  - Missing counter caches
68
- - Custom method queries (invisible to Bullet)
49
+ - Custom method queries
69
50
  - Count in iteration patterns
70
51
  - Callback query N+1s
71
52
  - Pluck to array misuse
@@ -226,10 +207,10 @@ end
226
207
 
227
208
  ### 4. Custom Method Query (N+1 in query methods)
228
209
 
229
- 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.
230
211
 
231
212
  ```ruby
232
- # Bad - Bullet CANNOT catch this
213
+ # Bad - where inside loop
233
214
  class User < ApplicationRecord
234
215
  def supports?(team_name)
235
216
  teams.where(name: team_name).exists?
@@ -336,6 +317,10 @@ user_ids = User.active.pluck(:id) # Query 1: SELECT id FROM users
336
317
  Post.where(user_id: user_ids) # Query 2: SELECT * FROM posts WHERE user_id IN (1,2,3...)
337
318
  # Also holds potentially thousands of IDs in memory
338
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
+
339
324
  # Bad - Same problem with map
340
325
  user_ids = users.map(&:id)
341
326
  Post.where(user_id: user_ids)
@@ -345,11 +330,22 @@ Post.where(user_id: User.active.select(:id))
345
330
  # Single query: SELECT * FROM posts WHERE user_id IN (SELECT id FROM users WHERE active = true)
346
331
  ```
347
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
+
348
343
  **Performance comparison with 10,000 users:**
349
344
 
350
345
  | Approach | Queries | Memory | Time |
351
346
  |----------|---------|--------|------|
352
347
  | `pluck` + `where` | 2 | ~80KB for IDs | ~45ms |
348
+ | `.all.pluck` + `where` | 2 | ~40KB+ for all IDs | ~100ms+ |
353
349
  | `select` subquery | 1 | None | ~20ms |
354
350
 
355
351
  ## Inline Suppression
@@ -72,22 +72,18 @@ module EagerEye
72
72
  end
73
73
 
74
74
  def detect_block_or_inline_directive(line)
75
- # Check for eager_eye:disable-block explicitly
76
75
  if line =~ BLOCK_START_PATTERN && !code_before_comment?(line)
77
76
  detectors = parse_detector_names(::Regexp.last_match(1) || "all")
78
77
  return { type: :block_start, detectors: detectors }
79
78
  end
80
79
 
81
- # Check for eager_eye:disable (either inline or block start)
82
80
  return unless line =~ INLINE_DISABLE_PATTERN
83
81
 
84
82
  detectors = parse_detector_names(::Regexp.last_match(1) || "all")
85
83
 
86
- # If there's code before the comment, it's inline (same line only)
87
84
  if code_before_comment?(line)
88
85
  { type: :inline, detectors: detectors }
89
86
  else
90
- # No code before comment, so it's a block start
91
87
  { type: :block_start, detectors: detectors }
92
88
  end
93
89
  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.9"
4
+ VERSION = "1.0.10"
5
5
  end
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: 1.0.9
4
+ version: 1.0.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - hamzagedikkaya