fix: break a case-insensitive tie by bit count on a [Flags] enum - #70
Merged
Conversation
Of two unannotated members differing only by case, the one a token
matching neither spelling exactly falls back to is decided by the order
the serializer holds its members in — and that order is not the same on
both kinds of enum. An ordinary enum is Enum.GetNames order; a [Flags]
one puts the most bits first, so a composite wins over a member it
covers. This package applied the first rule to both, so on
{ Read = 1, read = 3 } the request body read "READ" as 3 while every
other channel read it as 1.
The bit count is taken over the sign-extended value, which is measured
rather than reasoned about: -128 on an sbyte enum sets one bit of the
byte and fifty-seven of the widened value, and the serializer counts
fifty-seven, in either declaration order. Counting the byte would have
been the tidier reading and the wrong one.
Twelve shapes were measured to establish the rule — bits over value,
bits over declaration order, and GetNames order for members tied on
bits. Four are now fixtures in the derived parity corpus, which found
the divergence on the comma-list path as well: a trailing comma moves a
token onto the path where the exact spelling no longer wins first, and
no hand-written example would have covered that.
The corpus guard is now derived rather than flat. It asserted more than
a hundred tokens, which is not evidence of coverage on an enum with
eight names and is unreachable for a two-member one — so it failed the
minimal fixture that exists precisely to be minimal. It now asks for
the cross product each shape's own vocabulary implies.
Also restores the EMN0004 changelog entries, in both languages. They
were never written: the script that added them used str.replace with no
assertion, against an anchor that branch did not carry, so it reported
success having changed nothing. The documentation suite could not see
it either — it holds the two languages structurally paired, and an
entry missing from both keeps them paired.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5U2BZXpHQr7YcfNHVx9dA
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Of two unannotated members differing only by case, the one a miscased token falls back to is decided by the order the serializer holds its members in — and that order is not the same on both kinds of enum. This package applied the ordinary-enum rule to
[Flags]too, so on{ Read = 1, read = 3 }the request body read"READ"as 3 while every other channel read it as 1.What the serializer actually does
Twelve shapes measured, not reasoned about:
"READ"[Flags] { Read = 1, read = 3 }[Flags] { Read = 3, read = 1 }[Flags] { read = 3, Read = 4 }[Flags] { Read = 1, REad = 3, read = 7 }[Flags] { read = 6, Read = 3 }Enum.GetNamesorder[Flags] { read = 2, Read = 1 }GetNamesorder, not declaration order[Flags] : sbyte { Read = -128, read = 3 }{ Read = 1, read = 3 }GetNamesorderThe
sbyterow is the one worth naming:-128sets one bit of the byte and fifty-seven of the widenedulong, and the serializer counts fifty-seven — in both declaration orders. Counting the byte would have been the tidier reading and the wrong one, soToUInt64, which already sign-extends, is exactly the right input.Type of change
Changes
EnumContract.FallbackOrder:Enum.GetNamesfor an ordinary enum, andOrderByDescendingonBitOperations.PopCount(ToUInt64(value))for a[Flags]one.OrderByDescendingis a stable sort, so members tied on bit count keepGetNamesorder between them — which is what the three tie-break shapes above measure.ClrNamesIgnoringCasetakesisFlagsand walks that order.ReadParityTests: both declaration directions, the signed shape, and one tied on bits. Neutralising the sort key fails them with the divergence spelled out — and it fails on'Read,'too, because a trailing comma moves a token onto the list path where the exact spelling no longer wins first. No hand-written example would have covered that.ReadParityTests' own remark named this shape as "a separate defect, reported and open". It is now what the file covers.Also in this PR: two changelog entries that were never written
EMN0004's changelog entries (#69) are absent frommainin both languages. They were not lost in the rebase — the script that wrote them usedstr.replacewith no assertion, against an anchor that branch did not carry, so it reported success having changed nothing, and I ticked the box on the strength of it.The documentation suite could not catch it either: it holds the two languages structurally paired, and an entry missing from both keeps them paired. This restores both, alongside the new entries for this fix.
Testing
dotnet build -c Release— clean, warnings are errors heredotnet test -c Release— 936 passed, 0 failedtests/PackageSmokeTest/run.sh— not run: nothing in packaging, the analyzers or a public entry point changed.EnumContractis internal and the public surface is untouched.Also run:
tools/style/lint-layout.sh(nothing to report), and the four fixtures against a neutralised sort key, to confirm they fail without the fix.Public API
Documentation
docs/updatedCHANGELOG.mdanddocs/for-users/CHANGELOG.fr.mdboth updatedThe user-facing pages need nothing:
contract-rulesdocuments which member a miscased name reaches without promising an order, and this makes that reachable member the serializer's one.🤖 Generated with Claude Code
https://claude.ai/code/session_01M5U2BZXpHQr7YcfNHVx9dA
Generated by Claude Code