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.

Merge request reports

Loading