rubocop-fourshark
A RuboCop extension that encodes 4Shark's Ruby, Rails, and RSpec conventions as enforceable cops.
Why this gem exists
4Shark has its own definition of good Ruby/Rails/RSpec code. Two needs the stock RuboCop ecosystem does not cover on its own:
- Conventions stock RuboCop has no cop for. Rules like "every association declares
inverse_of", "belongs_toisoptional: truewith manual presence validation", or "no associations inside factories" are 4Shark decisions with no equivalent inrubocop-rails/rubocop-rspec. This gem ships them as real cops. - Stock conventions 4Shark deliberately rejects. RuboCop's defaults nudge toward patterns 4Shark does not want — safe navigation (
&.) andtrybeing the clearest examples. Rather than each repo re-litigating the same.rubocop.ymloverrides, the position is made enforceable in one place: a custom cop that flags the rejected construct.
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.
Installation
The gem is published to RubyGems. Add it to the application's Gemfile:
gem 'rubocop-fourshark', require: false
or:
bundle add rubocop-fourshark --require=false
Then activate it as a plugin in .rubocop.yml:
plugins:
- rubocop-fourshark
Activating the plugin auto-loads the gem's config/default.yml, which enables every cop below.
Note — plugins are not transitively activated.
rubocop-foursharkdepends on the upstream plugins (rubocop-rails,rubocop-rspec,rubocop-rspec_rails,rubocop-performance,rubocop-factory_bot), butlint_rollerdoes not auto-activate a plugin's dependencies. Each repo still lists the upstream plugins it uses in its ownplugins:block.
Note — list
rubocop-foursharklast. When this gem configures a cop that an upstream plugin also ships (e.g.RSpec/Dialect, owned byrubocop-rspec), RuboCop merges the plugins' default config inplugins:order and the last one wins. Listrubocop-foursharkafter the upstream plugins so its configuration is the one that takes effect:plugins: - rubocop-rspec - rubocop-rspec_rails - rubocop-rails - rubocop-performance - rubocop-factory_bot - rubocop-fourshark
Cops
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 carries runnable @example blocks showing the bad/good shapes.
Cop naming
Cop names follow RuboCop's empirical convention — a noun phrase describing the construct or smell, no Disallow*/No* prefixes — with two deliberate exceptions:
Style/DisallowDelegate/Style/DisallowSafeNavigation/Style/DisallowTernary/Style/DisallowTrykeep theDisallow*prefix. RuboCop has no idiom for "forbid a construct it otherwise permits" (it usesEnforcedStyleon one cop), and the natural noun name is already taken by a stock cop with the opposite intent —Style/SafeNavigationconverts to&.,Rails/Delegateconverts todelegate.Disallow*is unambiguous and collision-free.Rails/MandatoryInverseOfis named to distinguish it from stockRails/InverseOf, which only flags associations where Active Record cannot auto-detect the inverse. Ours mandatesinverse_ofon every association — a strict superset — so the gem disables the stock cop (below).
Stock cops disabled
Where a 4Shark cop supersedes or contradicts a stock cop, config/default.yml turns the stock one off:
| Disabled stock cop | Why |
|---|---|
Rails/Delegate |
contradicts Style/DisallowDelegate — it turns an explicit method into delegate, we forbid the macro |
Rails/InverseOf |
superseded by Rails/MandatoryInverseOf (covers its cases and more) |
Style/MultilineTernaryOperator |
superseded by Style/DisallowTernary — it shapes a construct we forbid outright |
Style/NestedTernaryOperator |
superseded by Style/DisallowTernary — same |
Style/SafeNavigation |
contradicts Style/DisallowSafeNavigation — it pushes &., we forbid it |
Style/TernaryParentheses |
superseded by Style/DisallowTernary — same |
Style
| Cop | Intent |
|---|---|
Style/DisallowDelegate |
Flags automatic delegation (delegate, delegate_missing_to, def_delegator(s), DelegateClass) and a hand-written method whose whole body forwards its own name to a collaborator. A delegate line is a macro call, not a definition, so grep 'def foo' and jump-to-definition find nothing — the community name for the smell is Fowler's Middle Man. Not flagged: a method answering through its own self.class, each in a class that includes or prepends Enumerable, and a body composing the object's own state into a call on a direct collaborator. Let the caller ask the object that knows. |
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. |
Style/DisallowTernary |
Flags the ternary conditional (cond ? a : b). ? and : are punctuation that already mean other things in Ruby (valid?, :symbol), and the ternary hides a branch inside an expression where skimming misses it. Use an explicit if/else. |
Style/DisallowTry |
Flags try / try!. Same rationale — it hides the nil/missing-method case instead of handling it explicitly. |
Layout
| Cop | Intent |
|---|---|
Layout/MultilineStatementSpacing |
Requires a blank line between two consecutive statements when either spans multiple lines, so multi-line statements read as distinct units. |
Naming
| Cop | Intent |
|---|---|
Naming/RescuedExceptionsVariableName |
A rescued exception is bound to exception (_exception when unread), not the cop's default e — 4Shark bans single-letter variables, and this is the one slot where a stock cop demanded one. Stock cop, configured via PreferredName; autocorrects. |
Rails (models)
| Cop | Intent |
|---|---|
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. |
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. |
Rails/OptionalBelongsTo |
belongs_to must be optional: true (see the rationale below). Scoped to app/models. |
Rails/OrderedMacros |
Same-kind class macros (associations, validations, scopes) must be sorted alphabetically within their group. Scoped to app/models. |
RSpec (specs)
| Cop | Intent |
|---|---|
RSpec/Dialect |
Use let, never subject or let! — every subject/subject!/let! is flagged in favor of a lazy let (force creation in an explicit before, not with let!). The implicit subject behind is_expected is a different method and is left untouched. Stock cop, configured via PreferredMethods. |
RSpec/InverseOfMatcher |
Root models must assert .inverse_of in association specs; subclasses must not (it belongs to the parent). Scoped to spec/models. |
RSpec/OverwrittenLet |
A let/let! must not override one defined in an outer example group — shadowing makes it ambiguous which value applies. Scenario-specific lets, and the same name across sibling contexts, are fine. |
RSpec/ConditionalInLet |
A let must not contain conditional logic (if/case) — branch with separate contexts instead. A ternary inside a let is flagged by Style/DisallowTernary, not by this cop. |
RSpec/FactoryBotInBefore |
Object creation belongs in let, not before. before is for actions, not for building the subjects under test. |
FactoryBot (factories)
| Cop | Intent |
|---|---|
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. |
This README states the intent each cop enforces. One convention deviates from a safe Rails default and is worth spelling out — see below.
Why belongs_to is optional: true by default
Rails' default belongs_to (optional: false) validates that the associated record exists, which costs an existence SELECT per record. That validation is redundant in an application whose own request handling already establishes, before the association is assigned, that the record exists and is reachable by the caller — and under high throughput the redundant query is a measurable per-request cost.
So the convention is:
belongs_tois always declaredoptional: true(enforced byRails/OptionalBelongsTo), turning off Rails' automatic existence validation.- Presence is validated manually with
validates :x_id, presence: truewhere the business rule requires it — case by case, not globally (which is why no cop enforces the presence side).
A deliberate performance trade-off — not an omission.
Development
After checking out the repo, install dependencies and run the suite:
bundle install
bundle exec rspec # cop specs
bundle exec rubocop # self-lint (includes rubocop-internal_affairs)
Each new cop ships with a spec (expect_offense / expect_no_offenses) and a config/default.yml entry.
Releasing
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.
License
Released under the MIT License.