eager_eye 1.2.15 → 1.3.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.
data/README.md CHANGED
@@ -1,87 +1,52 @@
1
1
  <p align="center">
2
- <img src="images/icon.png" alt="EagerEye Logo" width="140">
2
+ <img src="images/icon.png" alt="EagerEye" width="140">
3
3
  </p>
4
4
 
5
5
  <h1 align="center">EagerEye</h1>
6
6
 
7
7
  <p align="center">
8
- <strong>Static analysis tool for detecting N+1 queries in Rails applications.</strong>
8
+ <strong>Catch N+1 queries in your Rails app — without running it.</strong><br>
9
+ <sub>Static analysis powered by Ruby AST. Fast. Zero runtime overhead. CI-ready.</sub>
9
10
  </p>
10
11
 
11
12
  <p align="center">
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.2.15-red.svg" alt="Gem Version"></a>
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
- <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
- <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
17
- <a href="https://marketplace.visualstudio.com/items?itemName=hamzagedikkaya.eager-eye"><img src="https://img.shields.io/badge/VS%20Code-Extension-blue.svg" alt="VS Code Extension"></a>
13
+ <strong>English</strong> · <a href="README.tr.md">Türkçe</a>
18
14
  </p>
19
15
 
20
16
  <p align="center">
21
- <em>Analyze your Ruby code without running it — find N+1 query issues before they hit production using AST parsing.</em>
17
+ <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>
18
+ <a href="https://rubygems.org/gems/eager_eye"><img src="https://img.shields.io/gem/v/eager_eye?color=red&label=gem" alt="Gem Version"></a>
19
+ <a href="https://rubygems.org/gems/eager_eye"><img src="https://img.shields.io/gem/dt/eager_eye?color=blue&label=downloads" alt="Downloads"></a>
20
+ <a href="https://www.ruby-lang.org/"><img src="https://img.shields.io/badge/ruby-%3E%3D%203.1-CC342D" alt="Ruby"></a>
21
+ <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/github/license/hamzagedikkaya/eager_eye" alt="License"></a>
22
+ <a href="https://marketplace.visualstudio.com/items?itemName=hamzagedikkaya.eager-eye"><img src="https://img.shields.io/badge/VS%20Code-Extension-007ACC?logo=visualstudiocode&logoColor=white" alt="VS Code Extension"></a>
22
23
  </p>
23
24
 
25
+ > 💡 **Prefer in-editor warnings?** Install the [VS Code extension](https://marketplace.visualstudio.com/items?itemName=hamzagedikkaya.eager-eye) — same engine, runs on save, surfaces issues right next to the offending line. Same speed as the CLI, just a smoother feedback loop.
26
+
24
27
  ---
25
28
 
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 11 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
- - Delegation N+1s (hidden via `delegate :method, to: :association`)
54
- - Decorator N+1s (Draper, SimpleDelegator, Presenter, ViewObject)
55
- - Scope chain N+1s (named scopes on associations in loops)
56
- - Validation N+1s (uniqueness validation in batch create/save)
57
-
58
- 🔧 **Developer-friendly:**
59
- - Inline suppression (like RuboCop)
60
- - Auto-fix support (3 fixers: PluckToSelect, CountToSize, AddIncludes)
61
- - `.jbuilder` file support (`json.array!` iteration detection)
62
- - JSON/Console output formats
63
- - RSpec integration
64
-
65
- 🚀 **CI-ready:**
66
- - No test suite required
67
- - GitHub Actions examples included
68
- - Severity levels and filtering
69
-
70
- ## Installation
71
-
72
- Add to your Gemfile:
29
+ ## Why EagerEye?
30
+
31
+ **Bullet** finds N+1s when your tests hit them. **EagerEye** finds them statically — before any code runs.
32
+
33
+ - 🎯 **Catch what tests miss** — N+1s in code paths your test suite doesn't exercise still get flagged.
34
+ - **Run in CI on every PR** — no DB, no fixtures, no Rails boot. Just `eager_eye app/`.
35
+ - 🔬 **11 detector types** — beyond simple loop access: serializer nesting, callback queries, decorator/delegation traps, batch validation, scope chains, plucked-array misuse, and more.
36
+ - 🤝 **Plays well with Bullet** — static + runtime cover different blind spots. Use both.
37
+
38
+ ## Install
73
39
 
74
40
  ```ruby
41
+ # Gemfile
75
42
  gem "eager_eye", group: :development
76
43
  ```
77
44
 
78
- Then run:
79
-
80
45
  ```bash
81
46
  bundle install
82
47
  ```
83
48
 
84
- Or install standalone:
49
+ Or standalone:
85
50
 
86
51
  ```bash
87
52
  gem install eager_eye
@@ -89,477 +54,322 @@ gem install eager_eye
89
54
 
90
55
  ## Quick Start
91
56
 
92
- ### CLI Usage
93
-
94
57
  ```bash
95
- # Analyze default app/ directory
58
+ # Scan the default app/ directory
96
59
  eager_eye
97
60
 
98
- # Analyze specific paths
61
+ # Or scan specific paths
99
62
  eager_eye app/controllers app/serializers
100
63
 
101
- # Output as JSON (for CI)
102
- eager_eye --format json
103
-
104
- # Don't fail on issues (exit 0)
105
- eager_eye --no-fail
106
-
107
- # Run specific detectors only
108
- eager_eye --only loop_association,serializer_nesting
64
+ # Generate a config file (optional)
65
+ rails g eager_eye:install
109
66
 
110
- # Exclude paths
111
- eager_eye --exclude "app/legacy/**"
67
+ # Run via rake
68
+ rake eager_eye:analyze
112
69
  ```
113
70
 
114
- ### Rails Integration
71
+ Sample output:
115
72
 
116
- ```bash
117
- # Generate config file
118
- rails g eager_eye:install
73
+ ```text
74
+ app/controllers/posts_controller.rb
75
+ Line 15: [LoopAssociation] Potential N+1 query: `post.author` called inside iteration
76
+ Suggestion: Use `includes(:author)` on the collection before iterating
119
77
 
120
- # Run via rake
121
- rake eager_eye:analyze
78
+ Line 23: [MissingCounterCache] `.count` called on `comments` may cause N+1 queries
79
+ Suggestion: Add `counter_cache: true` to the belongs_to association
122
80
 
123
- # JSON output
124
- rake eager_eye:json
81
+ Total: 2 issues (2 warnings, 0 errors)
125
82
  ```
126
83
 
127
- ## Detected Issues
84
+ ## What it detects
85
+
86
+ | # | Detector | What it catches |
87
+ |---|---|---|
88
+ | 1 | **LoopAssociation** | Association calls inside `each`/`map`/`find_each`/etc. without preloading |
89
+ | 2 | **SerializerNesting** | Nested association access in Blueprinter / ActiveModel::Serializer / Alba blocks |
90
+ | 3 | **MissingCounterCache** | `.count` / `.size` on associations inside loops where a counter cache would help |
91
+ | 4 | **CustomMethodQuery** | `.where`, `.find_by`, `.exists?` etc. on association chains inside iterations |
92
+ | 5 | **CountInIteration** | `.count` (always queries) used in loops where `.size` (uses preload) would suffice |
93
+ | 6 | **CallbackQuery** | Iteration-driven queries inside ActiveRecord callbacks (`after_save`, `after_create`, ...) |
94
+ | 7 | **PluckToArray** | `.pluck(:id)` results passed to `where(id: ...)` instead of using a subquery; flags `.all.pluck` as critical |
95
+ | 8 | **DelegationNPlusOne** | `delegate :method, to: :association` calls in loops where the target isn't preloaded |
96
+ | 9 | **DecoratorNPlusOne** | Draper / SimpleDelegator / Presenter / ViewObject access without preload before `.decorate` |
97
+ | 10 | **ScopeChainNPlusOne** | Named scopes (`.recent`, `.active`) on associations in loops — invisible query triggers |
98
+ | 11 | **ValidationNPlusOne** | `Model.create`/`save` inside loops on models with `validates :x, uniqueness: true` |
128
99
 
129
- ### 1. Loop Association (N+1 in iterations)
100
+ EagerEye also tracks preloads across pagination wrappers (`pagy`, `paginate`, `kaminari`), per-method scope, multi-line builder chains, and helper-method parameters — so warnings respect the eager-loading you've already set up.
130
101
 
131
- Detects association calls inside loops that may cause N+1 queries.
102
+ <details>
103
+ <summary><b>Detailed examples for each detector →</b></summary>
104
+
105
+ ### 1. LoopAssociation
132
106
 
133
107
  ```ruby
134
- # Bad - N+1 query on each iteration
135
- posts.each do |post|
136
- post.author.name # Query for each post!
137
- post.comments.count # Another query for each post!
138
- end
108
+ # Bad
109
+ posts.each { |post| post.author.name } # query per post
139
110
 
140
- # Good - Eager load associations (chained)
141
- posts.includes(:author, :comments).each do |post|
142
- post.author.name # No additional query
143
- post.comments.count # No additional query
144
- end
111
+ # Good chained
112
+ posts.includes(:author).each { |post| post.author.name }
145
113
 
146
- # Good - Eager load on separate line (also detected correctly!)
114
+ # Good separate line (preload tracked across assignment)
147
115
  @posts = Post.includes(:author)
148
- @posts.each do |post|
149
- post.author.name # No warning - EagerEye tracks the preload
150
- end
151
-
152
- # Also works with preload and eager_load
153
- posts = Post.preload(:comments)
154
- posts.each { |post| post.comments.size } # No warning
116
+ @posts.each { |post| post.author.name }
155
117
 
156
- # Single record context - no N+1 possible (also detected correctly!)
118
+ # Good single record (no N+1 possible)
157
119
  @user = User.find(params[:id])
158
- @user.posts.each do |post|
159
- post.comments # No warning - single user, no N+1
160
- end
161
-
162
- # Scope-defined preloads are recognized (v1.1.0+)
163
- # In Post model:
164
- class Post < ApplicationRecord
165
- has_many :comments, -> { includes(:author) }
166
- end
167
-
168
- # In controller - EagerEye recognizes comments are already preloaded via scope!
169
- posts.each do |post|
170
- post.comments.map(&:author) # No warning - preloaded via scope
171
- end
120
+ @user.posts.each { |post| post.comments }
172
121
  ```
173
122
 
174
- ### 2. Serializer Nesting (N+1 in serializers)
123
+ Recognizes `.includes`, `.preload`, `.eager_load`, scoped `has_many` (`-> { includes(:author) }`), and pagination wrappers like `@pagy, items = pagy(...)`.
175
124
 
176
- Detects nested association access in serializer blocks.
125
+ ### 2. SerializerNesting
177
126
 
178
127
  ```ruby
179
- # Bad - N+1 in serializer
180
- class PostSerializer < ActiveModel::Serializer
181
- attribute :author_name do
182
- object.author.name # Query for each serialized post!
183
- end
128
+ # Bad
129
+ class PostSerializer < Blueprinter::Base
130
+ field :author_name { |post| post.author.name } # query per serialized post
184
131
  end
185
132
 
186
- # Good - Eager load in controller
187
- class PostsController < ApplicationController
188
- def index
189
- @posts = Post.includes(:author)
190
- render json: @posts, each_serializer: PostSerializer
191
- end
192
- end
133
+ # Good preload in controller
134
+ @posts = Post.includes(:author)
135
+ render json: PostSerializer.render(@posts)
193
136
  ```
194
137
 
195
- Supports multiple serializer libraries:
196
- - ActiveModel::Serializers
197
- - Blueprinter
198
- - Alba
138
+ Supports Blueprinter, ActiveModel::Serializers, Alba.
199
139
 
200
- ### 3. Missing Counter Cache
201
-
202
- Detects `.count`, `.size`, or `.length` calls on associations **inside iterations** that could benefit from counter caches. Single calls outside loops are not flagged since they don't cause N+1 issues.
140
+ ### 3. MissingCounterCache
203
141
 
204
142
  ```ruby
205
- # Bad - COUNT query for each post in iteration
206
- posts.each do |post|
207
- post.comments.count # Detected: N+1 query!
208
- post.likes.size # Detected: N+1 query!
209
- end
143
+ # Bad COUNT query for each post
144
+ posts.each { |post| post.comments.count }
210
145
 
211
- # OK - Single count call (not in iteration, no N+1)
212
- post.comments.count # Not flagged - single query is fine
213
-
214
- # Good - Add counter cache for iteration use cases
215
- # In Comment model:
216
- belongs_to :post, counter_cache: true
217
-
218
- # Then this is a simple column read:
219
- posts.each do |post|
220
- post.comments_count # No query - just reads the column
221
- end
146
+ # Good counter cache (Comment: belongs_to :post, counter_cache: true)
147
+ posts.each { |post| post.comments_count } # column read, no query
222
148
  ```
223
149
 
224
- ### 4. Custom Method Query (N+1 in query methods)
150
+ Only flagged inside iterations single calls don't cause N+1.
225
151
 
226
- Detects query methods (`.where`, `.find_by`, `.exists?`, etc.) called on associations inside loops.
152
+ ### 4. CustomMethodQuery
227
153
 
228
154
  ```ruby
229
- # Bad - where inside loop
230
- class User < ApplicationRecord
231
- def supports?(team_name)
232
- teams.where(name: team_name).exists?
233
- end
234
- end
235
-
236
- @users.each do |user|
237
- user.supports?("Lakers") # Query for each user!
238
- end
239
-
240
- # Bad - find_by inside loop
241
- @orders.each do |order|
242
- order.line_items.find_by(featured: true)
243
- end
155
+ # Bad where inside loop
156
+ @users.each { |user| user.teams.where(name: "Lakers").exists? }
244
157
 
245
- # Good - Preload and filter in Ruby
246
- @users.includes(:teams).each do |user|
247
- user.teams.any? { |t| t.name == "Lakers" }
248
- end
158
+ # Good preload + filter in Ruby
159
+ @users.includes(:teams).each { |user| user.teams.any? { |t| t.name == "Lakers" } }
249
160
  ```
250
161
 
251
- **Detected methods:** `where`, `find_by`, `find_by!`, `exists?`, `find`, `first`, `last`, `take`, `pluck`, `ids`, `count`, `sum`, `average`, `minimum`, `maximum`
252
-
253
- ### 5. Count in Iteration
162
+ Detected: `where`, `find_by`, `exists?`, `find`, `first`, `last`, `take`, `pluck`, `count`, `sum`, `average`, `minimum`, `maximum`. Per-model scoped — won't flag `obj.foo` just because some other model defines `def foo` with a query.
254
163
 
255
- Detects `.count` called on associations inside loops. Unlike `.size`, `.count` always executes a COUNT query even when the association is preloaded.
164
+ ### 5. CountInIteration
256
165
 
257
166
  ```ruby
258
- # Bad - COUNT query for each user, even with includes!
167
+ # Bad .count always queries, even with includes
259
168
  @users = User.includes(:posts)
260
- @users.each do |user|
261
- user.posts.count # Executes: SELECT COUNT(*) FROM posts WHERE user_id = ?
262
- end
263
-
264
- # Good - Use .size (checks if loaded first)
265
- @users.each do |user|
266
- user.posts.size # No query - counts the loaded array
267
- end
169
+ @users.each { |user| user.posts.count } # SELECT COUNT(*) per user
268
170
 
269
- # Best - Use counter_cache for frequent counts
270
- # In Post model: belongs_to :user, counter_cache: true
271
- user.posts_count # Just reads the column
171
+ # Good .size uses the preload
172
+ @users.each { |user| user.posts.size }
272
173
  ```
273
174
 
274
- **Key differences:**
275
-
276
- | Method | Loaded Collection | Not Loaded |
277
- |--------|------------------|------------|
175
+ | Method | Loaded | Not loaded |
176
+ |---|---|---|
278
177
  | `.count` | COUNT query | COUNT query |
279
- | `.size` | Array#size | COUNT query |
280
- | `.length` | Array#length | Loads all, then counts |
281
-
282
- ### 6. Callback Query Detection
178
+ | `.size` | array#size | COUNT query |
179
+ | `.length` | array#length | loads all then counts |
283
180
 
284
- Detects N+1 patterns inside ActiveRecord callbacks - specifically iterations that execute queries on each loop.
181
+ ### 6. CallbackQuery
285
182
 
286
183
  ```ruby
287
- # Bad - N+1 in callback (DETECTED)
184
+ # Bad N+1 inside callback
288
185
  class Order < ApplicationRecord
289
186
  after_create :notify_subscribers
290
187
 
291
188
  def notify_subscribers
292
- customer.followers.each do |follower| # Error: Iteration in callback
293
- follower.notifications.create!(...) # Warning: Query on iteration variable
294
- end
189
+ customer.followers.each { |f| f.notifications.create!(...) } # N inserts + N queries
295
190
  end
296
191
  end
297
192
 
298
- # OK - Single query in callback (NOT flagged - not N+1)
299
- class Article < ApplicationRecord
300
- after_save :update_stats
301
-
302
- def update_stats
303
- author.articles.count # Single query, acceptable
304
- end
305
- end
306
-
307
- # OK - Query not on iteration variable (NOT flagged)
308
- class Post < ApplicationRecord
309
- after_save :process_items
310
-
311
- def process_items
312
- items.each do |item|
313
- OtherModel.where(name: item.name).first # OtherModel is receiver, not item
314
- end
315
- end
316
- end
317
-
318
- # Good - Move iterations to background job
193
+ # Good defer to background job
319
194
  after_commit :schedule_notifications, on: :create
320
-
321
195
  def schedule_notifications
322
196
  NotifySubscribersJob.perform_later(id)
323
197
  end
324
198
  ```
325
199
 
326
- ### 7. Pluck to Array Misuse
327
-
328
- Detects when `.pluck(:id)` or `.map(&:id)` results are used in `where` clauses instead of subqueries.
200
+ ### 7. PluckToArray
329
201
 
330
202
  ```ruby
331
- # Bad - Two queries + memory overhead
203
+ # Warning two queries + memory overhead
332
204
  user_ids = User.active.pluck(:id)
333
- Post.where(user_id: user_ids) # ⚠️ Warning
205
+ Post.where(user_id: user_ids)
334
206
 
335
- # Worse - Loads entire table! 🔴 Error
207
+ # Error loads entire table
336
208
  user_ids = User.all.pluck(:id)
337
209
  Post.where(user_id: user_ids)
338
210
 
339
- # Good - Single subquery
211
+ # Good single subquery
340
212
  Post.where(user_id: User.active.select(:id))
341
213
  ```
342
214
 
343
- **Severity:**
344
- - ⚠️ **Warning** - Scoped `.pluck(:id)` (two queries, memory overhead)
345
- - 🔴 **Error** - Unscoped `.all.pluck(:id)` (loads entire table)
215
+ `.where(...).all.pluck(:id)` is correctly recognized as scoped, not a table scan.
346
216
 
347
- ### 8. Delegation N+1
348
-
349
- Detects when methods delegated via `delegate :method, to: :association` are called inside loops without preloading the target association. These are invisible to `LoopAssociation` because `order.name` looks like a plain attribute, not an association access.
217
+ ### 8. DelegationNPlusOne
350
218
 
351
219
  ```ruby
352
- # Model
353
220
  class Order < ApplicationRecord
354
221
  belongs_to :user
355
222
  delegate :full_name, :email, to: :user
356
223
  end
357
224
 
358
- # Bad - N+1 (each call hits the database for user)
359
- orders.each do |order|
360
- order.full_name # actually: order.user.full_name — loads user for each order!
361
- order.email # actually: order.user.email — another load!
362
- end
225
+ # Bad looks like attribute access, actually loads user per order
226
+ orders.each { |o| o.full_name }
363
227
 
364
- # Good - Eager load the delegated-to association
365
- orders.includes(:user).each do |order|
366
- order.full_name # no N+1 — user is already loaded
367
- order.email # no N+1 — user is already loaded
368
- end
228
+ # Good
229
+ orders.includes(:user).each { |o| o.full_name }
369
230
  ```
370
231
 
371
- EagerEye detects these by:
372
- 1. Scanning model files for `delegate :method, to: :assoc` declarations
373
- 2. Tracking which methods delegate to which associations
374
- 3. Flagging calls to those methods inside iteration blocks when the association is not preloaded
232
+ Cross-file: scans models for `delegate ... to: :assoc` declarations.
375
233
 
376
- ### 9. Decorator N+1
377
-
378
- Detects N+1 queries inside Draper decorators, SimpleDelegator subclasses, and classes named `Decorator`, `Presenter`, or `ViewObject`. Each decorator wraps a single record — when a collection is decorated without preloading, every method that accesses an association triggers a new query per record.
234
+ ### 9. DecoratorNPlusOne
379
235
 
380
236
  ```ruby
381
- # Bad - N+1 on each decorated post
382
237
  class PostDecorator < Draper::Decorator
383
238
  def comment_summary
384
- object.comments.map(&:body).join(", ") # Query for each post!
385
- end
386
-
387
- def tag_list
388
- object.tags.map(&:name).join(", ") # Another query for each post!
239
+ object.comments.map(&:body).join(", ") # query per decorated post
389
240
  end
390
241
  end
391
242
 
392
- # Controller - no includes = N+1
243
+ # Bad
393
244
  @posts = Post.all.decorate
394
245
 
395
- # Good - Eager load before decorating
396
- @posts = Post.includes(:comments, :tags).all.decorate
246
+ # Good
247
+ @posts = Post.includes(:comments).all.decorate
397
248
  ```
398
249
 
399
- Supports the following object references inside decorators:
400
- - `object` — Draper standard
401
- - `__getobj__` — SimpleDelegator standard
402
- - `source`, `model` — alternative Draper aliases
403
-
404
- ### 10. Scope Chain N+1
250
+ Recognizes `object`, `__getobj__`, `source`, `model` references inside Draper / SimpleDelegator / Presenter / ViewObject classes.
405
251
 
406
- Detects named scope calls on associations inside iterations. Unlike explicit query methods (`.where`, `.find_by`) caught by `CustomMethodQuery`, named scopes (`.recent`, `.active`, `.published`) are invisible query triggers.
252
+ ### 10. ScopeChainNPlusOne
407
253
 
408
254
  ```ruby
409
- # Model
410
255
  class Comment < ApplicationRecord
411
256
  scope :recent, -> { where("created_at > ?", 1.week.ago) }
412
- scope :approved, -> { where(approved: true) }
413
257
  end
414
258
 
415
- # Bad - scope call per iteration
416
- posts.each do |post|
417
- post.comments.recent # Query for each post!
418
- post.comments.approved.count # Query for each post!
419
- end
259
+ # Bad scope call per iteration
260
+ posts.each { |post| post.comments.recent }
420
261
 
421
- # Good - preload and filter in Ruby
422
- posts.includes(:comments).each do |post|
423
- post.comments.select { |c| c.created_at > 1.week.ago }
424
- end
262
+ # Good preload + filter
263
+ posts.includes(:comments).each { |post| post.comments.select { |c| c.created_at > 1.week.ago } }
425
264
  ```
426
265
 
427
- EagerEye detects these by:
428
- 1. Scanning model files for `scope :name, -> { ... }` declarations
429
- 2. Flagging known scope names called on association chains inside iteration blocks
266
+ Cross-file: scans models for `scope :name, -> { ... }` declarations.
430
267
 
431
- ### 11. Validation N+1
432
-
433
- Detects batch create/save operations inside iterations for models with `validates uniqueness`. Each uniqueness validation triggers a SELECT query per record, resulting in 2N queries (SELECT + INSERT per record).
268
+ ### 11. ValidationNPlusOne
434
269
 
435
270
  ```ruby
436
- # Model
437
271
  class User < ApplicationRecord
438
272
  validates :email, uniqueness: true
439
273
  end
440
274
 
441
- # Bad - 2N queries (SELECT + INSERT per record)
442
- params[:users].each do |user_params|
443
- User.create!(user_params) # SELECT + INSERT for each!
444
- end
275
+ # Bad SELECT + INSERT per record
276
+ params[:users].each { |p| User.create!(p) }
445
277
 
446
- # Bad - same problem with new + save
447
- params[:users].each do |user_params|
448
- user = User.new(user_params)
449
- user.save! # SELECT + INSERT for each!
450
- end
451
-
452
- # Good - use insert_all with unique index
453
- User.insert_all(params[:users]) # Single bulk INSERT, DB enforces uniqueness
278
+ # Good single bulk INSERT, DB enforces uniqueness via index
279
+ User.insert_all(params[:users])
454
280
  ```
455
281
 
456
- EagerEye detects these by:
457
- 1. Scanning model files for `validates :attr, uniqueness: true` or `validates_uniqueness_of` declarations
458
- 2. Flagging `Model.create/create!` or `Model.new` + `.save/.save!` patterns inside iteration blocks
282
+ </details>
459
283
 
460
- ## Inline Suppression
284
+ ## Inline suppression
461
285
 
462
- Suppress false positives using inline comments (RuboCop-style):
286
+ RuboCop-style comments suppress false positives or accepted patterns:
463
287
 
464
288
  ```ruby
465
- # Disable for single line
289
+ # Single line
466
290
  user.posts.count # eager_eye:disable CountInIteration
467
291
 
468
- # Disable for next line
292
+ # Next line
469
293
  # eager_eye:disable-next-line LoopAssociation
470
294
  @users.each { |u| u.profile }
471
295
 
472
- # Disable block
296
+ # Block
473
297
  # eager_eye:disable LoopAssociation, SerializerNesting
474
- @users.each do |user|
475
- user.posts.each { |p| p.author }
476
- end
298
+ @users.each { |u| u.posts.each { |p| p.author } }
477
299
  # eager_eye:enable LoopAssociation, SerializerNesting
478
300
 
479
- # Disable entire file (must be in first 5 lines)
301
+ # Whole file (must be in first 5 lines)
480
302
  # eager_eye:disable-file CustomMethodQuery
481
303
 
482
304
  # With reason
483
305
  user.posts.count # eager_eye:disable CountInIteration -- using counter_cache
484
306
 
485
- # Disable all detectors
307
+ # Disable everything
486
308
  # eager_eye:disable all
487
309
  ```
488
310
 
489
- ### Available Detector Names
311
+ Detector names are accepted as either CamelCase (`LoopAssociation`) or snake_case (`loop_association`).
490
312
 
491
- Both CamelCase and snake_case formats are accepted:
313
+ ## Auto-fix (experimental)
492
314
 
493
- | Detector | CamelCase | snake_case |
494
- |----------|-----------|------------|
495
- | Loop Association | `LoopAssociation` | `loop_association` |
496
- | Serializer Nesting | `SerializerNesting` | `serializer_nesting` |
497
- | Missing Counter Cache | `MissingCounterCache` | `missing_counter_cache` |
498
- | Custom Method Query | `CustomMethodQuery` | `custom_method_query` |
499
- | Count in Iteration | `CountInIteration` | `count_in_iteration` |
500
- | Callback Query | `CallbackQuery` | `callback_query` |
501
- | Pluck to Array | `PluckToArray` | `pluck_to_array` |
502
- | Delegation N+1 | `DelegationNPlusOne` | `delegation_n_plus_one` |
503
- | Decorator N+1 | `DecoratorNPlusOne` | `decorator_n_plus_one` |
504
- | Scope Chain N+1 | `ScopeChainNPlusOne` | `scope_chain_n_plus_one` |
505
- | Validation N+1 | `ValidationNPlusOne` | `validation_n_plus_one` |
506
- | All Detectors | `all` | `all` |
315
+ ```bash
316
+ eager_eye --suggest-fixes # show diff
317
+ eager_eye --fix # interactive
318
+ eager_eye --fix --force # apply all without confirmation
319
+ ```
507
320
 
508
- ## Auto-fix (Experimental)
321
+ | Issue | Auto-fix |
322
+ |---|---|
323
+ | `.pluck(:id)` used in `.where(id: ...)` | → `.select(:id)` |
324
+ | `.count` in iteration | → `.size` |
325
+ | Missing `includes` before loop | → inserts `.includes(:assoc)` |
509
326
 
510
- EagerEye can automatically fix some simple issues:
327
+ > Always review the diff and re-run your test suite after `--fix`.
511
328
 
512
- ```bash
513
- # Show fix suggestions
514
- eager_eye --suggest-fixes
329
+ ## CI integration
515
330
 
516
- # Apply fixes interactively
517
- eager_eye --fix
331
+ ```yaml
332
+ # .github/workflows/eager_eye.yml
333
+ name: EagerEye
334
+ on: [pull_request]
518
335
 
519
- # Apply all fixes without confirmation
520
- eager_eye --fix --force
336
+ jobs:
337
+ analyze:
338
+ runs-on: ubuntu-latest
339
+ steps:
340
+ - uses: actions/checkout@v4
341
+ - uses: ruby/setup-ruby@v1
342
+ with:
343
+ ruby-version: "3.3"
344
+ - run: gem install eager_eye
345
+ - run: eager_eye app/ --format json > report.json
346
+ - run: |
347
+ issues=$(ruby -rjson -e 'puts JSON.parse(File.read("report.json"))["summary"]["total_issues"]')
348
+ [ "$issues" -gt 0 ] && echo "::warning::Found $issues potential N+1 issues" || true
521
349
  ```
522
350
 
523
- ### Currently Supported Auto-fixes
351
+ See [examples/github_action.yml](examples/github_action.yml) for a fuller setup with PR annotations.
524
352
 
525
- | Issue | Fix |
526
- |-------|-----|
527
- | `.pluck(:id)` inline | → `.select(:id)` |
528
- | `.count` in iteration | → `.size` |
529
- | Missing `includes` before loop | → `.includes(:assoc)` inserted |
353
+ ### Baseline mode (brownfield projects)
530
354
 
531
- ### Example
355
+ Most existing Rails apps have hundreds of N+1 issues already — failing CI on
356
+ every one of them is noise. Capture today's report as a baseline and let CI
357
+ fail **only on regressions** (new issues introduced by a PR):
532
358
 
533
- ```
534
- $ eager_eye --suggest-fixes
535
-
536
- app/services/user_service.rb:
537
- Line 12:
538
- - Post.where(user_id: User.active.pluck(:id))
539
- + Post.where(user_id: User.active.select(:id))
540
-
541
- app/controllers/posts_controller.rb:
542
- Line 8:
543
- - user.posts.count
544
- + user.posts.size
545
-
546
- Line 5:
547
- - @posts.each do |post|
548
- + @posts.includes(:author).each do |post|
549
-
550
- $ eager_eye --fix
551
- app/services/user_service.rb:12
552
- - Post.where(user_id: User.active.pluck(:id))
553
- + Post.where(user_id: User.active.select(:id))
554
- Apply this fix? [y/n/q] y
555
- Applied
556
- ```
359
+ ```bash
360
+ # One-time: capture the current state as the baseline
361
+ eager_eye app/ --format json > .eager_eye_baseline.json
557
362
 
558
- > **Warning:** Auto-fix is experimental. Always review changes and run your test suite after applying fixes.
363
+ # In CI: only NEW issues count
364
+ eager_eye app/ --baseline .eager_eye_baseline.json
365
+ ```
559
366
 
560
- ## RSpec Integration
367
+ The baseline file is a normal `--format json` report. Refresh it as you
368
+ fix existing issues. The match key is `(detector, file_path, line_number,
369
+ message, severity, suggestion)` — if any of those change for a known issue,
370
+ it shows up as "new" until the baseline is refreshed.
561
371
 
562
- EagerEye provides RSpec matchers for testing your codebase:
372
+ ## RSpec integration
563
373
 
564
374
  ```ruby
565
375
  # spec/rails_helper.rb
@@ -575,228 +385,104 @@ RSpec.describe "EagerEye Analysis" do
575
385
  expect("app/serializers").to pass_eager_eye(only: [:serializer_nesting])
576
386
  end
577
387
 
578
- # Allow some issues during migration
388
+ # Allow some during migration
579
389
  it "legacy code is acceptable" do
580
390
  expect("app/services/legacy").to pass_eager_eye(max_issues: 10)
581
391
  end
582
-
583
- it "models have no callback issues except legacy" do
584
- expect("app/models").to pass_eager_eye(
585
- only: [:callback_query],
586
- exclude: ["app/models/legacy/**"]
587
- )
588
- end
589
392
  end
590
393
  ```
591
394
 
592
- ### Matcher Options
593
-
594
- | Option | Type | Description |
595
- |--------|------|-------------|
596
- | `only` | `Array<Symbol>` | Run only specified detectors |
597
- | `exclude` | `Array<String>` | Glob patterns to exclude |
598
- | `max_issues` | `Integer` | Maximum allowed issues (default: 0) |
395
+ Matcher options: `only:` (Array<Symbol>), `exclude:` (Array<String> globs), `max_issues:` (Integer, default 0).
599
396
 
600
397
  ## Configuration
601
398
 
602
- ### Config File (.eager_eye.yml)
603
-
604
399
  ```yaml
605
- # Paths to exclude from analysis (glob patterns)
400
+ # .eager_eye.yml
606
401
  excluded_paths:
607
- - app/serializers/legacy/**
402
+ - app/legacy/**
608
403
  - lib/tasks/**
609
404
 
610
- # Detectors to enable (default: all)
611
- enabled_detectors:
405
+ enabled_detectors: # default: all
612
406
  - loop_association
613
407
  - serializer_nesting
614
- - missing_counter_cache
615
408
  - custom_method_query
616
- - count_in_iteration
617
- - callback_query
618
- - pluck_to_array
619
- - delegation_n_plus_one
620
- - decorator_n_plus_one
621
- - scope_chain_n_plus_one
622
- - validation_n_plus_one
623
-
624
- # Severity levels per detector (error, warning, info)
409
+ # ...
410
+
625
411
  severity_levels:
626
- loop_association: error # Definite N+1
627
- serializer_nesting: warning
628
- custom_method_query: warning
629
- count_in_iteration: warning
630
- callback_query: warning
631
- pluck_to_array: warning # Optimization
632
- delegation_n_plus_one: warning # Hidden delegation N+1
633
- decorator_n_plus_one: warning # Decorator/Presenter N+1
634
- scope_chain_n_plus_one: warning # Scope chain on association
635
- validation_n_plus_one: warning # Uniqueness validation in batch
636
- missing_counter_cache: info # Suggestion
637
-
638
- # Minimum severity to report (default: info)
639
- min_severity: warning
640
-
641
- # Base path to analyze (default: app)
642
- app_path: app
412
+ loop_association: error
413
+ missing_counter_cache: info
414
+ # ...
643
415
 
644
- # Exit with error code when issues found (default: true)
416
+ min_severity: warning # info | warning | error
417
+ app_path: app
645
418
  fail_on_issues: true
646
419
  ```
647
420
 
648
- ### Programmatic Configuration
421
+ Or programmatically:
649
422
 
650
423
  ```ruby
651
424
  EagerEye.configure do |config|
652
425
  config.excluded_paths = ["app/legacy/**"]
653
426
  config.enabled_detectors = [:loop_association, :serializer_nesting]
654
- config.severity_levels = { loop_association: :error, missing_counter_cache: :info }
655
427
  config.min_severity = :warning
656
- config.app_path = "app"
657
428
  config.fail_on_issues = true
658
429
  end
659
430
  ```
660
431
 
661
- ## CI Integration
432
+ ## CLI reference
662
433
 
663
- ### GitHub Actions
664
-
665
- ```yaml
666
- name: EagerEye
667
- on: [pull_request]
668
-
669
- jobs:
670
- analyze:
671
- runs-on: ubuntu-latest
672
- steps:
673
- - uses: actions/checkout@v4
674
- - uses: ruby/setup-ruby@v1
675
- with:
676
- ruby-version: "3.3"
677
- - run: gem install eager_eye
678
- - run: eager_eye app/ --format json > report.json
679
- - name: Check results
680
- run: |
681
- issues=$(cat report.json | ruby -rjson -e 'puts JSON.parse(STDIN.read)["summary"]["total_issues"]')
682
- if [ "$issues" -gt 0 ]; then
683
- echo "::warning::Found $issues potential N+1 issues"
684
- fi
685
- ```
686
-
687
- See [examples/github_action.yml](examples/github_action.yml) for a complete example with PR annotations.
688
-
689
- ## CLI Reference
690
-
691
- ```
434
+ ```text
692
435
  Usage: eager_eye [paths] [options]
693
436
 
694
- Options:
695
- -f, --format FORMAT Output format: console, json (default: console)
696
- -e, --exclude PATTERN Exclude files matching pattern (can be used multiple times)
697
- -o, --only DETECTORS Run only specified detectors (comma-separated)
698
- -s, --min-severity LEVEL Minimum severity to report (info, warning, error)
699
- --no-fail Exit with 0 even when issues are found
700
- --no-color Disable colored output
701
- -v, --version Show version
702
- -h, --help Show this help message
703
- ```
704
-
705
- ## Output Formats
706
-
707
- ### Console (default)
708
-
709
- ```
710
- EagerEye Analysis Results
711
- =========================
712
-
713
- app/controllers/posts_controller.rb
714
- Line 15: [LoopAssociation] Potential N+1 query: `post.author` called inside iteration
715
- Suggestion: Consider using `includes(:author)` on the collection before iterating
716
-
717
- Line 23: [MissingCounterCache] `.count` called on `comments` may cause N+1 queries
718
- Suggestion: Consider adding `counter_cache: true` to the belongs_to association
719
-
720
- ----------------------------------------
721
- Total: 2 issues (2 warnings, 0 errors)
722
- ```
723
-
724
- ### JSON
725
-
726
- ```json
727
- {
728
- "summary": {
729
- "total_issues": 2,
730
- "warnings": 2,
731
- "errors": 0,
732
- "files_analyzed": 15
733
- },
734
- "issues": [
735
- {
736
- "detector": "loop_association",
737
- "file_path": "app/controllers/posts_controller.rb",
738
- "line_number": 15,
739
- "message": "Potential N+1 query: `post.author` called inside iteration",
740
- "severity": "warning",
741
- "suggestion": "Consider using `includes(:author)` on the collection"
742
- }
743
- ]
744
- }
437
+ -f, --format FORMAT console | json (default: console)
438
+ -e, --exclude PATTERN glob to exclude (repeatable)
439
+ -o, --only DETECTORS comma-separated detector list
440
+ -s, --min-severity LEVEL info | warning | error
441
+ --no-fail always exit 0
442
+ --no-color plain output
443
+ --baseline FILE compare against a previous JSON report;
444
+ only NEW issues are reported (and counted)
445
+ --suggest-fixes print fix diffs without applying
446
+ --fix interactively apply auto-fixes
447
+ --fix --force apply all auto-fixes
448
+ -v, --version
449
+ -h, --help
745
450
  ```
746
451
 
747
452
  ## Limitations
748
453
 
749
- EagerEye uses static analysis, which means:
750
-
751
- - **No runtime context** - Cannot know if associations are already eager loaded elsewhere
752
- - **Heuristic-based** - Uses naming conventions to identify associations (may have false positives)
753
- - **Ruby code only** - Does not analyze SQL queries or ActiveRecord internals
754
- - **Cross-file scope** - Cross-file analysis covers model-defined query methods; controller-to-view or service-to-service patterns are not yet tracked
755
-
756
- For best results, use EagerEye alongside runtime tools like Bullet for comprehensive N+1 detection.
757
-
758
- ## VS Code Extension
454
+ EagerEye is static analysis. That comes with trade-offs:
759
455
 
760
- EagerEye is also available as a VS Code extension for real-time analysis while coding.
456
+ - **No runtime context** can't see what `find_each` block actually does at runtime.
457
+ - **Heuristic association detection** — falls back to common name patterns (`author`, `user`, ...) when a model isn't in the parsed set; can over-flag in tiny edge cases.
458
+ - **Cross-file flow** — propagates preloads across same-class methods (controller → its private helpers), but cross-file flow (controller → external service object → iteration) isn't tracked yet.
459
+ - **Ruby code only** — doesn't read SQL or your DB schema.
761
460
 
762
- **Features:**
763
- - Real-time analysis on file save
764
- - Problem highlighting with squiggly underlines
765
- - Quick fix actions for common issues
766
- - Status bar showing issue count
767
-
768
- **Install:** Search for "EagerEye" in VS Code Extensions or visit the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=hamzagedikkaya.eager-eye).
461
+ Use it alongside [Bullet](https://github.com/flyerhzm/bullet) for a complete picture: static (EagerEye) catches code paths tests don't hit, runtime (Bullet) catches what static can't see.
769
462
 
770
463
  ## Development
771
464
 
772
465
  ```bash
773
- # Setup
774
466
  bin/setup
775
-
776
- # Run tests
777
467
  bundle exec rspec
778
-
779
- # Run linter
780
468
  bundle exec rubocop
781
-
782
- # Interactive console
783
469
  bin/console
784
470
  ```
785
471
 
786
472
  ## Contributing
787
473
 
788
- Bug reports and pull requests are welcome on GitHub at https://github.com/hamzagedikkaya/eager_eye.
474
+ Bug reports and PRs welcome at <https://github.com/hamzagedikkaya/eager_eye>.
789
475
 
790
- 1. Fork the repository
791
- 2. Create your feature branch (`git checkout -b feature/my-feature`)
792
- 3. Commit your changes (`git commit -am 'Add my feature'`)
793
- 4. Push to the branch (`git push origin feature/my-feature`)
794
- 5. Create a Pull Request
476
+ 1. Fork
477
+ 2. `git checkout -b feature/my-feature`
478
+ 3. Add specs (this repo is at ~95% coverage)
479
+ 4. `git commit -am 'Add my feature'`
480
+ 5. Open a Pull Request
795
481
 
796
482
  ## License
797
483
 
798
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
484
+ MIT see [LICENSE.txt](LICENSE.txt).
799
485
 
800
486
  ## Code of Conduct
801
487
 
802
- Everyone interacting in the EagerEye project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/hamzagedikkaya/eager_eye/blob/master/CODE_OF_CONDUCT.md).
488
+ Everyone interacting in EagerEye's codebases, issue trackers, and discussions is expected to follow the [code of conduct](CODE_OF_CONDUCT.md).