Skip to content

fix(subscriptions): emit converter switch arms most-specific-first - #592

Merged
alexeyzimarev merged 2 commits into
devfrom
fix/589-converter-generator-arm-order
Sep 20, 2026
Merged

alexeyzimarev merged 2 commits into
devfrom
fix/589-converter-generator-arm-order

Conversation

@alexeyzimarev

@alexeyzimarev alexeyzimarev commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #589

Problem

Since 0.16.4 (#542), ConsumeContextConverterGenerator discovers all [EventType] types and emits one type-pattern switch arm per type, in discovery order. When one message type is a supertype of another, its arm can come first and subsume the more specific arm, so the consuming project fails to build with CS8510: The pattern is unreachable. Namespace traversal goes outer to inner, so a base record in Foo always lands before a derived record in Foo.Bar.

It is not limited to [EventType] discovery: any scanned usage that brings in both a base and a derived type (for example On<Base> next to On<Derived>) takes the same path.

It is also a correctness problem. The reflection fallback in MessageConsumeContextConverter converts using the message's exact runtime type, so the generated converter only agrees with it when the most specific arm wins.

Fix

Each candidate now carries a specificity rank: the number of its base classes plus all of its interfaces. A subtype always has strictly more supertypes than any of its supertypes, so emitting arms by rank descending keeps the arm of a type ahead of the arms of its supertypes.

Types of equal rank keep their discovery order (the sort is stable), which is still deterministic. This matters for types related only through generic variance, such as IEnvelope<Zebra> and IEnvelope<Animal> with IEnvelope<out T>: they have the same rank, so the rank cannot order them. Keeping discovery order means anything that compiled before this change still compiles. Variance-related message types declared broader-first still fail with CS8510, as they did before; ordering those correctly needs implicit-conversion checks across all candidate pairs on every generator run, which is not worth it until someone actually hits it.

The issue suggested sorting by base-class depth. That covers classes but not interfaces: every interface has depth zero, so IAnyEvent could still precede IBookingEvent : IAnyEvent and hit the same error. Counting interfaces as well covers both.

Tests

The generator had no tests. ConsumeContextConverterGeneratorTests in Eventuous.Tests.Subscriptions runs the generator through CSharpGeneratorDriver and asserts that the output compiles without errors and that arms are ordered most-specific-first, for:

  • the class inheritance case from the issue
  • an interface chain with an implementing record, named so that alphabetical order is the opposite of the required order
  • two covariantly related interfaces of equal rank, declared specific-first and named so that alphabetical order would put the broader one first

All three failed with CS8510 before the corresponding fix. The test project now references the generator project and the CodeAnalysis packages.

Verified locally on net10.0 only (137 tests in the project pass); net8.0 and net9.0 runtimes are not installed on my machine, so those rely on CI.

Notes

No public API change, so no docs or plugin updates are needed.

Not addressed here: GetTypeSyntax prefixes keyword types with global::, so IMessageConsumeContext<string> generates global::string (CS1041). It predates this change and deserves its own issue.

🤖 Generated with Claude Code

)

ConsumeContextConverterGenerator emitted one type-pattern switch arm per
discovered message type in discovery order. When one type was a supertype
of another (a base [EventType] record, or an interface), its arm could
precede and subsume the more specific one, failing the build with CS8510.
Where it did compile, a derived message was wrapped in the base type's
context, unlike the reflection fallback which uses the exact runtime type.

Rank each candidate by its number of supertypes (base classes plus all
interfaces), which is strictly greater for a subtype than for any of its
supertypes, and emit arms by that rank descending, then by name for
deterministic output. Base-class depth alone is not enough: all interfaces
have depth zero, so a base interface could still precede a derived one.

Add generator tests covering class and interface inheritance.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-20T12:36:21.926267Z bb0c4e7 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

Emit converter switch arms most-specific-first

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Rank converter candidates by inherited classes and interfaces before emitting switch arms.
• Order ties deterministically to prevent unreachable patterns and preserve exact runtime
 conversion.
• Add Roslyn generator tests for class and interface inheritance ordering.
Diagram

graph TD
    A["Event type scan"] --> C["Rank candidates"] --> D["Deduplicate types"] --> E["Sort specific first"] --> F["Generate switch arms"] --> G["Runtime conversion"]
    B["Usage scan"] --> C
Loading
High-Level Assessment

The hierarchy-count ranking is appropriate because every subtype has more inherited classes and interfaces than its supertypes, while ordinal tie-breaking keeps unrelated types deterministic. Base-class depth alone was correctly rejected because it cannot order interface inheritance; a full subtype topological sort would be more complex without improving the resulting order.

Files changed (3) +129 / -13

Bug fix (1) +34 / -13
ConsumeContextConverterGenerator.csOrder generated converter arms by type specificity +34/-13

Order generated converter arms by type specificity

• Carries a specificity rank with each discovered message type, calculated from all base classes and interfaces. Candidates are deduplicated and sorted by descending specificity, then ordinal name, before converter switch arms are emitted.

src/Core/gen/Eventuous.Subscriptions.Generators/ConsumeContextConverterGenerator.cs

Tests (1) +92 / -0
ConsumeContextConverterGeneratorTests.csAdd inheritance-order regression tests for the generator +92/-0

Add inheritance-order regression tests for the generator

• Introduces Roslyn generator-driver tests that compile generated output and verify derived classes, implementations, and derived interfaces precede their supertypes. The assertions cover both EventType discovery and explicit consume-context usages.

src/Core/test/Eventuous.Tests.Subscriptions/ConsumeContextConverterGeneratorTests.cs

Other (1) +3 / -0
Eventuous.Tests.Subscriptions.csprojReference generator and Roslyn dependencies for tests +3/-0

Reference generator and Roslyn dependencies for tests

• Adds a project reference to the subscriptions generator and private Microsoft.CodeAnalysis package references required by the compilation-based generator tests.

src/Core/test/Eventuous.Tests.Subscriptions/Eventuous.Tests.Subscriptions.csproj

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Covariant message usages break builds ✓ Resolved 🐞 Bug ≡ Correctness
Description
GetSpecificity gives covariantly related constructed interfaces the same rank because it counts
only their identical base and interface sets, so ThenBy can put the broader pattern first. When
both IMessageConsumeContext> and IMessageConsumeContext> are used with Zebra : Animal, ordinal
naming emits the Animal arm before the implicitly convertible Zebra arm and the generated
converter fails with CS8510.
Code

src/Core/gen/Eventuous.Subscriptions.Generators/ConsumeContextConverterGenerator.cs[R143-146]

+    static int GetSpecificity(ITypeSymbol symbol) {
+        var count = symbol.AllInterfaces.Length;
+
+        for (var baseType = symbol.BaseType; baseType != null; baseType = baseType.BaseType) count++;
Evidence
Explicit context usages pass arbitrary reference-type arguments into GetCandidate, while the
context interface permits interface arguments through its class constraint. The changed ranking
counts only AllInterfaces and base classes; constructed forms such as IEnumerable and
IEnumerable therefore tie even though covariance makes the latter implicitly convertible to the
former, after which the changed ordinal tie-break emits the broader name first directly into switch
type patterns.

src/Core/gen/Eventuous.Subscriptions.Generators/ConsumeContextConverterGenerator.cs[77-87]
src/Core/gen/Eventuous.Subscriptions.Generators/ConsumeContextConverterGenerator.cs[138-148]
src/Core/gen/Eventuous.Subscriptions.Generators/ConsumeContextConverterGenerator.cs[226-236]
src/Core/gen/Eventuous.Subscriptions.Generators/ConsumeContextConverterGenerator.cs[253-265]
src/Core/src/Eventuous.Subscriptions/Context/IMessageConsumeContext.cs[80-89]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The specificity count does not represent subsumption caused by generic variance. Covariantly related constructed interfaces can receive equal ranks and then be placed broader-first by the name tie-breaker, producing an unreachable switch arm.
## Fix Focus Areas
- src/Core/gen/Eventuous.Subscriptions.Generators/ConsumeContextConverterGenerator.cs[138-149]
- src/Core/gen/Eventuous.Subscriptions.Generators/ConsumeContextConverterGenerator.cs[226-236]
- src/Core/test/Eventuous.Tests.Subscriptions/ConsumeContextConverterGeneratorTests.cs[31-59]
## Recommended Fix
Retain each candidate's type symbol and use Roslyn implicit-conversion classification to topologically order candidates: when A has an implicit reference conversion to B but not vice versa, emit A before B. Use ordinal type names only for unrelated candidates, and add a regression test using covariant interfaces such as `IEnumerable<Zebra>` and `IEnumerable<Animal>` with `Zebra : Animal`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can turn on the rule miner and Qodo learns your standards from review history

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bb0c4e7355

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +143 to +146
static int GetSpecificity(ITypeSymbol symbol) {
var count = symbol.AllInterfaces.Length;

for (var baseType = symbol.BaseType; baseType != null; baseType = baseType.BaseType) count++;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Order variant constructed types by conversion, not rank

When candidates are related through generic variance—for example, IEvent<out T> used as both IMessageConsumeContext<IEvent<Base>> and IMessageConsumeContext<IEvent<Derived>>—the two constructed interfaces have identical base/interface counts. The name tiebreak can therefore emit IEvent<Base> first, which subsumes the IEvent<Derived> pattern and still produces CS8510. Determine ordering from actual implicit reference conversions/subtyping rather than relying solely on the supertype count.

Useful? React with 👍 / 👎.

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.

Verified with a generator test: IEnvelope<Zebra> and IEnvelope<Animal> (with IEnvelope<out T>) get the same supertype count, and the name tie-break emitted the broader arm first, which fails with CS8510. The name tie-break could also break a project that compiled before this PR, when arms simply followed declaration order.

Fixed in a965b3e: the name tie-break is gone and the sort is stable, so equally specific types keep their discovery order. That is still deterministic, and anything that compiled before still compiles. Added Should_keep_discovery_order_for_types_of_equal_specificity as a regression test, and reworded the comment that claimed the rank alone guarantees no arm is subsumed.

Not doing the conversion-based topological ordering here. Variance-related message types declared broader-first failed before this PR too, so it is not a regression. Ordering them correctly needs implicit-conversion checks across all candidate pairs, on every generator run, over every [EventType] type in all referenced assemblies, and nobody has reported hitting this. It can be revisited if it comes up.

@github-actions

github-actions Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Test Results

   48 files  + 24     48 suites  +24   14m 14s ⏱️ + 1m 33s
  607 tests +  7    607 ✅ +  7  0 💤 ±0  0 ❌ ±0 
1 208 runs  +597  1 208 ✅ +597  0 💤 ±0  0 ❌ ±0 

Results for commit a965b3e. ± Comparison against base commit efb66b3.

♻️ This comment has been updated with latest results.

…er arms

Types related only through generic variance (IEnvelope<Zebra> and
IEnvelope<Animal> with IEnvelope<out T>) have the same number of
supertypes, so the specificity rank cannot order them. Breaking such ties
by name could emit the broader arm first and fail with CS8510, including
in projects that compiled before, when arms followed declaration order.

Drop the name tie-break and rely on the stable sort, so equally specific
types keep their discovery order. That is still deterministic, and
anything that compiled before the arms were ranked still compiles.

Reword the comments that claimed the rank alone guarantees no arm is
subsumed, and add a regression test.

Co-Authored-By: Claude Fable 5.1 <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.

ConsumeContextConverterGenerator emits unreachable switch arms for inherited [EventType] records (CS8510) since 0.16.4

1 participant