Draft: Add RSpec/LetItBeImmutable RuboCop cop

What does this MR do and why?

Adds the RSpec/LetItBeImmutable RuboCop cop, which enforces that every let_it_be declaration carries an explicit mutation modifier.

The motivation is that the testing guide already recommends treating let_it_be objects as immutable, but with ~73k usages across the repo, fewer than 1% currently use freeze: true. Silent mutation of shared state is a common source of flaky and order-dependent tests. Making the intent explicit at the declaration site - rather than relying on convention - surfaces these issues earlier and encourages contributors to reach for the right tool.

Accepted modifiers (any one is sufficient):

Modifier Behavior
freeze: true Freezes the Ruby object; mutations raise FrozenError. Default-safe choice.
reload: true Calls object.reload after each example. Equivalent to let_it_be_with_reload.
refind: true Calls ActiveRecord::Base.find after each example. Equivalent to let_it_be_with_refind.

The named helpers let_it_be_with_reload and let_it_be_with_refind are also accepted without an offense, since they are already self-documenting.

Rollout strategy:

The cop is enabled globally with error severity. A TODO file (.rubocop_todo/rspec/let_it_be_immutable.yml) covers the ~11.5k existing offending files, matching how other large cops (RSpec/FeatureCategory, RSpec/NamedSubject, etc.) were introduced. New files must comply immediately; existing files are cleaned up gradually in focused follow-up MRs, starting with spec/models/.

This approach was chosen over a directory-scoped Include allowlist because it also protects new code added to not-yet-cleaned-up directories.

Open questions for maintainers

This MR is intentionally opened as a discussion starter. Happy to iterate on any of the following before merging:

  1. Rollout strategy - is a global cop + TODO file acceptable, or would you prefer an Include-based allowlist that grows directory by directory? The TODO file is ~11.5k lines, about 3x the next largest (RSpec/FeatureCategory), so I'm totally open to alternatives.
  2. freeze: false behavior - currently flagged (no offense is raised only for true values).
  3. Non-AR objects - the cop applies to all let_it_be calls, not just ActiveRecord-backed ones.

References

Screenshots or screen recordings

Not applicable - tooling change only.

How to set up and validate locally

  1. Run the cop spec:

    bundle exec rspec spec/rubocop/cop/rspec/let_it_be_immutable_spec.rb

    Expected: 10 examples, 0 failures.

  2. Check a file that should have offenses (with TODO suppressed):

    REVEAL_RUBOCOP_TODO=1 bundle exec rubocop --only RSpec/LetItBeImmutable spec/models/user_spec.rb
  3. Check a file that already uses freeze: true (should be clean):

    bundle exec rubocop --only RSpec/LetItBeImmutable spec/support_specs/helpers/ci/job_helpers_spec.rb

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Pedro Pombeiro

Merge request reports

Loading