scope_hunter 0.1.2 → 0.1.4

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: 152b7ad3fe4ef80bd06512c3bf89c9251b062036b61d8594e30f3ed0c447e005
4
- data.tar.gz: 469869fccbb1952f7ab62fe42f128d2cdcc9f824a400360f8bebec36b3ed8368
3
+ metadata.gz: 5579aa3ad0e861d5285ca7f1e69f1dff179febb032f197f8f8745b0938366755
4
+ data.tar.gz: 30ad427605fbb0b7cda71b5d7baf926796978673c6a0014ee75d649148d6acff
5
5
  SHA512:
6
- metadata.gz: ebc695af9654fd651e2e6ce8357af162225fd335e969a09b77f77759d6b705f19b1d59ebe7e5565e41dd50fdb5bbf11526a8907ed43aa162818cb6c8217114ca
7
- data.tar.gz: a3ff44df8bb996cb3aa82ff4283e0fa2935b922463ed8315e700c40c6c611b78050969ead365a46c30b59f182f4117826635f452f10c597cb66ba9637a9dc629
6
+ metadata.gz: 17426f0b9ddd3c1bfc6b92e30cbcdb5f5ad14c948d2735b8de805aa18c29f7843acf493dd676eaa65420a48ea3a16cad894ecd3fd8e4ca289e7d21db6143b6c9
7
+ data.tar.gz: 74b3c4d7846ab8c1826c01ce9a081d1e2be9cf090c7b37bccbb62f46e9e1d1a775605900511449d01fde5f7d9720c41b5aa0a131160a76d4ceb36e7c7f027bd3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.1.4] - 2026-03-27
4
+
5
+ ### Fixed
6
+ - Gemspec shipped with empty `config/` glob — default config was never included in the
7
+ installed gem; corrected to `lib/config/*.yml`
8
+ - Bare `rescue` replaced with `rescue NoMethodError` / `rescue StandardError` so unexpected
9
+ exceptions are no longer silently swallowed
10
+ - Removed `SuggestPartialMatches` config key from `default.yml` — it was never implemented
11
+
12
+ ### Changed
13
+ - Indentation normalised to 2-space throughout `canonicalizer.rb` and `scope_index.rb`
14
+ - Added cop-level test for `rewhere` query matching an existing scope
15
+
16
+ ## [0.1.3] - 2026-03-27
17
+
18
+ ### Added
19
+ - Cross-file scope detection: scopes defined in `app/models/` are indexed and matched
20
+ against queries anywhere in the codebase (controllers, services, jobs, etc.)
21
+ - `where.not()` support: queries using `where.not(...)` are matched against scopes
22
+ that use the same pattern
23
+ - `IgnoreModels` configuration option to exclude specific models from detection and indexing
24
+ - `ModelPaths` configuration option to control which files are scanned for scope definitions
25
+ - Parameterized scope matching: scopes with lambda parameters (e.g. `->(role) { where(role: role) }`)
26
+ are matched against queries with literal values using the same key structure
27
+ - Dynamic value guard: queries with runtime values (`method calls`, `variables`) are never flagged
28
+
29
+ ### Fixed
30
+ - `normalize_hash` raised `NoMethodError` when a scope lambda used a dynamic value as the
31
+ entire where argument (e.g. `where(:__dynamic__)`)
32
+
33
+ ### Changed
34
+ - Test suite expanded from 3 to 110+ examples with branch coverage tracking (94.5% line, 93.46% branch)
35
+
36
+ ## [0.1.2] - 2025-11-01
37
+
38
+ ### Changed
39
+ - Bumped version
40
+
41
+ ## [0.1.1] - 2025-10-20
42
+
43
+ ### Added
44
+ - LintRoller plugin integration for enhanced linting compatibility
45
+ - Default lint roller plugin metadata in gemspec
46
+ - YAML configuration files
47
+
3
48
  ## [0.1.0] - 2025-10-14
4
49
 
50
+ ### Added
5
51
  - Initial release
52
+ - `ScopeHunter/UseExistingScope` cop detects ActiveRecord queries matching named scopes
53
+ - Autocorrect support: replaces matched query with `Model.scope_name`
54
+ - Trailing method chain preservation during autocorrect
55
+ - Signature normalization: hash key order, `rewhere` treated as `where`
data/README.md CHANGED
@@ -1,43 +1,303 @@
1
- # ScopeHunter
1
+ # Scope Hunter
2
2
 
3
- TODO: Delete this and the text below, and describe your gem
3
+ [![Ruby Version](https://img.shields.io/badge/ruby-%3E%3D%203.2.0-red.svg)](https://www.ruby-lang.org/)
4
+ [![RuboCop](https://img.shields.io/badge/rubocop-%3E%3D%201.60-blue.svg)](https://rubocop.org/)
5
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE.txt)
4
6
 
5
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/scope_hunter`. To experiment with that code, run `bin/console` for an interactive prompt.
7
+ > A RuboCop extension that catches duplicate ActiveRecord queries and replaces them with the named scope you already wrote.
6
8
 
7
- ## Installation
9
+ ---
8
10
 
9
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
11
+ ## The Problem
10
12
 
11
- Install the gem and add to the application's Gemfile by executing:
13
+ Rails lets you define reusable query logic as named scopes:
14
+
15
+ ```ruby
16
+ class User < ApplicationRecord
17
+ scope :active, -> { where(status: :active) }
18
+ end
19
+ ```
20
+
21
+ But over time, the same query gets copy-pasted elsewhere without anyone realising the scope already exists:
22
+
23
+ ```ruby
24
+ # In a controller
25
+ User.where(status: :active) # duplicates User.active
26
+
27
+ # In a service object
28
+ User.where(status: :active).order(:name) # also duplicates it
29
+ ```
30
+
31
+ Scope Hunter finds these duplicates automatically and fixes them with `rubocop -A`.
32
+
33
+ ---
34
+
35
+ ## Quick Start
36
+
37
+ **1. Add to your Gemfile:**
38
+
39
+ ```ruby
40
+ gem 'scope_hunter'
41
+ ```
42
+
43
+ **2. Add to `.rubocop.yml`:**
44
+
45
+ ```yaml
46
+ require:
47
+ - scope_hunter
48
+
49
+ ScopeHunter/UseExistingScope:
50
+ Enabled: true
51
+ ```
52
+
53
+ **3. Run RuboCop:**
12
54
 
13
55
  ```bash
14
- bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
56
+ bundle exec rubocop
57
+ ```
58
+
59
+ That's it. Scope Hunter will scan your `app/models/` directory and flag any query that matches a named scope.
60
+
61
+ ---
62
+
63
+ ## What It Looks Like
64
+
65
+ Given this model:
66
+
67
+ ```ruby
68
+ class User < ApplicationRecord
69
+ scope :active, -> { where(status: :active) }
70
+ scope :with_posts, -> { joins(:posts) }
71
+ scope :recent, -> { order(created_at: :desc) }
72
+ end
73
+ ```
74
+
75
+ Scope Hunter flags these:
76
+
77
+ ```
78
+ app/controllers/users_controller.rb:5:5: C: ScopeHunter/UseExistingScope:
79
+ Query matches `User.active`. Use the scope instead.
80
+ User.where(status: :active)
81
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^
82
+
83
+ app/services/report.rb:12:5: C: ScopeHunter/UseExistingScope:
84
+ Query matches `User.with_posts`. Use the scope instead.
85
+ User.joins(:posts)
86
+ ^^^^^^^^^^^^^^^^^^
87
+ ```
88
+
89
+ Running `rubocop -A` autocorrects them:
90
+
91
+ ```ruby
92
+ # Before
93
+ User.where(status: :active).order(:name).limit(10)
94
+
95
+ # After — the matched part is replaced; the rest is kept
96
+ User.active.order(:name).limit(10)
97
+ ```
98
+
99
+ ---
100
+
101
+ ## Features
102
+
103
+ ### Scope types detected
104
+
105
+ | Scope pattern | Example |
106
+ |---|---|
107
+ | `where` / `rewhere` conditions | `scope :active, -> { where(status: :active) }` |
108
+ | `where.not` conditions | `scope :inactive, -> { where.not(status: :active) }` |
109
+ | `joins` | `scope :with_posts, -> { joins(:posts) }` |
110
+ | `order` | `scope :recent, -> { order(created_at: :desc) }` |
111
+ | Combined conditions | `scope :admin, -> { where(role: :admin).order(:name) }` |
112
+
113
+ ### Smart matching
114
+
115
+ - **`rewhere` is treated as `where`** — `rewhere(status: :active)` matches the same scope as `where(status: :active)`
116
+ - **Hash key order doesn't matter** — `where(a: 1, b: 2)` matches `where(b: 2, a: 1)`
117
+ - **Parameterized scopes are matched by shape** — `scope :by_role, ->(r) { where(role: r) }` matches any `where(role: <value>)`
118
+ - **Trailing methods are preserved** — `.order()`, `.limit()`, and anything after the matched query are kept intact
119
+ - **Cross-file detection** — scopes defined in `app/models/` are detected even when the duplicate query is in a controller, service, or job
120
+ - **Dynamic values are ignored** — `User.where(status: current_user.status)` is never flagged because the runtime value is unknown
121
+
122
+ ### Autocorrect
123
+
124
+ The cop ships with conservative autocorrect. Running `rubocop -A` will replace the flagged query with the scope name, preserving any chained methods that come after.
125
+
126
+ ---
127
+
128
+ ## Configuration
129
+
130
+ Add any of these to your `.rubocop.yml` under `ScopeHunter/UseExistingScope`:
131
+
132
+ ```yaml
133
+ ScopeHunter/UseExistingScope:
134
+ Enabled: true
135
+
136
+ # Glob patterns for model files to scan for scope definitions.
137
+ # Defaults to app/models/**/*.rb
138
+ ModelPaths:
139
+ - "app/models/**/*.rb"
140
+ - "app/domain/**/*.rb" # add extra paths as needed
141
+
142
+ # Models to skip entirely — useful for legacy or auto-generated models
143
+ # where scope reuse isn't practical.
144
+ IgnoreModels:
145
+ - LegacyReport
146
+ - AdminAuditLog
15
147
  ```
16
148
 
17
- If bundler is not being used to manage dependencies, install the gem by executing:
149
+ ### `ModelPaths`
150
+
151
+ Controls which files are scanned for scope definitions. By default, Scope Hunter reads everything under `app/models/`. If your project keeps models elsewhere, add those paths here.
152
+
153
+ ### `IgnoreModels`
154
+
155
+ A list of model class names to exclude from both detection and indexing. Queries on these models are never flagged, and scopes inside them are never indexed.
156
+
157
+ ---
158
+
159
+ ## Detailed Examples
160
+
161
+ ### `where.not`
162
+
163
+ ```ruby
164
+ scope :inactive, -> { where.not(status: :active) }
165
+
166
+ # Flagged
167
+ User.where.not(status: :active)
168
+
169
+ # Autocorrected to
170
+ User.inactive
171
+ ```
172
+
173
+ ### Parameterized scope
174
+
175
+ The scope uses a lambda parameter — Scope Hunter matches by the key name, not the value.
176
+
177
+ ```ruby
178
+ scope :by_role, ->(role) { where(role: role) }
179
+
180
+ # Flagged — value :admin doesn't matter, the key `role:` matches
181
+ User.where(role: :admin)
182
+
183
+ # Autocorrected to
184
+ User.by_role
185
+ ```
186
+
187
+ ### Dynamic values are safe
188
+
189
+ ```ruby
190
+ scope :active, -> { where(status: :active) }
191
+
192
+ # NOT flagged — runtime value is unknown, could be anything
193
+ User.where(status: current_status)
194
+ User.where(status: @status)
195
+ User.where(status: params[:status])
196
+ ```
197
+
198
+ ### Cross-file detection
199
+
200
+ Scopes in your models are indexed once per run. You can write the query anywhere in your app:
201
+
202
+ ```ruby
203
+ # app/models/user.rb
204
+ class User < ApplicationRecord
205
+ scope :active, -> { where(status: :active) }
206
+ end
207
+
208
+ # app/controllers/users_controller.rb
209
+ class UsersController < ApplicationController
210
+ def index
211
+ # Flagged — Scope Hunter found the matching scope in user.rb
212
+ @users = User.where(status: :active)
213
+ end
214
+ end
215
+ ```
216
+
217
+ ### Ignoring a model
218
+
219
+ ```yaml
220
+ # .rubocop.yml
221
+ ScopeHunter/UseExistingScope:
222
+ IgnoreModels:
223
+ - LegacyReport
224
+ ```
225
+
226
+ ```ruby
227
+ # Not flagged — LegacyReport is in the ignore list
228
+ LegacyReport.where(status: :active)
229
+ ```
230
+
231
+ ---
232
+
233
+ ## Running the cop
18
234
 
19
235
  ```bash
20
- gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
236
+ # Report all offenses
237
+ bundle exec rubocop
238
+
239
+ # Scan only your models directory
240
+ bundle exec rubocop app/models/
241
+
242
+ # Run only this cop
243
+ bundle exec rubocop --only ScopeHunter/UseExistingScope
244
+
245
+ # Autocorrect all offenses
246
+ bundle exec rubocop -A
247
+
248
+ # Autocorrect only this cop
249
+ bundle exec rubocop --only ScopeHunter/UseExistingScope -A
21
250
  ```
22
251
 
23
- ## Usage
252
+ ---
253
+
254
+ ## How It Works (for the curious)
24
255
 
25
- TODO: Write usage instructions here
256
+ 1. **Index** Before checking any file, Scope Hunter reads every file matched by `ModelPaths` and builds an in-memory index of all named scopes, keyed by a normalized signature.
257
+ 2. **Normalize** — Each scope's query chain is reduced to a canonical form: `M=User|W={status:?}|J=[]|O=[]`. Values are replaced with `?` so the shape is matched, not the literal value.
258
+ 3. **Detect** — For every ActiveRecord query found in the file being checked, the same normalization is applied and the result is looked up in the index.
259
+ 4. **Flag** — If a match is found, an offense is reported. Queries with dynamic values are skipped before this step.
260
+ 5. **Autocorrect** — The matched portion of the query is replaced with `Model.scope_name`; any trailing method chain is appended unchanged.
261
+
262
+ ---
26
263
 
27
264
  ## Development
28
265
 
29
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
266
+ ```bash
267
+ # Install dependencies
268
+ bundle install
269
+
270
+ # Run tests
271
+ bundle exec rspec
272
+
273
+ # Run tests with coverage report
274
+ bundle exec rspec --format documentation
275
+
276
+ # Install the gem locally
277
+ bundle exec rake install
278
+ ```
30
279
 
31
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
280
+ ### Releasing a new version
281
+
282
+ 1. Update `lib/scope_hunter/version.rb`
283
+ 2. Run `bundle exec rake release`
284
+
285
+ This creates a git tag, pushes the commit and tag, and publishes the gem to [rubygems.org](https://rubygems.org).
286
+
287
+ ---
32
288
 
33
289
  ## Contributing
34
290
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/scope_hunter. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/scope_hunter/blob/master/CODE_OF_CONDUCT.md).
291
+ Bug reports and pull requests are welcome at [github.com/Ajithxolo/scope_hunter](https://github.com/Ajithxolo/scope_hunter).
36
292
 
37
- ## License
293
+ 1. Fork the repository
294
+ 2. Create a feature branch: `git checkout -b my-feature`
295
+ 3. Add tests for your change (we target 90%+ coverage)
296
+ 4. Make your change and confirm tests pass: `bundle exec rspec`
297
+ 5. Push and open a pull request
38
298
 
39
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
299
+ ---
40
300
 
41
- ## Code of Conduct
301
+ ## License
42
302
 
43
- Everyone interacting in the ScopeHunter project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/scope_hunter/blob/master/CODE_OF_CONDUCT.md).
303
+ MIT see [LICENSE.txt](LICENSE.txt).
@@ -1,4 +1,6 @@
1
1
  ScopeHunter/UseExistingScope:
2
2
  Enabled: true
3
3
  Autocorrect: conservative
4
- SuggestPartialMatches: true
4
+ ModelPaths:
5
+ - "app/models/**/*.rb"
6
+ IgnoreModels: []
@@ -13,17 +13,54 @@ module RuboCop
13
13
 
14
14
  MSG = 'Query matches `%<model>s.%<scope>s`. Use the scope instead.'
15
15
 
16
+ # Class-level project index shared across all cop instances within a run.
17
+ # Accumulated from configured model files plus each file as it is processed.
18
+ @project_index = nil
19
+ @model_files_scanned = false
20
+
21
+ class << self
22
+ attr_reader :project_index
23
+
24
+ # Resets cross-file state. Call before each RSpec example to prevent pollution.
25
+ def reset_project_index!
26
+ @project_index = nil
27
+ @model_files_scanned = false
28
+ end
29
+
30
+ def ensure_project_index!
31
+ @project_index ||= ::ScopeHunter::ScopeIndex.new
32
+ end
33
+
34
+ def model_files_scanned?
35
+ @model_files_scanned
36
+ end
37
+
38
+ def mark_model_files_scanned!
39
+ @model_files_scanned = true
40
+ end
41
+ end
42
+
16
43
  def on_new_investigation
17
- @index = ::ScopeHunter::ScopeIndex.new
44
+ self.class.ensure_project_index!
45
+
46
+ unless self.class.model_files_scanned?
47
+ self.class.mark_model_files_scanned!
48
+ scan_model_files
49
+ end
50
+
18
51
  index_scopes(processed_source.ast)
19
52
  end
20
53
 
21
54
  def on_send(node)
22
55
  chain = ::ScopeHunter::ASTUtils.relation_chain(node)
23
56
  return unless chain
57
+ return if dynamic_where_values?(chain)
58
+
59
+ model_name = chain.first[:recv]
60
+ return if ignored_model?(model_name)
24
61
 
25
62
  sig = ::ScopeHunter::Canonicalizer.signature(chain)
26
- match = @index.find(sig)
63
+ match = self.class.project_index&.find(sig)
27
64
  return unless match
28
65
 
29
66
  add_offense(node, message: format(MSG, model: match.model, scope: match.name)) do |corrector|
@@ -34,18 +71,59 @@ module RuboCop
34
71
 
35
72
  private
36
73
 
74
+ def scan_model_files
75
+ model_file_paths.each do |path|
76
+ next if current_file?(path)
77
+
78
+ src = ::RuboCop::ProcessedSource.new(File.read(path), RUBY_VERSION.to_f, path)
79
+ index_scopes(src.ast)
80
+ rescue StandardError
81
+ next
82
+ end
83
+ end
84
+
85
+ def model_file_paths
86
+ patterns = cop_config.fetch("ModelPaths", ["app/models/**/*.rb"])
87
+ Array(patterns).flat_map { |p| Dir.glob(p) }
88
+ end
89
+
90
+ def ignored_model?(model_name)
91
+ Array(cop_config.fetch("IgnoreModels", [])).include?(model_name)
92
+ end
93
+
94
+ # Returns true when any where/rewhere/where_not step in the chain contains a
95
+ # dynamic value (method call, variable, etc.). Matching against a scope in
96
+ # that case would be a false positive because the runtime value is unknown
97
+ # and may not correspond to what the scope filters for.
98
+ def dynamic_where_values?(chain)
99
+ chain.any? do |step|
100
+ next false unless %i[where rewhere where_not].include?(step[:msg])
101
+
102
+ step[:args].any? do |arg|
103
+ arg == :__dynamic__ ||
104
+ (arg.is_a?(Hash) && arg.any? { |_, v| v == :__dynamic__ })
105
+ end
106
+ end
107
+ end
108
+
109
+ def current_file?(path)
110
+ return false unless processed_source.path
111
+ File.expand_path(path) == File.expand_path(processed_source.path)
112
+ end
113
+
37
114
  def index_scopes(ast)
38
115
  return unless ast
39
- # Find `scope :name, -> { ... }`
116
+
40
117
  ast.each_node(:send) do |send|
41
118
  next unless send.method_name == :scope
119
+
42
120
  name_node = send.arguments[0]
43
121
  next unless name_node&.sym_type?
44
122
 
45
123
  model = enclosing_class_name(send)
46
124
  next unless model
125
+ next if ignored_model?(model)
47
126
 
48
- # The body is the lambda argument (second argument)
49
127
  lambda_body = send.arguments[1]
50
128
  next unless lambda_body&.block_type?
51
129
 
@@ -53,7 +131,7 @@ module RuboCop
53
131
  next unless chain
54
132
 
55
133
  sig = ::ScopeHunter::Canonicalizer.signature(chain, model: model)
56
- @index.add(model:, name: name_node.value, signature: sig)
134
+ self.class.project_index.add(model:, name: name_node.value, signature: sig)
57
135
  end
58
136
  end
59
137
 
@@ -63,25 +141,24 @@ module RuboCop
63
141
  klass.identifier.const_name
64
142
  end
65
143
 
66
- # Conservative autocorrect: replace the first AR part with Model.scope, keep trailing chain
67
144
  def replacement_for(node, match)
68
145
  trailing = trailing_after_first_ar(node)
69
- ([ "#{match.model}.#{match.name}" ] + trailing).join
70
- rescue
146
+ (["#{match.model}.#{match.name}"] + trailing).join
147
+ rescue StandardError
71
148
  nil
72
149
  end
73
150
 
74
151
  def trailing_after_first_ar(node)
75
152
  out = []
76
153
  cur = node
77
- # Gather segments like `.order(...).limit(5)` in reverse
78
154
  while cur&.send_type?
79
155
  seg = "." + cur.method_name.to_s
80
156
  seg << "(#{cur.arguments.map(&:source).join(", ")})" unless cur.arguments.empty?
81
157
  out.unshift(seg)
82
- cur = cur.receiver
158
+ # where.not(...) counts as a single logical step — skip both nodes.
159
+ cur = ::ScopeHunter::ASTUtils.where_not_node?(cur) ? cur.receiver.receiver : cur.receiver
83
160
  end
84
- out.drop(1) # drop the first AR call; replaced by scope
161
+ out.drop(1)
85
162
  end
86
163
  end
87
164
  end
@@ -9,6 +9,7 @@ module ScopeHunter
9
9
  AR_METHODS = %i[where rewhere joins order limit offset select distinct group having references includes preload].freeze
10
10
 
11
11
  # Returns array of steps: [{recv: "User", msg: :where, args: [{status: :active}]}, ...]
12
+ # Handles the compound `where.not(...)` pattern as a single :where_not step.
12
13
  def relation_chain(node, require_model: true)
13
14
  return nil unless node&.send_type?
14
15
 
@@ -21,7 +22,17 @@ module ScopeHunter
21
22
  recv = cur.receiver
22
23
  args = cur.arguments
23
24
 
24
- if AR_METHODS.include?(msg) || model_const?(recv)
25
+ if where_not_node?(cur)
26
+ # `recv` is the bare `.where` call; its receiver is the model (or nil in scope body)
27
+ model_recv = recv.receiver
28
+ chain.unshift({
29
+ recv: model_recv&.const_type? ? const_name(model_recv) : nil,
30
+ msg: :where_not,
31
+ args: unwrap_args(args)
32
+ })
33
+ seen_ar = true
34
+ cur = model_recv
35
+ elsif AR_METHODS.include?(msg) || model_const?(recv)
25
36
  chain.unshift({
26
37
  recv: recv&.const_type? ? const_name(recv) : nil,
27
38
  msg: msg,
@@ -39,13 +50,20 @@ module ScopeHunter
39
50
  chain
40
51
  end
41
52
 
53
+ def where_not_node?(node)
54
+ node.method_name == :not &&
55
+ node.receiver&.send_type? &&
56
+ node.receiver.method_name == :where &&
57
+ node.receiver.arguments.empty?
58
+ end
59
+
42
60
  def model_const?(node)
43
61
  node&.const_type?
44
62
  end
45
63
 
46
64
  def const_name(node)
47
65
  node.const_name
48
- rescue
66
+ rescue NoMethodError
49
67
  nil
50
68
  end
51
69
 
@@ -67,7 +85,8 @@ module ScopeHunter
67
85
  when node.true_type? then true
68
86
  when node.false_type? then false
69
87
  when node.const_type? then node.const_name.to_sym
70
- else :__dynamic__
88
+ else :__dynamic__ # lvar, ivar, send, etc. — normalized to ? by Canonicalizer,
89
+ # enabling parameterized scope matching
71
90
  end
72
91
  end
73
92
  end
@@ -1,56 +1,59 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ScopeHunter
4
- module Canonicalizer
5
- extend self
6
-
7
- # chain: array of {recv:, msg:, args:}
8
- # output: stable signature string
9
- def signature(chain, model: nil)
10
- state = { model: model, where: {}, joins: [], order: [] }
11
-
12
- chain.each do |step|
13
- state[:model] ||= step[:recv] if step[:recv].is_a?(String)
14
-
15
- case step[:msg]
16
- when :where, :rewhere
17
- h = normalize_hash(step[:args].first)
18
- state[:where].merge!(h)
19
- when :joins
20
- state[:joins] |= normalize_list(step[:args])
21
- state[:joins].sort!
22
- when :order
23
- state[:order] += normalize_order(step[:args])
24
- end
4
+ module Canonicalizer
5
+ extend self
6
+
7
+ # chain: array of {recv:, msg:, args:}
8
+ # output: stable signature string
9
+ def signature(chain, model: nil)
10
+ state = { model: model, where: {}, where_not: {}, joins: [], order: [] }
11
+
12
+ chain.each do |step|
13
+ state[:model] ||= step[:recv] if step[:recv].is_a?(String)
14
+
15
+ case step[:msg]
16
+ when :where, :rewhere
17
+ state[:where].merge!(normalize_hash(step[:args].first))
18
+ when :where_not
19
+ state[:where_not].merge!(normalize_hash(step[:args].first))
20
+ when :joins
21
+ state[:joins] |= normalize_list(step[:args])
22
+ state[:joins].sort!
23
+ when :order
24
+ state[:order] += normalize_order(step[:args])
25
25
  end
26
-
27
- w = state[:where].sort_by(&:first).map { |k,_| "#{k}:?" }.join(",")
28
- j = state[:joins].join(",")
29
- o = state[:order].map { |(c,d)| "(#{c},#{d || 'asc'})" }.join(",")
30
-
31
- "M=#{state[:model]}|W={#{w}}|J=[#{j}]|O=[#{o}]"
32
- end
33
-
34
- def normalize_hash(obj)
35
- h = (obj || {}).to_h
36
- h.transform_keys { |k| k.to_s } .transform_values { |_| :"?" }
37
26
  end
38
-
39
- def normalize_list(args)
40
- Array(args).flatten.compact.map(&:to_s)
41
- end
42
-
43
- def normalize_order(args)
44
- first = args.first
45
- case first
46
- when Hash
47
- first.map { |k, v| [k.to_s, v&.to_s] }
48
- when Symbol, String
49
- [[first.to_s, "asc"]]
50
- else
51
- []
52
- end
27
+
28
+ w = state[:where].sort_by(&:first).map { |k, _| "#{k}:?" }.join(",")
29
+ wn = state[:where_not].sort_by(&:first).map { |k, _| "#{k}:?" }.join(",")
30
+ j = state[:joins].join(",")
31
+ o = state[:order].map { |(c, d)| "(#{c},#{d || "asc"})" }.join(",")
32
+
33
+ sig = "M=#{state[:model]}|W={#{w}}|J=[#{j}]|O=[#{o}]"
34
+ sig += "|WN={#{wn}}" unless wn.empty?
35
+ sig
36
+ end
37
+
38
+ def normalize_hash(obj)
39
+ h = obj.is_a?(Hash) ? obj : {}
40
+ h.transform_keys { |k| k.to_s }.transform_values { |_| :"?" }
41
+ end
42
+
43
+ def normalize_list(args)
44
+ Array(args).flatten.compact.map(&:to_s)
45
+ end
46
+
47
+ def normalize_order(args)
48
+ first = args.first
49
+ case first
50
+ when Hash
51
+ first.map { |k, v| [k.to_s, v&.to_s] }
52
+ when Symbol, String
53
+ [[first.to_s, "asc"]]
54
+ else
55
+ []
53
56
  end
54
57
  end
55
58
  end
56
-
59
+ end
@@ -1,22 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "lint_roller"
4
-
5
3
  module ScopeHunter
6
- class Plugin < LintRoller::Plugin
7
- def name = "scope_hunter"
8
- def version = ::ScopeHunter::VERSION
9
-
10
- # Tell Standard/RuboCop how to load our cops + config
11
- def rules
12
- LintRoller::Rules.new(
13
- rubocop: {
14
- # require our gem so the injector runs and cops are available
15
- require: ["scope_hunter"],
16
- # point to the default config that enables the cop
17
- config: File.expand_path("../../config/default.yml", __FILE__)
18
- }
19
- )
4
+ # RuboCop plugin class for gem integration
5
+ class Plugin < RuboCop::Plugin
6
+ def self.plugin_name
7
+ "scope_hunter"
20
8
  end
21
9
  end
22
10
  end
@@ -1,20 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ScopeHunter
4
- Scope = Struct.new(:model, :name, :signature, keyword_init: true)
5
-
6
- class ScopeIndex
7
- def initialize
8
- @by_signature = {}
9
- end
10
-
11
- def add(model:, name:, signature:)
12
- (@by_signature[signature] ||= []) << Scope.new(model:, name:, signature:)
13
- end
14
-
15
- def find(signature)
16
- Array(@by_signature[signature]).first
17
- end
4
+ Scope = Struct.new(:model, :name, :signature, keyword_init: true)
5
+
6
+ class ScopeIndex
7
+ def initialize
8
+ @by_signature = {}
9
+ end
10
+
11
+ def add(model:, name:, signature:)
12
+ (@by_signature[signature] ||= []) << Scope.new(model:, name:, signature:)
13
+ end
14
+
15
+ def find(signature)
16
+ Array(@by_signature[signature]).first
18
17
  end
19
18
  end
20
-
19
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ScopeHunter
4
- VERSION = "0.1.2"
4
+ VERSION = "0.1.4"
5
5
  end
data/lib/scope_hunter.rb CHANGED
@@ -5,11 +5,12 @@ require "scope_hunter/version" if File.exist?(File.join(__dir__, "scope_hunter/v
5
5
  require "scope_hunter/ast_utils"
6
6
  require "scope_hunter/canonicalizer"
7
7
  require "scope_hunter/scope_index"
8
- require "scope_hunter/plugin"
9
8
 
10
9
  # When rubocop loads, inject our default config
11
10
  begin
12
11
  require "rubocop"
12
+ # Load the cop BEFORE inject to ensure it's registered
13
+ require "rubocop/cop/scope_hunter/use_existing_scope"
13
14
  require "rubocop/scope_hunter/inject"
14
15
  rescue LoadError
15
16
  # rubocop not present (e.g., runtime only) — that's fine
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: scope_hunter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ajith kumar
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-10-27 00:00:00.000000000 Z
11
+ date: 2026-03-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rubocop
@@ -93,15 +93,15 @@ files:
93
93
  - lib/scope_hunter/scope_index.rb
94
94
  - lib/scope_hunter/version.rb
95
95
  - sig/scope_hunter.rbs
96
- homepage: https://github.com/ajithbuddy/scope_hunter
96
+ homepage: https://github.com/Ajithxolo/scope_hunter
97
97
  licenses:
98
98
  - MIT
99
99
  metadata:
100
100
  allowed_push_host: https://rubygems.org
101
- homepage_uri: https://github.com/ajithbuddy/scope_hunter
102
- source_code_uri: https://github.com/ajithbuddy/scope_hunter
103
- changelog_uri: https://github.com/ajithbuddy/scope_hunter/blob/main/CHANGELOG.md
104
- default_lint_roller_plugin: ScopeHunter::Plugin
101
+ homepage_uri: https://github.com/Ajithxolo/scope_hunter
102
+ source_code_uri: https://github.com/Ajithxolo/scope_hunter
103
+ changelog_uri: https://github.com/Ajithxolo/scope_hunter/blob/main/CHANGELOG.md
104
+ rubocop_plugin: ScopeHunter::Plugin
105
105
  post_install_message:
106
106
  rdoc_options: []
107
107
  require_paths: