The project is in a healthy, maintained state
RuboCop extension enforcing 4Shark's Ruby, Rails, and RSpec conventions.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Project Readme

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:

  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.
  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.

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-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.

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.

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/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.
  • 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).

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/InverseOf superseded by Rails/MandatoryInverseOf (covers its cases and more)
Style/SafeNavigation contradicts Style/DisallowSafeNavigation — it pushes &., we forbid it

Style

Cop Intent
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/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.

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/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. Ternaries are allowed.
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.

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.

Why belongs_to is optional: true by default

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.

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.

So the convention is:

  • belongs_to is always declared optional: true (enforced by Rails/OptionalBelongsTo), turning off Rails' automatic existence validation.
  • 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).

A deliberate performance trade-off backed by the external-identifier mapping — 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

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.

License

Released under the MIT License.