Skip to content

fix: break a case-insensitive tie by bit count on a [Flags] enum - #70

Merged
Reefact merged 1 commit into
mainfrom
claude/flags-ignore-case-tiebreak
Aug 11, 2026
Merged

fix: break a case-insensitive tie by bit count on a [Flags] enum#70
Reefact merged 1 commit into
mainfrom
claude/flags-ignore-case-tiebreak

Conversation

@Reefact

@Reefact Reefact commented Aug 11, 2026

Copy link
Copy Markdown
Owner

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:

shape "READ" what it establishes
[Flags] { Read = 1, read = 3 } 3 more bits wins
[Flags] { Read = 3, read = 1 } 3 …in either declaration order
[Flags] { read = 3, Read = 4 } 3 bits beat the larger value
[Flags] { Read = 1, REad = 3, read = 7 } 7 most bits, not merely "a composite"
[Flags] { read = 6, Read = 3 } 3 tied on bits → Enum.GetNames order
[Flags] { read = 2, Read = 1 } 1 GetNames order, not declaration order
[Flags] : sbyte { Read = -128, read = 3 } -128 the count is over the sign-extended value
plain { Read = 1, read = 3 } 1 an ordinary enum stays GetNames order

The sbyte row is the one worth naming: -128 sets one bit of the byte and fifty-seven of the widened ulong, and the serializer counts fifty-seven — in both declaration orders. Counting the byte would have been the tidier reading and the wrong one, so ToUInt64, which already sign-extends, is exactly the right input.

Type of change

  • Bug fix
  • New feature
  • Breaking change to the public API
  • Refactoring
  • Analyzer / diagnostic change
  • Tests
  • Documentation
  • Build / CI / tooling

Changes

  • New EnumContract.FallbackOrder: Enum.GetNames for an ordinary enum, and OrderByDescending on BitOperations.PopCount(ToUInt64(value)) for a [Flags] one. OrderByDescending is a stable sort, so members tied on bit count keep GetNames order between them — which is what the three tie-break shapes above measure. ClrNamesIgnoringCase takes isFlags and walks that order.
  • Four fixtures join 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.
  • The corpus guard is now derived instead of flat. It demanded more than 100 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, which is a stronger claim on the large shapes and a possible one on the small.
  • 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 from main in both languages. They were not lost in the rebase — the script that wrote them used str.replace with 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 here
  • dotnet test -c Release — 936 passed, 0 failed
  • tests/PackageSmokeTest/run.sh — not run: nothing in packaging, the analyzers or a public entry point changed. EnumContract is 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

  • No change to the public surface
  • The surface changed and the baseline was updated in the same commit

Documentation

  • README / docs/ updated
  • The French counterpart was updated to match
  • CHANGELOG.md and docs/for-users/CHANGELOG.fr.md both updated
  • No documentation change required

The user-facing pages need nothing: contract-rules documents 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

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
@Reefact
Reefact merged commit ecc0c35 into main Aug 11, 2026
10 checks passed
@Reefact
Reefact deleted the claude/flags-ignore-case-tiebreak branch August 11, 2026 12:10
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