rubocop-fourshark 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +20 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +129 -0
- data/Rakefile +33 -0
- data/config/default.yml +96 -0
- data/lib/rubocop/cop/factory_bot/association_in_factory.rb +67 -0
- data/lib/rubocop/cop/fourshark_cops.rb +14 -0
- data/lib/rubocop/cop/layout/multiline_statement_spacing.rb +55 -0
- data/lib/rubocop/cop/rails/bidirectional_association.rb +169 -0
- data/lib/rubocop/cop/rails/mandatory_inverse_of.rb +79 -0
- data/lib/rubocop/cop/rails/optional_belongs_to.rb +48 -0
- data/lib/rubocop/cop/rails/ordered_macros.rb +74 -0
- data/lib/rubocop/cop/rspec/conditional_in_let.rb +57 -0
- data/lib/rubocop/cop/rspec/factory_bot_in_before.rb +46 -0
- data/lib/rubocop/cop/rspec/inverse_of_matcher.rb +125 -0
- data/lib/rubocop/cop/rspec/overwritten_let.rb +91 -0
- data/lib/rubocop/cop/style/disallow_safe_navigation.rb +17 -0
- data/lib/rubocop/cop/style/disallow_try.rb +19 -0
- data/lib/rubocop/fourshark/plugin.rb +31 -0
- data/lib/rubocop/fourshark/version.rb +7 -0
- data/lib/rubocop/fourshark.rb +10 -0
- data/lib/rubocop-fourshark.rb +9 -0
- metadata +181 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 202b492f9486b827fd67079c3a7bb113bb194a87fd968646eebbf30b881140cc
|
|
4
|
+
data.tar.gz: 20f9b022eb67bad4c30fe2295c3d56a30c027594c15142303dcfe707c5390f85
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6cd1bc1df875dc06da1fbe6091e896afe434e99f13e64c3f564e5ac43bcf23ac5cb5a4e3e0ff6792829df9df109ec331066fac1395472b536372ced8dd025d65
|
|
7
|
+
data.tar.gz: f62cfe9f582d0ef9108800eef733aa8957852b7183cc3bee53f7182680a51ceda57293d19137f4baf9badb345cf9add64f9a15643734d0b515672188026435a9
|
data/.ruby-gemset
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rubocop-fourshark
|
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
4.0.5
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
## [0.2.1] - 2026-05-29
|
|
2
|
+
|
|
3
|
+
### Fixed
|
|
4
|
+
|
|
5
|
+
- `RSpec/InverseOfMatcher` no longer crashes RuboCop — it classifies root vs subclass statically (reading the model file) instead of loading the model class
|
|
6
|
+
|
|
7
|
+
## [0.2.0] - 2026-05-29
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Disable stock `Rails/InverseOf` and `Style/SafeNavigation`, which are superseded or contradicted by 4Shark cops
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- Cop names aligned with RuboCop naming conventions
|
|
16
|
+
- `RSpec/LetNotInContext` renamed to `RSpec/OverwrittenLet` and relaxed to flag only a `let` that overrides one from an outer scope
|
|
17
|
+
|
|
18
|
+
## [0.1.0] - 2026-05-29
|
|
19
|
+
|
|
20
|
+
- Initial release
|
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Code of Conduct
|
|
2
|
+
|
|
3
|
+
"rubocop-fourshark" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
|
|
4
|
+
|
|
5
|
+
* Participants will be tolerant of opposing views.
|
|
6
|
+
* Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
|
|
7
|
+
* When interpreting the words and actions of others, participants should always assume good intentions.
|
|
8
|
+
* Behaviour which can be reasonably considered harassment will not be tolerated.
|
|
9
|
+
|
|
10
|
+
If you have any concerns about behaviour within this project, please contact us at ["plribeiro3000@gmail.com"](mailto:"plribeiro3000@gmail.com").
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paulo Ribeiro
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# rubocop-fourshark
|
|
2
|
+
|
|
3
|
+
A RuboCop extension that encodes 4Shark's Ruby, Rails, and RSpec conventions as enforceable cops.
|
|
4
|
+
|
|
5
|
+
## Why this gem exists
|
|
6
|
+
|
|
7
|
+
4Shark has its own definition of good Ruby/Rails/RSpec code. Two needs the stock RuboCop ecosystem does not cover on its own:
|
|
8
|
+
|
|
9
|
+
1. **Conventions stock RuboCop has no cop for.** Rules like "every association declares `inverse_of`", "`belongs_to` is `optional: true` with manual presence validation", or "no associations inside factories" are 4Shark decisions with no equivalent in `rubocop-rails`/`rubocop-rspec`. This gem ships them as real cops.
|
|
10
|
+
2. **Stock conventions 4Shark deliberately rejects.** RuboCop's defaults nudge toward patterns 4Shark does not want — safe navigation (`&.`) and `try` being the clearest examples. Rather than each repo re-litigating the same `.rubocop.yml` overrides, the position is made enforceable in one place: a custom cop that flags the rejected construct.
|
|
11
|
+
|
|
12
|
+
The goal is a single dependency that every 4Shark Ruby repository inherits, so the conventions are identical across all of them — and a new convention is added once, here, instead of in each repository.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
The gem is published to RubyGems. Add it to the application's `Gemfile`:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
gem 'rubocop-fourshark', require: false
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
or:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bundle add rubocop-fourshark --require=false
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Then activate it as a plugin in `.rubocop.yml`:
|
|
29
|
+
|
|
30
|
+
```yaml
|
|
31
|
+
plugins:
|
|
32
|
+
- rubocop-fourshark
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Activating the plugin auto-loads the gem's `config/default.yml`, which enables every cop below.
|
|
36
|
+
|
|
37
|
+
> **Note — plugins are not transitively activated.** `rubocop-fourshark` depends on the upstream plugins (`rubocop-rails`, `rubocop-rspec`, `rubocop-rspec_rails`, `rubocop-performance`, `rubocop-factory_bot`), but `lint_roller` does not auto-activate a plugin's dependencies. Each repo still lists the upstream plugins it uses in its own `plugins:` block.
|
|
38
|
+
|
|
39
|
+
## Cops
|
|
40
|
+
|
|
41
|
+
All cops are **enabled by default** the moment the plugin is activated. Cops scoped to a path (models, specs, factories) only run on files matching that path. Each cop's source file under [`lib/rubocop/cop`](lib/rubocop/cop) carries runnable `@example` blocks showing the bad/good shapes.
|
|
42
|
+
|
|
43
|
+
### Naming
|
|
44
|
+
|
|
45
|
+
Cop names follow RuboCop's empirical convention — a noun phrase describing the construct or smell, no `Disallow*`/`No*` prefixes — with two deliberate exceptions:
|
|
46
|
+
|
|
47
|
+
- **`Style/DisallowSafeNavigation` / `Style/DisallowTry`** keep the `Disallow*` prefix. RuboCop has no idiom for "forbid a construct it otherwise permits" (it uses `EnforcedStyle` on one cop), and the natural noun name is already taken by a stock cop with the *opposite* intent — `Style/SafeNavigation` *converts to* `&.`. `Disallow*` is unambiguous and collision-free.
|
|
48
|
+
- **`Rails/MandatoryInverseOf`** is named to distinguish it from stock `Rails/InverseOf`, which only flags associations where Active Record cannot auto-detect the inverse. Ours mandates `inverse_of` on *every* association — a strict superset — so the gem disables the stock cop (below).
|
|
49
|
+
|
|
50
|
+
### Stock cops disabled
|
|
51
|
+
|
|
52
|
+
Where a 4Shark cop supersedes or contradicts a stock cop, `config/default.yml` turns the stock one off:
|
|
53
|
+
|
|
54
|
+
| Disabled stock cop | Why |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `Rails/InverseOf` | superseded by `Rails/MandatoryInverseOf` (covers its cases and more) |
|
|
57
|
+
| `Style/SafeNavigation` | contradicts `Style/DisallowSafeNavigation` — it pushes `&.`, we forbid it |
|
|
58
|
+
|
|
59
|
+
### Style
|
|
60
|
+
|
|
61
|
+
| Cop | Intent |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `Style/DisallowSafeNavigation` | Flags safe navigation (`&.`). 4Shark rejects it — a `&.` chain silently swallows a `nil` that usually signals a real bug. Use an explicit conditional so the `nil` case is handled on purpose. |
|
|
64
|
+
| `Style/DisallowTry` | Flags `try` / `try!`. Same rationale — it hides the `nil`/missing-method case instead of handling it explicitly. |
|
|
65
|
+
|
|
66
|
+
### Layout
|
|
67
|
+
|
|
68
|
+
| Cop | Intent |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `Layout/MultilineStatementSpacing` | Requires a blank line between two consecutive statements when either spans multiple lines, so multi-line statements read as distinct units. |
|
|
71
|
+
|
|
72
|
+
### Rails (models)
|
|
73
|
+
|
|
74
|
+
| Cop | Intent |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `Rails/MandatoryInverseOf` | Every association (`belongs_to`/`has_many`/`has_one`) must declare `inverse_of`, so both sides resolve to the same in-memory object. Stricter than stock `Rails/InverseOf`, which only fires when AR can't auto-detect the inverse. Scoped to `app/models`. |
|
|
77
|
+
| `Rails/BidirectionalAssociation` | An association must be declared on both sides of the relationship — the opposite model must carry the matching association. Scoped to `app/models`. |
|
|
78
|
+
| `Rails/OptionalBelongsTo` | `belongs_to` must be `optional: true` (see the rationale below). Scoped to `app/models`. |
|
|
79
|
+
| `Rails/OrderedMacros` | Same-kind class macros (associations, validations, scopes) must be sorted alphabetically within their group. Scoped to `app/models`. |
|
|
80
|
+
|
|
81
|
+
### RSpec (specs)
|
|
82
|
+
|
|
83
|
+
| Cop | Intent |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `RSpec/InverseOfMatcher` | Root models must assert `.inverse_of` in association specs; subclasses must not (it belongs to the parent). Scoped to `spec/models`. |
|
|
86
|
+
| `RSpec/OverwrittenLet` | A `let`/`let!` must not override one defined in an outer example group — shadowing makes it ambiguous which value applies. Scenario-specific `let`s, and the same name across sibling contexts, are fine. |
|
|
87
|
+
| `RSpec/ConditionalInLet` | A `let` must not contain conditional logic (`if`/`case`) — branch with separate `context`s instead. Ternaries are allowed. |
|
|
88
|
+
| `RSpec/FactoryBotInBefore` | Object creation belongs in `let`, not `before`. `before` is for actions, not for building the subjects under test. |
|
|
89
|
+
|
|
90
|
+
### FactoryBot (factories)
|
|
91
|
+
|
|
92
|
+
| Cop | Intent |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `FactoryBot/AssociationInFactory` | No associations declared inside a factory — they trigger cascading object creation and callbacks. Set the association manually in the spec. Scoped to `spec/factories`. |
|
|
95
|
+
|
|
96
|
+
The full rationale behind each convention (the "why this is good 4Shark code") lives in the team's engineering docs; this README states the intent each cop enforces. One convention deviates from a safe Rails default and is worth spelling out — see below.
|
|
97
|
+
|
|
98
|
+
### Why `belongs_to` is `optional: true` by default
|
|
99
|
+
|
|
100
|
+
4Shark never exposes internal database IDs across its API and upload boundaries. Clients send their **own** identifiers; each external identifier is mapped to its internal record (a surrogate-key cross-reference), and that lookup is scoped to the client's account — so it confirms both that the record **exists** and that it **belongs to the caller**, in a single step.
|
|
101
|
+
|
|
102
|
+
By the time an association is assigned, existence and ownership have already been verified — more strictly than Rails would. Rails' default `belongs_to` (`optional: false`) then adds an existence `SELECT` per record on top of that: redundant work, and at high API throughput a measurable per-request cost.
|
|
103
|
+
|
|
104
|
+
So the convention is:
|
|
105
|
+
|
|
106
|
+
- `belongs_to` is always declared `optional: true` (enforced by `Rails/OptionalBelongsTo`), turning off Rails' automatic existence validation.
|
|
107
|
+
- Presence is validated manually with `validates :x_id, presence: true` **where the business rule requires it** — case by case, not globally (which is why no cop enforces the presence side).
|
|
108
|
+
|
|
109
|
+
A deliberate performance trade-off backed by the external-identifier mapping — not an omission.
|
|
110
|
+
|
|
111
|
+
## Development
|
|
112
|
+
|
|
113
|
+
After checking out the repo, install dependencies and run the suite:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
bundle install
|
|
117
|
+
bundle exec rspec # cop specs
|
|
118
|
+
bundle exec rubocop # self-lint (includes rubocop-internal_affairs)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Each new cop ships with a spec (`expect_offense` / `expect_no_offenses`) and a `config/default.yml` entry.
|
|
122
|
+
|
|
123
|
+
## Releasing
|
|
124
|
+
|
|
125
|
+
This project does **not** use HubFlow. Releases are cut from `main`: feature branches merge into `main` via PR, the version is bumped, a `vX.Y.Z` tag is created from `main`, and the gem is published to RubyGems. Consuming repos depend on the published version in their `Gemfile`.
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
Released under the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/gem_tasks'
|
|
4
|
+
require 'rspec/core/rake_task'
|
|
5
|
+
require 'rubocop/rake_task'
|
|
6
|
+
|
|
7
|
+
RSpec::Core::RakeTask.new(:spec) do |spec|
|
|
8
|
+
spec.pattern = FileList['spec/**/*_spec.rb']
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
RuboCop::RakeTask.new
|
|
12
|
+
|
|
13
|
+
task default: %i[spec rubocop]
|
|
14
|
+
|
|
15
|
+
desc 'Generate a new cop with a template'
|
|
16
|
+
|
|
17
|
+
task :new_cop, [:cop] do |_task, args|
|
|
18
|
+
require 'rubocop'
|
|
19
|
+
|
|
20
|
+
cop_name = args.fetch(:cop) do
|
|
21
|
+
warn 'usage: bundle exec rake new_cop[Department/Name]'
|
|
22
|
+
exit!
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
generator = RuboCop::Cop::Generator.new(cop_name)
|
|
26
|
+
|
|
27
|
+
generator.write_source
|
|
28
|
+
generator.write_spec
|
|
29
|
+
generator.inject_require(root_file_path: 'lib/rubocop/cop/fourshark_cops.rb')
|
|
30
|
+
generator.inject_config(config_file_path: 'config/default.yml')
|
|
31
|
+
|
|
32
|
+
puts generator.todo
|
|
33
|
+
end
|
data/config/default.yml
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# rubocop-fourshark — custom cops (4Shark conventions).
|
|
2
|
+
# Cops live in their natural stock departments (Style/Rails/RSpec/Layout/FactoryBot)
|
|
3
|
+
# so the names read like any other cop. Where a 4Shark cop supersedes or
|
|
4
|
+
# contradicts a stock cop, the stock cop is disabled here (see the bottom block).
|
|
5
|
+
|
|
6
|
+
Layout/MultilineStatementSpacing:
|
|
7
|
+
Description: 'Add a blank line between consecutive statements when either spans multiple lines.'
|
|
8
|
+
Enabled: true
|
|
9
|
+
VersionAdded: '0.2.0'
|
|
10
|
+
|
|
11
|
+
Style/DisallowSafeNavigation:
|
|
12
|
+
Description: 'Do not use safe navigation (`&.`). Use explicit conditionals instead.'
|
|
13
|
+
Enabled: true
|
|
14
|
+
VersionAdded: '0.1.0'
|
|
15
|
+
|
|
16
|
+
Style/DisallowTry:
|
|
17
|
+
Description: 'Do not use `try` or `try!`. Use explicit conditionals instead.'
|
|
18
|
+
Enabled: true
|
|
19
|
+
VersionAdded: '0.1.0'
|
|
20
|
+
|
|
21
|
+
Rails/MandatoryInverseOf:
|
|
22
|
+
Description: 'Every association must declare `inverse_of` (stricter than stock `Rails/InverseOf`).'
|
|
23
|
+
Enabled: true
|
|
24
|
+
Include:
|
|
25
|
+
- 'app/models/**/*.rb'
|
|
26
|
+
Exclude:
|
|
27
|
+
- 'app/serializers/**/*.rb'
|
|
28
|
+
VersionAdded: '0.2.0'
|
|
29
|
+
|
|
30
|
+
Rails/BidirectionalAssociation:
|
|
31
|
+
Description: 'Associations must be declared on both sides of the relationship.'
|
|
32
|
+
Enabled: true
|
|
33
|
+
Include:
|
|
34
|
+
- 'app/models/**/*.rb'
|
|
35
|
+
Exclude:
|
|
36
|
+
- 'app/serializers/**/*.rb'
|
|
37
|
+
VersionAdded: '0.2.0'
|
|
38
|
+
|
|
39
|
+
Rails/OptionalBelongsTo:
|
|
40
|
+
Description: 'Declare `belongs_to` with `optional: true` and validate presence manually.'
|
|
41
|
+
Enabled: true
|
|
42
|
+
Include:
|
|
43
|
+
- 'app/models/**/*.rb'
|
|
44
|
+
VersionAdded: '0.1.0'
|
|
45
|
+
|
|
46
|
+
Rails/OrderedMacros:
|
|
47
|
+
Description: 'Sort same-kind class macro declarations (associations, validations, scopes) alphabetically.'
|
|
48
|
+
Enabled: true
|
|
49
|
+
Include:
|
|
50
|
+
- 'app/models/**/*.rb'
|
|
51
|
+
VersionAdded: '0.2.0'
|
|
52
|
+
|
|
53
|
+
RSpec/InverseOfMatcher:
|
|
54
|
+
Description: 'Root models must include `.inverse_of` in association specs; subclasses must not.'
|
|
55
|
+
Enabled: true
|
|
56
|
+
Include:
|
|
57
|
+
- 'spec/models/**/*_spec.rb'
|
|
58
|
+
VersionAdded: '0.2.0'
|
|
59
|
+
|
|
60
|
+
RSpec/OverwrittenLet:
|
|
61
|
+
Description: 'Do not override a `let` defined in an outer example group.'
|
|
62
|
+
Enabled: true
|
|
63
|
+
Include:
|
|
64
|
+
- 'spec/**/*_spec.rb'
|
|
65
|
+
VersionAdded: '0.2.0'
|
|
66
|
+
|
|
67
|
+
RSpec/ConditionalInLet:
|
|
68
|
+
Description: 'Do not put conditional logic in a `let` — use separate contexts.'
|
|
69
|
+
Enabled: true
|
|
70
|
+
Include:
|
|
71
|
+
- 'spec/**/*_spec.rb'
|
|
72
|
+
VersionAdded: '0.2.0'
|
|
73
|
+
|
|
74
|
+
RSpec/FactoryBotInBefore:
|
|
75
|
+
Description: 'Do not create objects in `before` — use `let` for object creation.'
|
|
76
|
+
Enabled: true
|
|
77
|
+
Include:
|
|
78
|
+
- 'spec/**/*_spec.rb'
|
|
79
|
+
VersionAdded: '0.2.0'
|
|
80
|
+
|
|
81
|
+
FactoryBot/AssociationInFactory:
|
|
82
|
+
Description: 'Do not declare associations in factories — set them manually in the spec.'
|
|
83
|
+
Enabled: true
|
|
84
|
+
Include:
|
|
85
|
+
- 'spec/factories/**/*.rb'
|
|
86
|
+
VersionAdded: '0.2.0'
|
|
87
|
+
|
|
88
|
+
# Stock cops disabled because a 4Shark cop supersedes or contradicts them.
|
|
89
|
+
# `Rails/InverseOf` only flags when AR can't auto-detect the inverse;
|
|
90
|
+
# `Rails/MandatoryInverseOf` covers it and more.
|
|
91
|
+
# `Style/SafeNavigation` converts to `&.`, the opposite of `Style/DisallowSafeNavigation`.
|
|
92
|
+
Rails/InverseOf:
|
|
93
|
+
Enabled: false
|
|
94
|
+
|
|
95
|
+
Style/SafeNavigation:
|
|
96
|
+
Enabled: false
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rubocop'
|
|
4
|
+
|
|
5
|
+
module RuboCop
|
|
6
|
+
module Cop
|
|
7
|
+
module FactoryBot
|
|
8
|
+
# Forbids declaring associations inside a `factory`/`trait` block. An
|
|
9
|
+
# association is a bare attribute call with no value (e.g. `customer`),
|
|
10
|
+
# which triggers cascading object creation and callbacks. Set the
|
|
11
|
+
# association manually in the spec instead.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# # bad
|
|
15
|
+
# factory :payment do
|
|
16
|
+
# amount { 100.0 }
|
|
17
|
+
# customer
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# # good
|
|
21
|
+
# factory :payment do
|
|
22
|
+
# amount { 100.0 }
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
class AssociationInFactory < ::RuboCop::Cop::Base
|
|
26
|
+
MSG = 'Do not declare associations in factories — set them manually in the spec.'
|
|
27
|
+
|
|
28
|
+
CONTAINER_METHODS = %i[factory trait].freeze
|
|
29
|
+
# FactoryBot DSL methods that are bare calls but are NOT associations.
|
|
30
|
+
DSL_KEYWORDS = %i[skip_create initialize_with].freeze
|
|
31
|
+
|
|
32
|
+
def on_block(node)
|
|
33
|
+
return unless container_block?(node)
|
|
34
|
+
|
|
35
|
+
body = node.body
|
|
36
|
+
return unless body
|
|
37
|
+
|
|
38
|
+
statements = body.begin_type? ? body.children : [body]
|
|
39
|
+
|
|
40
|
+
statements.each do |statement|
|
|
41
|
+
add_offense(statement.loc.selector) if association_call?(statement)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
alias on_numblock on_block
|
|
46
|
+
alias on_itblock on_block
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def container_block?(node)
|
|
51
|
+
send = node.send_node
|
|
52
|
+
CONTAINER_METHODS.include?(send.method_name) && send.receiver.nil?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A bare attribute call (no receiver, no arguments, no block) inside a
|
|
56
|
+
# factory body is an implicit association.
|
|
57
|
+
def association_call?(node)
|
|
58
|
+
return false unless node.is_a?(::RuboCop::AST::Node) && node.send_type?
|
|
59
|
+
return false unless node.receiver.nil? && node.arguments.empty?
|
|
60
|
+
return false if DSL_KEYWORDS.include?(node.method_name)
|
|
61
|
+
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'factory_bot/association_in_factory'
|
|
4
|
+
require_relative 'layout/multiline_statement_spacing'
|
|
5
|
+
require_relative 'rails/bidirectional_association'
|
|
6
|
+
require_relative 'rails/mandatory_inverse_of'
|
|
7
|
+
require_relative 'rails/optional_belongs_to'
|
|
8
|
+
require_relative 'rails/ordered_macros'
|
|
9
|
+
require_relative 'rspec/conditional_in_let'
|
|
10
|
+
require_relative 'rspec/factory_bot_in_before'
|
|
11
|
+
require_relative 'rspec/inverse_of_matcher'
|
|
12
|
+
require_relative 'rspec/overwritten_let'
|
|
13
|
+
require_relative 'style/disallow_safe_navigation'
|
|
14
|
+
require_relative 'style/disallow_try'
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rubocop'
|
|
4
|
+
|
|
5
|
+
module RuboCop
|
|
6
|
+
module Cop
|
|
7
|
+
module Layout
|
|
8
|
+
# Requires a blank line between two consecutive statements when either of
|
|
9
|
+
# them spans multiple lines. Adjacent single-line statements need no blank
|
|
10
|
+
# line. The boundary with the enclosing `def`/`do`/`end` is left to other
|
|
11
|
+
# cops.
|
|
12
|
+
#
|
|
13
|
+
# Detection-only (no autocorrect) for now — the comment-handling and
|
|
14
|
+
# autocorrect behaviour need validation against the real repos first.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# # bad
|
|
18
|
+
# foo(
|
|
19
|
+
# bar
|
|
20
|
+
# )
|
|
21
|
+
# baz
|
|
22
|
+
#
|
|
23
|
+
# # good
|
|
24
|
+
# foo(
|
|
25
|
+
# bar
|
|
26
|
+
# )
|
|
27
|
+
#
|
|
28
|
+
# baz
|
|
29
|
+
#
|
|
30
|
+
class MultilineStatementSpacing < ::RuboCop::Cop::Base
|
|
31
|
+
MSG = 'Add a blank line around multi-line statements.'
|
|
32
|
+
|
|
33
|
+
def on_begin(node)
|
|
34
|
+
node.children.each_cons(2) do |first, second|
|
|
35
|
+
next unless first.is_a?(::RuboCop::AST::Node) && second.is_a?(::RuboCop::AST::Node)
|
|
36
|
+
next unless first.multiline? || second.multiline?
|
|
37
|
+
next if blank_line_between?(first, second)
|
|
38
|
+
|
|
39
|
+
add_offense(second)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def blank_line_between?(first, second)
|
|
46
|
+
return false if second.first_line - first.last_line < 2
|
|
47
|
+
|
|
48
|
+
((first.last_line + 1)...second.first_line).any? do |line|
|
|
49
|
+
processed_source.lines[line - 1].strip.empty?
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rubocop'
|
|
4
|
+
|
|
5
|
+
module RuboCop
|
|
6
|
+
module Cop
|
|
7
|
+
module Rails
|
|
8
|
+
# Ensures that all ActiveRecord associations are declared on both sides.
|
|
9
|
+
#
|
|
10
|
+
# Example:
|
|
11
|
+
# # bad
|
|
12
|
+
# class Post < ApplicationRecord
|
|
13
|
+
# belongs_to :user
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# class User < ApplicationRecord
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# # good
|
|
20
|
+
# class Post < ApplicationRecord
|
|
21
|
+
# belongs_to :user, inverse_of: :posts
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# class User < ApplicationRecord
|
|
25
|
+
# has_many :posts, inverse_of: :user
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
class BidirectionalAssociation < ::RuboCop::Cop::Base
|
|
29
|
+
MSG = 'Associations must be declared on both sides of the relationship.'
|
|
30
|
+
|
|
31
|
+
def self.default_configuration
|
|
32
|
+
super.merge(
|
|
33
|
+
'Include' => ['app/models/**/*.rb'],
|
|
34
|
+
'Exclude' => ['app/serializers/**/*.rb']
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def on_new_investigation
|
|
39
|
+
return unless in_model_file?(processed_source.file_path)
|
|
40
|
+
return unless processed_source.ast
|
|
41
|
+
|
|
42
|
+
associations.each do |model_name, _assoc_name, inverse_name, target_class|
|
|
43
|
+
next if model_name.nil? || target_class.nil?
|
|
44
|
+
|
|
45
|
+
opposite_model_path = File.join(Dir.pwd, "app/models/#{camel_to_snake(target_class)}.rb")
|
|
46
|
+
next unless File.exist?(opposite_model_path)
|
|
47
|
+
|
|
48
|
+
opposite_content = File.read(opposite_model_path)
|
|
49
|
+
|
|
50
|
+
expected_inverse =
|
|
51
|
+
if inverse_name
|
|
52
|
+
inverse_name.to_s
|
|
53
|
+
else
|
|
54
|
+
camel_to_snake(model_name).split('/').last.pluralize
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
unless /(belongs_to|has_many|has_one)\s+:#{expected_inverse}/.match?(opposite_content)
|
|
58
|
+
add_global_offense("#{target_class} is missing opposite association for #{model_name}")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
source_name =
|
|
63
|
+
if processed_source && processed_source.buffer
|
|
64
|
+
processed_source.file_path
|
|
65
|
+
else
|
|
66
|
+
'unknown'
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
warn "Rails/BidirectionalAssociation failed on #{source_name}: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def associations
|
|
75
|
+
processed_source.ast.each_descendant(:send).filter_map do |node|
|
|
76
|
+
next unless %i[belongs_to has_many has_one].include?(node.method_name)
|
|
77
|
+
|
|
78
|
+
# Skip polymorphic associations and `as:` options
|
|
79
|
+
next if polymorphic_or_as?(node)
|
|
80
|
+
|
|
81
|
+
model_name = class_name_from_ast
|
|
82
|
+
|
|
83
|
+
assoc_name =
|
|
84
|
+
begin
|
|
85
|
+
node.first_argument.value if node.first_argument
|
|
86
|
+
rescue StandardError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
inverse_name = extract_inverse_of(node)
|
|
91
|
+
target_class = extract_class_name(node, assoc_name)
|
|
92
|
+
|
|
93
|
+
[model_name, assoc_name, inverse_name, target_class]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def extract_inverse_of(node)
|
|
98
|
+
kwargs = node.last_argument
|
|
99
|
+
return nil unless kwargs && kwargs.hash_type?
|
|
100
|
+
|
|
101
|
+
pair =
|
|
102
|
+
begin
|
|
103
|
+
kwargs.pairs.find { |p| p.key.value == :inverse_of }
|
|
104
|
+
rescue StandardError
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
pair.value.value if pair && pair.value
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def extract_class_name(node, assoc_name)
|
|
112
|
+
kwargs = node.last_argument
|
|
113
|
+
return assoc_name.to_s.camelize unless kwargs && kwargs.hash_type?
|
|
114
|
+
|
|
115
|
+
class_name_pair =
|
|
116
|
+
begin
|
|
117
|
+
kwargs.pairs.find { |p| p.key.value == :class_name }
|
|
118
|
+
rescue StandardError
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
if class_name_pair
|
|
123
|
+
class_name_pair.value.value
|
|
124
|
+
else
|
|
125
|
+
assoc_name.to_s.camelize
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def polymorphic_or_as?(node)
|
|
130
|
+
kwargs = node.last_argument
|
|
131
|
+
return false unless kwargs && kwargs.hash_type?
|
|
132
|
+
|
|
133
|
+
kwargs.pairs.any? do |pair|
|
|
134
|
+
key = pair.key.value
|
|
135
|
+
%i[polymorphic as].include?(key)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def class_name_from_ast
|
|
140
|
+
class_node = processed_source.ast.each_descendant(:class).first
|
|
141
|
+
return nil unless class_node
|
|
142
|
+
|
|
143
|
+
const_node = class_node.children.first
|
|
144
|
+
const_node.const_name if const_node
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def in_model_file?(path)
|
|
148
|
+
return false if path.nil?
|
|
149
|
+
|
|
150
|
+
path.start_with?(File.join(Dir.pwd, 'app/models'))
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Replace ActiveSupport's underscore with plain Ruby equivalent
|
|
154
|
+
#
|
|
155
|
+
# Example:
|
|
156
|
+
# "UserAccount" → "user_account"
|
|
157
|
+
# "PlanStatementAudit::Row" → "plan_statement_audit/row"
|
|
158
|
+
def camel_to_snake(name)
|
|
159
|
+
return '' if name.nil?
|
|
160
|
+
|
|
161
|
+
name.gsub('::', '/')
|
|
162
|
+
.gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
|
|
163
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
164
|
+
.downcase
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|