Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new Lint/ObjectEqualityOverride cop #13287

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

viralpraxis
Copy link
Contributor

@viralpraxis viralpraxis commented Oct 2, 2024

This PR adds new Lint/UnsafeEqualityMethodDefinition cop that checks for eql? and == methods without argument class check.

# bad
def eql?(other)
  title == other.title
end

#good
def eql?(other)
  other.is_a?(self.class) && title == other.title
end

NOTE: currently there are exactly 5 offenses (some of them belong to public classes AFAICS) in the rubocop's codebase itself:

== lib/rubocop/cop/offense.rb ==
W:211:  7: Lint/UnsafeEqualityMethodDefinition: Check against argument class within custom equality methods.
== lib/rubocop/cop/registry.rb ==
W:232:  7: Lint/UnsafeEqualityMethodDefinition: Check against argument class within custom equality methods.
== lib/rubocop/cop/severity.rb ==
W: 50:  7: Lint/UnsafeEqualityMethodDefinition: Check against argument class within custom equality methods.
== lib/rubocop/cop/variable_force/branch.rb ==
W:121: 11: Lint/UnsafeEqualityMethodDefinition: Check against argument class within custom equality methods.
== lib/rubocop/cop/variable_force/scope.rb ==
W: 35:  9: Lint/UnsafeEqualityMethodDefinition: Check against argument class within custom equality methods.

I'm not sure what to do with them.

Fixes #12289.


Before submitting the PR make sure the following are checked:

  • The PR relates to only one subject with a clear title and description in grammatically correct, complete sentences.
  • Wrote good commit messages.
  • Commit message starts with [Fix #issue-number] (if the related issue exists).
  • Feature branch is up-to-date with master (if not - rebase it).
  • Squashed related commits together.
  • Added tests.
  • Ran bundle exec rake default. It executes all tests and runs RuboCop on its own code.
  • Added an entry (file) to the changelog folder named {change_type}_{change_description}.md if the new code introduces user-observable changes. See changelog entry format for details.

@viralpraxis viralpraxis force-pushed the implement-lint-unsafe-equality-method-definition-cop branch from b772624 to 2c5cf6a Compare October 2, 2024 02:43
@koic
Copy link
Member

koic commented Oct 2, 2024

Before diving into the implementation details, here is some feedback on the requirements:

  1. I don't have a strong objection to performing class checks for object equality inspected with eql?, but can you provide a URL as a basis for this? I'm curious whether always including class comparisons in the overrides, as shown in the good examples, is logically consistent with the language specification.
  2. == checks for object equality. I think performing class checks for == is excessive. For example, in cases like 1 == 1.0 #=> true, equality via == should be delegated to the context of the class. Therefore, please exclude this from the scope of the cop’s inspection.
  3. The name Lint/UnsafeEqualityMethodDefinition feels off. Lint/ObjectEqualityOverride seems to be a better fit.

@viralpraxis
Copy link
Contributor Author

Before diving into the implementation details, here is some feedback on the requirements:

  1. I don't have a strong objection to performing class checks for object identity inspected with eql?, but can you provide a URL as a basis for this? I'm curious whether always including class comparisons in the overrides, as shown in the good examples, is logically consistent with the language specification.

I don’t have anything ruby-specific at hand, but it is widely accepted that the :== binary relation (setting aside the minor differences between :== and :eql?) should be an equivalence relation, meaning it must be reflexive, transitive, and symmetric. Checking whether the argument passed to :== is an instance of the same class (via other.instance_of?) is a necessary, though not sufficient, condition to ensure these properties (it doesn't work in the case of kind_of? but people use it everywhere anyway).

Although the original motivation for this cop was to prevent runtime errors related to object comparisons (see the original issue), I firmly believe that the absence of class checks can lead to difficult-to-diagnose bugs. I recall struggling with a ruby-i18n-related bug after upgrading from Ruby 3.0.0 to 3.0.1, which was ultimately caused by a missing class check (see https://stackoverflow.com/questions/77830896/hash-related-behavior-mismatch-between-ruby-mri-3-0-0-and-3-0-1/77841475#77841475 for details)

@koic
Copy link
Member

koic commented Oct 2, 2024

Well, == and eql? are expected to be overridden for equality checks based on the context. In other words, whether they maintain identity like equal? by default or are used for equality depends on the nature of the class. Considering the convenience for users, I have concerns about enforcing such context-dependent usage uniformly through a Lint rule.

For example, I think linter that enforces overriding hash method whenever eql? method is overridden can be uniformly implemented. This is because it is specified in the language specification as follows.
https://ruby-doc.org/3.3.5/Object.html#method-i-eql-3F

@viralpraxis
Copy link
Contributor Author

viralpraxis commented Oct 2, 2024

Thanks, I understand your point now. Unfortunately, ATM I don't see anything related in the specification.

@viralpraxis viralpraxis force-pushed the implement-lint-unsafe-equality-method-definition-cop branch from 2c5cf6a to fe4a4c7 Compare October 2, 2024 05:42
@viralpraxis viralpraxis changed the title Add new Lint/UnsafeEqualityMethodDefinition cop Add new Lint/ObjectEqualityOverride cop Oct 2, 2024
@viralpraxis viralpraxis force-pushed the implement-lint-unsafe-equality-method-definition-cop branch from fe4a4c7 to c0a1e24 Compare October 2, 2024 05:46
@viralpraxis
Copy link
Contributor Author

Fixed the issues you mentioned (#== and naming) just in case you think it worth merging. Lint now passes.

@bbatsov
Copy link
Collaborator

bbatsov commented Oct 30, 2024

I understand the proposal, but something like this needs an iron-clad rationale before we can merge it. (mostly because I don't think such code is particularly common in the wild)

@rubocop/rubocop-core As usual, in such tricky situations your thoughts would be most appreciated!

config/default.yml Outdated Show resolved Hide resolved
Comment on lines +6 to +7
# Identifies `eql?` method definitions which do not
# check for argument class which might lead to runtime errors.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a number of places in the PR where wording like this pops up but I don't think it's clear. Something like this might be better:

Identifies eql? method definitions which do not check the argument's class is the same as, or a descendant of, the current class.

Since there are more "good" possibilities than are enumerated in @example, we should be more comprehensive.

Additionally "might lead to runtime errors" isn't a strong rationale IMO. What sort of runtime errors, and how does checking the class improve that?

@dvandersluis
Copy link
Member

dvandersluis commented Oct 30, 2024

This will likely register a false positive for methods along the lines of (&& could be ||, etc.):

def eql?(other)
  super && self.title == other.title
end

and similarly (where foo is another method in the class):

def eql?(other)
  foo.eql?(other) && self.title == other.title
end

or even any generic method call could do the equality check but not be visible:

def classes_eql?(other)
  other.class == self.class
end

def eql?(other)
  classes_eql?(other) && self.title == other.title
end

@dvandersluis
Copy link
Member

dvandersluis commented Oct 30, 2024

Another category of false positives:

def eql?(other)
  # where CLASS_NAME is the actual class constant
  other.is_a?(CLASS_NAME) && self.title == other.title
end

@viralpraxis viralpraxis force-pushed the implement-lint-unsafe-equality-method-definition-cop branch from c0a1e24 to e0fc52d Compare November 3, 2024 13:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

New cop to detect unsafe comparison method definitions
4 participants