eager_eye 0.1.0 → 0.2.1

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: dce399f8b8160530246911ddbd4c9c5a9fb6cad487d409924c6e520993ff37cc
4
- data.tar.gz: 3be96cbcf42c206f0348c63a91991c1a872addd775d5054be9e3dc44cc8b4f52
3
+ metadata.gz: 2c1781e66205a83557a9751b64f19c44d70f90efbd04423d63e847d4e4a06c6e
4
+ data.tar.gz: eac8bb2e4a8b86017804b2c9662074ece08346a5303b5760bdba6dc7d939b3d9
5
5
  SHA512:
6
- metadata.gz: 23923fe65f0f51de105b9a6df93a8a115d9ceb40042dedc9d112ccfbdc48a324935cd48f18306944708ca355cb463caad4b97b994f619dff24d8c6cf475180c0
7
- data.tar.gz: efc5cfe923e04b55b3e5f2a9a6b201298176e4370a5cac92825bd01649ec528ea978fb378151ea7b74c2a82ce6194a02df6ce8c52018725f86bd83736410b71d
6
+ metadata.gz: 9a9fec60ec826824ef8b331f95f33918265f51ea027b43f60852656a0b1420622b9405a78f0fdc4ba3e32c9a30159deb5104fe7432b35fdf9bdea8e5325aa120
7
+ data.tar.gz: fe4032176ada8b10c84ee56c48a8ee60aadbf7c6547e98e0f36def0226a119a7383e6258a060a197b4b6276667fd65f30707dbaeda155ff9410551b2111167ed
data/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2025-12-15
11
+
12
+ ### Added
13
+
14
+ - **New Detector: `CustomMethodQuery`** - Detects query methods called inside iteration blocks
15
+ - Catches `.where`, `.find_by`, `.find_by!`, `.exists?` patterns that Bullet cannot detect
16
+ - Detects `.find`, `.first`, `.last`, `.take` inside loops
17
+ - Detects aggregation methods: `.pluck`, `.ids`, `.count`, `.sum`, `.average`, `.minimum`, `.maximum`
18
+ - Provides suggestions for preloading data before loops
19
+
20
+ ### Changed
21
+
22
+ - Updated default `enabled_detectors` to include `:custom_method_query`
23
+ - Updated README with new detector documentation and comparison table
24
+
10
25
  ## [0.1.0] - 2025-12-15
11
26
 
12
27
  ### Added
data/README.md CHANGED
@@ -14,7 +14,7 @@ EagerEye analyzes your Ruby code without running it, using AST (Abstract Syntax
14
14
  Unlike runtime tools like Bullet, EagerEye:
15
15
 
16
16
  - **Runs without executing code** - Works in CI pipelines without a test suite
17
- - **Catches more patterns** - Detects serializer N+1s and missing counter caches
17
+ - **Catches more patterns** - Detects serializer N+1s, missing counter caches, and query methods in loops
18
18
  - **Proactive detection** - Finds issues at code review time, not after deployment
19
19
 
20
20
  | Feature | EagerEye | Bullet |
@@ -23,6 +23,7 @@ Unlike runtime tools like Bullet, EagerEye:
23
23
  | Requires test suite | No | Yes |
24
24
  | Serializer N+1 detection | Yes | Limited |
25
25
  | Counter cache suggestions | Yes | No |
26
+ | Query methods in loops | Yes | No |
26
27
  | CI integration | Native | Requires tests |
27
28
  | False positive rate | Higher | Lower |
28
29
 
@@ -146,6 +147,35 @@ belongs_to :post, counter_cache: true
146
147
  post.comments_count
147
148
  ```
148
149
 
150
+ ### 4. Custom Method Query (N+1 in query methods)
151
+
152
+ Detects query methods (`.where`, `.find_by`, `.exists?`, etc.) called on associations inside loops. **These patterns are invisible to Bullet.**
153
+
154
+ ```ruby
155
+ # Bad - Bullet CANNOT catch this
156
+ class User < ApplicationRecord
157
+ def supports?(team_name)
158
+ teams.where(name: team_name).exists?
159
+ end
160
+ end
161
+
162
+ @users.each do |user|
163
+ user.supports?("Lakers") # Query for each user!
164
+ end
165
+
166
+ # Bad - find_by inside loop
167
+ @orders.each do |order|
168
+ order.line_items.find_by(featured: true)
169
+ end
170
+
171
+ # Good - Preload and filter in Ruby
172
+ @users.includes(:teams).each do |user|
173
+ user.teams.any? { |t| t.name == "Lakers" }
174
+ end
175
+ ```
176
+
177
+ **Detected methods:** `where`, `find_by`, `find_by!`, `exists?`, `find`, `first`, `last`, `take`, `pluck`, `ids`, `count`, `sum`, `average`, `minimum`, `maximum`
178
+
149
179
  ## Configuration
150
180
 
151
181
  ### Config File (.eager_eye.yml)
@@ -161,6 +191,7 @@ enabled_detectors:
161
191
  - loop_association
162
192
  - serializer_nesting
163
193
  - missing_counter_cache
194
+ - custom_method_query
164
195
 
165
196
  # Base path to analyze (default: app)
166
197
  app_path: app
@@ -7,7 +7,8 @@ module EagerEye
7
7
  DETECTOR_CLASSES = {
8
8
  loop_association: Detectors::LoopAssociation,
9
9
  serializer_nesting: Detectors::SerializerNesting,
10
- missing_counter_cache: Detectors::MissingCounterCache
10
+ missing_counter_cache: Detectors::MissingCounterCache,
11
+ custom_method_query: Detectors::CustomMethodQuery
11
12
  }.freeze
12
13
 
13
14
  attr_reader :paths, :issues
@@ -4,7 +4,7 @@ module EagerEye
4
4
  class Configuration
5
5
  attr_accessor :excluded_paths, :enabled_detectors, :app_path, :fail_on_issues
6
6
 
7
- DEFAULT_DETECTORS = %i[loop_association serializer_nesting missing_counter_cache].freeze
7
+ DEFAULT_DETECTORS = %i[loop_association serializer_nesting missing_counter_cache custom_method_query].freeze
8
8
 
9
9
  def initialize
10
10
  @excluded_paths = []
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EagerEye
4
+ module Detectors
5
+ class CustomMethodQuery < Base
6
+ QUERY_METHODS = %i[
7
+ where
8
+ find_by
9
+ find_by!
10
+ exists?
11
+ find
12
+ first
13
+ last
14
+ take
15
+ pluck
16
+ ids
17
+ count
18
+ sum
19
+ average
20
+ minimum
21
+ maximum
22
+ ].freeze
23
+
24
+ ITERATION_METHODS = %i[each map select find_all reject collect detect find_index flat_map].freeze
25
+
26
+ def self.detector_name
27
+ :custom_method_query
28
+ end
29
+
30
+ def detect(ast, file_path)
31
+ return [] unless ast
32
+
33
+ @issues = []
34
+ @file_path = file_path
35
+
36
+ find_iteration_blocks(ast) do |block_body, block_var|
37
+ check_block_for_query_methods(block_body, block_var)
38
+ end
39
+
40
+ @issues
41
+ end
42
+
43
+ private
44
+
45
+ def find_iteration_blocks(node, &block)
46
+ return unless node.is_a?(Parser::AST::Node)
47
+
48
+ if iteration_block?(node)
49
+ block_var = extract_block_variable(node)
50
+ block_body = extract_block_body(node)
51
+ yield(block_body, block_var) if block_var && block_body
52
+ end
53
+
54
+ node.children.each do |child|
55
+ find_iteration_blocks(child, &block)
56
+ end
57
+ end
58
+
59
+ def iteration_block?(node)
60
+ return false unless node.type == :block
61
+
62
+ send_node = node.children[0]
63
+ return false unless send_node&.type == :send
64
+
65
+ method_name = send_node.children[1]
66
+ ITERATION_METHODS.include?(method_name)
67
+ end
68
+
69
+ def check_block_for_query_methods(node, block_var)
70
+ return unless node.is_a?(Parser::AST::Node)
71
+
72
+ add_issue(node) if query_chain_on_association?(node, block_var)
73
+
74
+ node.children.each do |child|
75
+ check_block_for_query_methods(child, block_var)
76
+ end
77
+ end
78
+
79
+ def query_chain_on_association?(node, block_var)
80
+ return false unless node.type == :send
81
+
82
+ method_name = node.children[1]
83
+ return false unless QUERY_METHODS.include?(method_name)
84
+
85
+ receiver = node.children[0]
86
+ receiver_chain_starts_with?(receiver, block_var)
87
+ end
88
+
89
+ def receiver_chain_starts_with?(node, block_var)
90
+ return false unless node.is_a?(Parser::AST::Node)
91
+
92
+ case node.type
93
+ when :lvar
94
+ node.children[0] == block_var
95
+ when :send
96
+ receiver_chain_starts_with?(node.children[0], block_var)
97
+ else
98
+ false
99
+ end
100
+ end
101
+
102
+ def extract_block_variable(block_node)
103
+ args_node = block_node.children[1]
104
+ return nil unless args_node&.type == :args
105
+
106
+ first_arg = args_node.children[0]
107
+ return nil unless first_arg&.type == :arg
108
+
109
+ first_arg.children[0]
110
+ end
111
+
112
+ def extract_block_body(block_node)
113
+ block_node.children[2]
114
+ end
115
+
116
+ def add_issue(node)
117
+ method_name = node.children[1]
118
+ association_chain = reconstruct_chain(node.children[0])
119
+
120
+ @issues << create_issue(
121
+ file_path: @file_path,
122
+ line_number: node.loc.line,
123
+ message: "Query method `.#{method_name}` called on `#{association_chain}` inside iteration",
124
+ severity: :warning,
125
+ suggestion: "This query executes on each iteration. Consider preloading data or restructuring the query."
126
+ )
127
+ end
128
+
129
+ def reconstruct_chain(node)
130
+ return "" unless node.is_a?(Parser::AST::Node)
131
+
132
+ case node.type
133
+ when :lvar
134
+ node.children[0].to_s
135
+ when :send
136
+ receiver_str = reconstruct_chain(node.children[0])
137
+ method = node.children[1]
138
+ receiver_str.empty? ? method.to_s : "#{receiver_str}.#{method}"
139
+ else
140
+ ""
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EagerEye
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.1"
5
5
  end
data/lib/eager_eye.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "eager_eye/detectors/base"
7
7
  require_relative "eager_eye/detectors/loop_association"
8
8
  require_relative "eager_eye/detectors/serializer_nesting"
9
9
  require_relative "eager_eye/detectors/missing_counter_cache"
10
+ require_relative "eager_eye/detectors/custom_method_query"
10
11
  require_relative "eager_eye/analyzer"
11
12
  require_relative "eager_eye/reporters/base"
12
13
  require_relative "eager_eye/reporters/console"
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: 0.1.0
4
+ version: 0.2.1
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-14 00:00:00.000000000 Z
11
+ date: 2025-12-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ast
@@ -61,6 +61,7 @@ files:
61
61
  - lib/eager_eye/cli.rb
62
62
  - lib/eager_eye/configuration.rb
63
63
  - lib/eager_eye/detectors/base.rb
64
+ - lib/eager_eye/detectors/custom_method_query.rb
64
65
  - lib/eager_eye/detectors/loop_association.rb
65
66
  - lib/eager_eye/detectors/missing_counter_cache.rb
66
67
  - lib/eager_eye/detectors/serializer_nesting.rb