Skip to content

SONARJAVA-6839: Implement S9362: hashCode() and equals() should use consistent fields - #6004

Merged
nathsou merged 6 commits into
masterfrom
new-rule/S9362
Aug 25, 2026
Merged

SONARJAVA-6839: Implement S9362: hashCode() and equals() should use consistent fields#6004
nathsou merged 6 commits into
masterfrom
new-rule/S9362

Conversation

@nathsou

@nathsou nathsou commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Implement S9362 (HashCodeMismatchedFieldsCheck): flag a locally declared hashCode() that reads an instance field which the class's equals(Object) override never reads, breaking the Object.hashCode() contract.
  • The check aggregates every mismatched field for a class into a single issue on the hashCode() method name, with one secondary location per mismatched field.
  • Supports single-level getter delegation, excludes memoized/cached hash fields (name contains "hash"), and bails out (no issue) on any unresolved symbol, unresolvable helper call, or an equals() that compares no state.
  • Includes HashCodeMismatchedFieldsCheckSample.java covering both RSPEC examples plus the memoized-hash, unresolvable-helper, inherited-field, static-field, and asymmetric-direction exceptions, and a .withoutSemantic() CheckVerifier test (the sample has no external dependency, so local symbol resolution still succeeds without semantic info; documented in the test).
  • Generated S9362.html / S9362.json / profiles/Sonar_way/S9362 from the RSPEC branch with rule-api generate.

Links

AI disclosure

  • LLM model used for implementation: Claude Sonnet 5 (authored directly in the driving session; the workflow's cursor-grok-4.6-high routing tier was not delegated to a separate subagent for this step)

@nathsou nathsou self-assigned this Aug 24, 2026
@hashicorp-vault-sonar-prod

hashicorp-vault-sonar-prod Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

SONARJAVA-6839

@datadog-sonarsource

This comment has been minimized.

- Fix a compile error in the test sample (package-private abstract
  equals/hashCode illegally narrowing Object's public methods).
- Fail the scan instead of silently ignoring same-class static helper
  calls and non-standard same-class equals(SpecificType) overloads,
  which could otherwise hide field reads and cause false positives.
- Replace non-functional [[secondary=N]] test assertions (not
  understood by the modern CheckVerifier) with real underline-caret
  secondary location assertions, verified to actually fail on
  corruption.
- Recognize memoized hash fields by assignment inside hashCode(), not
  just by name.
- Visit Tree.Kind.RECORD so hand-written record equals/hashCode are
  covered.
- Add non-compiling regression tests for the isUnknown() bail-out
  paths.
- Extract the equals()/hashCode() member classification and the extra-
  fields computation out of visitNode() into helper methods to bring
  its Cognitive Complexity back under the threshold.
- Remove a defensive but effectively unreachable early-return (a
  class's own symbol/type is always known while visiting its own
  declaration), which was dragging down new-code coverage.
- Add regression tests for the getClass() allow-list and for pruning
  field reads inside a nested/anonymous class declared inside
  equals(), closing the remaining coverage gaps flagged by the SonarQube
  quality gate (89.6% -> fully covered new lines).
Tighten cached-hash detection, report delegated field reads at their hashCode call sites, and recognize qualified memoization assignments to prevent false results and improve issue locations.

@romainbrenguier romainbrenguier left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It looks ok but I think we can clarify a bit the Collector class part.

Rename the collection operation and separate its result from the mutable AST visitor so callers consume an explicit read-and-assigned-fields value.

@romainbrenguier romainbrenguier left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Some non-blocking comments.

Comment on lines +205 to +207
private final Map<Symbol, Tree> readFields = new LinkedHashMap<>();
private final Set<Symbol> assignedFields = new HashSet<>();
private boolean failed;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These could have been replaced with a ReadAndAssignedFields field

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 8b7428f. FieldReadCollector now owns one initialized ReadAndAssignedFields field instead of separately declaring its read fields, assigned fields, and failure state. When a scan fails, the collector replaces that record with an asFailed() copy while retaining the collected maps.

Validation: mvn -pl java-checks -am test -Dtest=HashCodeMismatchedFieldsCheckTest -Dsurefire.failIfNoSpecifiedTests=false passes.

Represent collected field data as a record and let the AST visitor own a single result value instead of duplicating its components.
@gitar-bot

gitar-bot Bot commented Aug 25, 2026

Copy link
Copy Markdown
Code Review ✅ Approved 3 resolved / 3 findings

Implements rule S9362 to flag hashCode() methods that read instance fields not used by equals(), ensuring contract consistency. Addressed secondary location placement and memoization detection feedback.

✅ 3 resolved
Quality: Secondary location for getter-delegated fields points inside getter

📄 java-checks/src/main/java/org/sonar/java/checks/HashCodeMismatchedFieldsCheck.java:126-129 📄 java-checks/src/main/java/org/sonar/java/checks/HashCodeMismatchedFieldsCheck.java:186-188
When a mismatched field is discovered through getter delegation, the Tree stored in fields is the identifier read inside the getter body (collected during the HELPER scan), not the reference in hashCode(). As a result the "Not compared in equals()" secondary location is rendered inside the getter method (e.g. return lastName;) rather than at the hashCode() usage, which can be confusing for users reading the issue. Consider anchoring the secondary to the invocation/field reference within hashCode() itself for clearer reporting.

Edge Case: Memoization detection misses this.field = ... assignments

📄 java-checks/src/main/java/org/sonar/java/checks/HashCodeMismatchedFieldsCheck.java:184-193 📄 java-checks/src/main/java/org/sonar/java/checks/HashCodeMismatchedFieldsCheck.java:96
The new assignment-based memoization exclusion only records the field when the assignment target is a bare IdentifierTree. A memoized hash written as this.hashCache = h; (a very common form) has variable() returning a MemberSelectExpressionTree, so the field is never added to assignedFields. If such a memo field's name does not contain "hash", the check will report a false positive despite the feature's stated goal of recognizing memoization "regardless of its name". Handle the this.-qualified case as well (e.g. via ExpressionUtils.isSelectOnThisOrSuper / extractIdentifierSymbol).

Edge Case: "hash" substring exclusion is broader than cached-hash intent

📄 java-checks/src/main/java/org/sonar/java/checks/HashCodeMismatchedFieldsCheck.java:92
The exclusion field.name().toLowerCase(Locale.ROOT).contains("hash") (HashCodeMismatchedFieldsCheck.java:92) exempts ANY field whose name merely contains the substring "hash", not just a memoized/cached hash. A genuine identity field such as passwordHash, contentHash, or hashKey that hashCode() reads but equals() omits would be silently unflagged — a real false negative for the exact contract violation this rule targets. Consider tightening the heuristic (e.g. match a whole-word/known set like hash, cachedHash, hashCode, or require the field to be non-final and assigned inside hashCode()) to reduce over-suppression.

Implementation Status ◻️ 0 of 8 objectives covered
◻️ SONARJAVA-6839 - 0 of 8 objectives covered

This PR does not contain any code changes related to rule S9362.

Other objectives on this issue, possibly covered elsewhere:

  • ◻️ Detect locally declared equals(Object) and hashCode() overrides
  • ◻️ Support simple equality helpers and getters when their field reads are resolvable
  • ◻️ Bail out on unknown symbols, complex/delegating helpers, nested scopes, and unclassifiable derived or memoized hash state
  • ◻️ Add rule metadata and description under the S9362 key
  • ◻️ Add focused CheckVerifier tests covering positive and negative cases, getters, inherited/static fields, memoized hash fields, and unresolved or complex methods
  • ◻️ Implement SonarJava rule S9362 for inconsistent fields between equals(Object) and hashCode()
  • ◻️ Conservatively compare same-owner, non-static fields read by both methods
  • ◻️ Report fields used by hashCode() but not by equals()
Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Auto-apply Compact
gitar auto-apply:on         
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

@sonarqube-next

Copy link
Copy Markdown
Contributor

@nathsou
nathsou merged commit 3331c14 into master Aug 25, 2026
18 checks passed
@nathsou
nathsou deleted the new-rule/S9362 branch August 25, 2026 09:14
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.

2 participants