Skip to content

Compare semantic strings ordinally, matching their ordinal Equals - #277

Merged
matt-edmondson merged 2 commits into
mainfrom
claude/ordinal-comparison-operators
Sep 24, 2026
Merged

matt-edmondson merged 2 commits into
mainfrom
claude/ordinal-comparison-operators

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #276.

The bug

SemanticString<T>.CompareTo — both the object and the ISemanticString overload — delegated to string.CompareTo(string), a culture-sensitive linguistic comparison against the current thread culture. Equals/GetHashCode are compiler-generated from WeakString and are ordinal. The <, <=, >, >= operators route through CompareTo(object), so they inherited it too.

Two consequences, both reachable through public API on every semantic string type:

  • Sort order depends on the running culture. Linguistic collation sorts by letter before case; ordinal sorts by code unit, so every uppercase letter precedes every lowercase one. Measured on this branch's test host with globalization enabled (en-US, ICU): "Zebra" vs "apple" compares +1 linguistically and -1 ordinally — a sign flip, so the same two values sort differently on different hosts.
  • CompareTo == 0 without Equals. On Windows NLS a hyphen carries minimal collation weight, so "Co-op"/"Coop" and "e-mail"/"email" compare equal while remaining unequal and differently hashed. SortedSet<T> and SortedDictionary<TKey,_> treat any two keys comparing 0 as duplicates, so one of two distinct, independently valid values — two Ibans, two Uuids — is silently discarded.

The fix

string.CompareOrdinal(WeakString, …) in both CompareTo overloads, per the issue's suggested fix, with a <remarks> recording why the ordering is ordinal.

Semantics.Paths/SemanticPath.cs carried the same defect in its explicit IComparable<IPath>.CompareTo, which is the comparison Comparer<IPath>.Default actually uses when sorting a path collection. It is fixed in the same commit: leaving it would have meant pathA.CompareTo(pathB) ordering differently depending on which interface the caller went through, which is worse than either behaviour on its own.

No public API change; only the ordering semantics of existing members.

Tests, and an honest note about what CI can prove

ktsu.Sdk sets InvariantGlobalization=true (Sdk.props:639), so the test host runs with globalization off, where string.CompareTo is already ordinal. The suite therefore cannot observe this bug at all — it is green before and after this change in the normal configuration. That does not make the bug theoretical: ktsu.Semantics ships as a library, and a consuming application sets its own globalization mode.

So the regression was proved by re-running the same suite against the built test assembly with System.Globalization.Invariant flipped to false and LANG=en_US.UTF-8:

configuration result
invariant (CI default), before fix 1341 total, 0 failed
invariant (CI default), after fix 1341 total, 0 failed
globalization on, before fix 3 failed — CompareTo_OrdersOrdinally, ComparisonOperators_OrderOrdinally, DefaultComparer_ForPathInterface_OrdersOrdinally
globalization on, after fix 1341 total, 0 failed

Four tests added:

  • CompareTo_OrdersOrdinally — both overloads, against string.CompareOrdinal as the oracle.
  • ComparisonOperators_OrderOrdinally — the four operators.
  • CompareTo_IsZeroExactlyWhenEqual — the assertion the issue asks for, plus the SortedSet retention it protects. Regressive on Windows NLS only, since ICU does not collapse the punctuation pairs.
  • DefaultComparer_ForPathInterface_OrdersOrdinally (PathSortingTests) — the IComparable<IPath> route.

Release build of the full solution: 0 warnings, 0 errors.

One thing for you to decide

Since the suite passes with globalization enabled (row 4 above), Semantics.Test could set <InvariantGlobalization>false</InvariantGlobalization> and these tests would then genuinely guard the fix in CI rather than being green by construction. That is a change to a setting the org SDK sets deliberately, and it would affect all 1341 tests, so I have not made it here — but the measurement says it is safe today.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XD56Kg65nsraeL7vTL9MgU


Generated by Claude Code

CompareTo and the <, <=, >, >= operators built on it delegated to
string.CompareTo, a culture-sensitive linguistic comparison, while Equals and
GetHashCode are ordinal. Sorting therefore varied with the running culture,
and on Windows NLS two values differing only in punctuation compared equal
while remaining unequal, so a SortedSet could discard one of them.

SemanticPath's explicit IComparable<IPath>.CompareTo had the same defect and
is fixed with it, so ordering agrees whichever interface a caller sorts
through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XD56Kg65nsraeL7vTL9MgU
Comment thread Semantics.Test/SemanticStringTests.cs Fixed
left.CompareTo(left) holds for any implementation, so it pinned nothing.
Comparing against a second instance created from the same value pins the
half of the contract this test is about: two Equals-equal values must also
compare zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XD56Kg65nsraeL7vTL9MgU
@sonarqubecloud

Copy link
Copy Markdown

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.

SemanticString comparison operators use culture-sensitive ordering, inconsistent with its ordinal Equals

2 participants