Skip to content

Fix NullPointerExceptions and contract violations in RecordValue - #1925

Open
slisson wants to merge 3 commits into
maintenance/mps20251from
fix/record-value-equals-compare-to
Open

Fix NullPointerExceptions and contract violations in RecordValue#1925
slisson wants to merge 3 commits into
maintenance/mps20251from
fix/record-value-equals-compare-to

Conversation

@slisson

@slisson slisson commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

RecordValue (org.iets3.core.expr.toplevel.plugin) threw NullPointerException from both equals and compareTo whenever a record member had no value, and its equals/hashCode/compareTo trio was mutually inconsistent. This fixes all three, with test cases in test.ts.expr.os.records.

What changed

1. equals threw an NPE on null member values (7ec5d2e4)

!rv.memberData[e.key].equals(e.value) dereferenced the other record's member value, which is null for a member that is present in the map but has no value. Replaced with the null-safe :ne: operator, so it generates as Objects.equals(...).

2. equals and hashCode disagreed (030cab47)

hashCode was Objects.hash(memberData, recordDeclaration) while equals compared only memberData. Two record values with equal members but different record declarations were therefore equals with differing hash codes — broken for HashMap/HashSet. equals now compares the record declaration as well.

The declaration is compared by node identity, which is what IRecordDeclaration.equals (thisNode :eq: declaration) already does for KernelF's own ==. Since records are nominal, both operands of a well-typed comparison carry the same declaration, so this changes nothing for well-typed programs; it fixes the contract for values that reach a hash-based collection across record types.

3. compareTo threw an NPE, and was not a valid Comparable (934b968f)

  • No record declaration. The interpreter builds new RecordValue(null) for inline/projected records. SLinkOperations.getChildren(null, …) returns empty, so control fell into the effectiveMembers() branch, which returns null for a null node, and the for-loop NPE'd. Such a record now compares by member name, over the sorted union of both sides' member names.
  • Null member values. These were skipped entirely, which made the ordering intransitive: {y:1} vs {y:null} and {y:null} vs {y:2} both compared equal while {y:1} vs {y:2} did not — a TreeSet of the three held two elements. A new private compareMemberValues sorts a member without a value before one that has a value; two present values still go through OH.compare unchanged.

The comparisonOrder branch is unchanged in intent — it still compares only the declared sort key, which is the feature, so compareTo == 0 remains deliberately weaker than equals.

Tests

Three test methods added to RecordValueTest in test.ts.expr.os.records. Against the unfixed code the two compareTo tests reproduce the defects — one with the NullPointerException at RecordValue.compareTo, one with an AssertionError because withNull.compareTo(one) was 0:

  • equalsIsNullSafeForNullMemberValues — regression test for (1)
  • compareToWorksWithoutRecordDeclaration — inline records: ordering, reflexivity, and differing key sets
  • compareToOrdersNullMemberValuesConsistentlywithNull < one < two, i.e. transitivity

All 4 tests in RecordValueTest pass, and the existing records test case (10 tests) is unaffected.

For the reviewer

  • The KernelF == operator uses neither method: EqualsHelper.equals has its own RecordValue branch. equals/hashCode matter for Java-side collections, compareTo for ordering (OH.compare, sorting, TreeSet/TreeMap). So the risk surface here is Java-side, not language semantics.
  • Interpreter suites in test.in.expr.os that touch record ordering were run: enums, enum_sort, EnumsWithValuesOfSortableTypes, references, records, path, grouping pass. projection fails, but it fails identically with compareTo reverted (same four items, Type system returned null type for child and an invalid null value detected in SumOp) — pre-existing and unrelated.
  • Separate finding, not addressed here: EqualsHelper.equals returns false for two structurally identical inline records, because it invokes the IRecordDeclaration.equals behavior method on a null node and gets the boolean default back. So == between two projected records is always false. Worth its own issue.

🤖 Generated with Claude Code

slisson and others added 3 commits August 24, 2026 11:28
Comparing member values used Object.equals, which throws when a member
is present but mapped to null. Use the null-safe :ne: operator instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
hashCode hashed memberData and recordDeclaration, while equals compared
only memberData, so two record values with equal members but different
record declarations were equal with differing hash codes. Compare the
record declaration in equals as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
compareTo threw a NullPointerException for a record value without a
record declaration -- what the interpreter builds for inline records --
because effectiveMembers() returns null for a null node. Such a record
now compares by member name instead.

Member values that were null were skipped entirely, which made the
ordering intransitive: {y:1} vs {y:null} and {y:null} vs {y:2} both
compared equal while {y:1} vs {y:2} did not, so a TreeSet of the three
held two elements. A member without a value now sorts before one that
has a value.

Adds test cases for both, plus a regression test for the equals()
NullPointerException fixed in 7ec5d2e.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant