scope_hunter 0.1.3 → 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 +4 -4
- data/CHANGELOG.md +50 -0
- data/README.md +278 -18
- data/lib/config/default.yml +3 -1
- data/lib/rubocop/cop/scope_hunter/use_existing_scope.rb +88 -11
- data/lib/scope_hunter/ast_utils.rb +22 -3
- data/lib/scope_hunter/canonicalizer.rb +51 -48
- data/lib/scope_hunter/scope_index.rb +14 -15
- data/lib/scope_hunter/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5579aa3ad0e861d5285ca7f1e69f1dff179febb032f197f8f8745b0938366755
|
|
4
|
+
data.tar.gz: 30ad427605fbb0b7cda71b5d7baf926796978673c6a0014ee75d649148d6acff
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
1
|
+
# Scope Hunter
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.ruby-lang.org/)
|
|
4
|
+
[](https://rubocop.org/)
|
|
5
|
+
[](LICENSE.txt)
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
> A RuboCop extension that catches duplicate ActiveRecord queries and replaces them with the named scope you already wrote.
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
---
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
## The Problem
|
|
10
12
|
|
|
11
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## How It Works (for the curious)
|
|
24
255
|
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
291
|
+
Bug reports and pull requests are welcome at [github.com/Ajithxolo/scope_hunter](https://github.com/Ajithxolo/scope_hunter).
|
|
36
292
|
|
|
37
|
-
|
|
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
|
-
|
|
299
|
+
---
|
|
40
300
|
|
|
41
|
-
##
|
|
301
|
+
## License
|
|
42
302
|
|
|
43
|
-
|
|
303
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|
data/lib/config/default.yml
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
([
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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,20 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ScopeHunter
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
data/lib/scope_hunter/version.rb
CHANGED
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.
|
|
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:
|
|
11
|
+
date: 2026-03-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rubocop
|