feat(assertions): inference-friendly pinned overload for covariant [AssertionExtension] with own generic (#5922)#6196
Conversation
…tension] with own generic (#5922) When an [AssertionExtension] class declares its own generic parameter over a non-sealed concrete (covariance-candidate) receiver, the generator emitted only a covariant Matches<TActual, T>(this IAssertionSource<TActual>, ...) overload. Because C# forbids partial type-argument specification, callers that must name the class's own argument (e.g. a non-inferable lambda type) were also forced to repeat the receiver type: Assert.That(ex).Matches<Exception, MyPayload>(...). Additionally emit an inference-friendly pinned-receiver overload Matches<T>(this IAssertionSource<Exception>, ...) for this shape. The common exact-receiver call site now needs only the class's own type argument (Assert.That(ex).Matches<MyPayload>(...)); the covariant overload is kept so a more-derived static receiver can still bind. No OverloadResolutionPriority is needed - C#'s "more specific parameter types" rule prefers the concrete-receiver overload when both apply. - AssertionExtensionGenerator: add pinnedReceiver path; emit the extra overload only for the covariance-candidate + own-generics shape. - Update ConcreteReceiverWithExtraGeneric snapshots (all TFMs); strengthen the test to pin both overload signatures alongside the compile-clean gate. - Add CovariantGenericExtensionInferenceTests: compile-time guard proving overload resolution (pinned for exact type, covariant for subclass, no CS0121 ambiguity).
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 41 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
There was a problem hiding this comment.
Code Review
This is a well-scoped, well-motivated fix for a real C# ergonomics pain point. The approach is sound and the implementation is correct.
What's good
Conservative scope: The pinned-receiver overload is emitted only when both conditions are true — covariance-candidate receiver AND own generic params. Emitting it more broadly would risk ambiguity or breaking changes.
No OverloadResolutionPriority polyfill needed: Relying on C#'s native "more specific parameter types" rule to prefer IAssertionSource<Exception> over IAssertionSource<TActual> is the right call. It avoids a generated-code dependency on a net9.0/polyfill attribute and is a more principled disambiguation.
Three-scenario test coverage: Exact-type receiver, subclass receiver, and both-applicable-with-inference all have explicit tests. The class-level XML comment clearly communicates why these are primarily compile-time guards.
Snapshot pinned-overload uses source.Context directly (not source.Context.Map<...>): correct, since the receiver is already pinned to IAssertionSource<Exception>.
Things to consider
1. EmitMethod local vs. parameters — implicit capture
void EmitMethod(IMethodSymbol constructor, bool negated)
{
GenerateExtensionMethod(sourceBuilder, data, constructor, negated, isNullableOverload: false);
if (needsPinnedOverload)
GenerateExtensionMethod(sourceBuilder, data, constructor, negated, isNullableOverload: false, pinnedReceiver: true);
}EmitMethod silently closes over sourceBuilder, data, and needsPinnedOverload. For a reader who jumps to the constructor loop call sites without reading upward, the captured state isn't obvious. This isn't a bug — the existing code already threads sourceBuilder and data through all helpers — but consider whether a static helper method EmitMethod(sourceBuilder, data, constructor, negated, needsPinnedOverload) would be cleaner, making the dependencies explicit rather than captured. Minor point, leave or change as you prefer.
2. OverloadResolutionPriority on both overloads
If a user annotates their assertion class with [OverloadResolutionPriority(n)] (non-zero), that priority is applied to both the covariant and pinned overloads (because effectivePriority = data.OverloadResolutionPriority is not pinnedReceiver-aware). When both overloads carry the same non-zero ORP tier, C#'s "more specific parameter types" rule still applies within that tier, so there's no ambiguity issue. But it's worth a comment near the effectivePriority block noting that ORP is intentionally the same on both overloads so that the "more specific receiver" rule remains the tiebreaker.
3. Nullable overloads and the new path
Nullable overloads already suppress covariance (isNullableOverload → exact receiver). They are generated separately from the EmitMethod loop (in the section that follows the constructor loop). Verifying: does the pinned-receiver shape ever interact with nullable overloads? The answer looks like "no" — the nullable path is a distinct branch and EmitMethod only controls the non-nullable path. Worth confirming with a quick scan to make sure no constructor accidentally ends up in both paths.
4. Test predicates always return Passed — acceptable but worth a comment
InferencePayloadMatchesAssertion and InferenceTaggedExceptionAssertion both return AssertionResult.Passed unconditionally, so p => p.Ok where Ok defaults to false never fails. The class-level XML doc explains this, but someone maintaining Pinned_Overload_Exact_Receiver_Only_Needs_Own_Type_Argument years from now might be confused why a failing predicate passes. A single inline comment on CheckAsync ("always passes; these fixtures are compile-time guards only") would close that gap. This is a minor nit.
Summary
The design is correct and the implementation is minimal. The two main things worth a second look are (1) whether the EmitMethod closure vs. explicit-parameter shape is intentional, and (2) a small clarification comment near the OverloadResolutionPriority assignment to explain why both overloads share the same priority. Neither is a blocker. Overall this is a solid, well-tested change.
…on-inferable (#5922) The pinned-receiver overload added in #6196 only helps the call site when the caller is forced to name a type argument - i.e. when an own generic parameter is NOT inferable from the constructor's value arguments (e.g. it appears only inside a Func<T, bool>). When every own generic is inferable (e.g. a plain T tag), the caller writes no type arguments at all and the covariant overload binds on its own, so the pinned overload is pure dead weight. Gate emission on CovarianceHelper.OwnGenericsAreInferable so the pinned overload is skipped for the inferable case. This also keeps the two overloads arity-disjoint (pinned <T...> vs covariant <TActual, T...>), so they are never both applicable and no OverloadResolutionPriority tiebreaker is needed. Also addresses review feedback: EmitMethod is now an explicit static method (no implicit closure capture), and the compile-guard test fixtures are commented as always-passing / compile-time-only.
|
Pushed a follow-up ( The problem with always emitting itThe pinned overload only earns its place when the caller is forced to name a type argument — i.e. when an own generic parameter is not inferable from the constructor's value arguments (it appears only inside a ChangeEmission is now gated on
A nice side effect: with the inferable case excluded, the pinned ( Review feedback addressed
Tests
|
There was a problem hiding this comment.
Code Review — PR #6196: Inference-friendly pinned overload for covariant AssertionExtension
Following up on my previous review (2026-06-08). Checking whether prior concerns were addressed and doing a fresh pass on the diff.
Previous comment corrections
Item 1 from prior review was wrong. I flagged EmitMethod as a "closure that silently closes over sourceBuilder, data", but looking at the actual code it's a private static void EmitMethod(StringBuilder sourceBuilder, AssertionExtensionData data, IMethodSymbol constructor, bool negated, bool emitPinned) — all explicit parameters. No closure at all. That concern is a non-issue; the implementation is clean.
New findings
1. Redundant double-check in hasOwnGenerics (AssertionExtensionGenerator.cs)
var hasOwnGenerics = data.ClassSymbol.IsGenericType && data.ClassSymbol.TypeParameters.Length > 0;IsGenericType == true for a named type symbol already implies TypeParameters.Length > 0. The && TypeParameters.Length > 0 guard is always-true when IsGenericType is true — it cannot add any protection. Fine to leave, but reads as unintentional.
2. OverloadResolutionPriority note — still unaddressed (AssertionExtensionGenerator.cs, effectivePriority block)
From the prior review: if a user annotates their class with [OverloadResolutionPriority(n)], effectivePriority is applied to both overloads because the pinned-path check happens after priority is read. The covariant overload's "more specific parameter types" wins naturally when both are applicable, so this doesn't break correctness today. But it's worth a short comment near the effectivePriority calculation noting that ORP is intentionally the same on both overloads — "more specific receiver wins by C# spec, no tiebreaker needed" — so a future maintainer doesn't try to add pinnedReceiver-aware priority logic.
3. Conservative treatment of T inside generic collections of delegates (CovarianceHelper.cs, AppearsInInferablePosition)
When a parameter type is IEnumerable<Func<T, bool>>, the method returns false for T (the outer IEnumerable<> is not a delegate, so it recurses into its type arg Func<T, bool>; Func<T, bool> is a delegate, so it stops and returns false). In practice a caller could pass a List<Func<int, bool>> and the compiler would infer T = int. So the analysis is conservative here — the pinned overload gets emitted even when it's technically redundant.
This is intentional and documented ("The bias is conservative…"), and the worst outcome is one extra overload. Worth noting explicitly in the method summary that this is a known conservatism for delegate-inside-collection cases, not a bug, so future readers don't "fix" it into an over-aggressive heuristic.
4. No test for negated assertion with pinned overload
EmitMethod emits the pinned overload for both positive and negated variants. There's no call-site test confirming the negated pinned path compiles and resolves correctly. The snapshot tests cover the generated text, but a small addition to CovariantGenericExtensionInferenceTests for a negated call (e.g. await Assert.That(ex).Not.MatchesPayload<Payload>(...)) would close this gap.
What's working well
- The logic for when to emit the pinned overload is precise and well-documented: covariance candidate and has own generics and those generics are not all inferable.
- The pinned overload correctly uses
source.Contextdirectly (no.Map<>()call) since the receiver is already the concrete type. IsExpressionTreecorrectly gates onSystem.Linq.Expressions.Expression<T>— the usual expression-tree pattern — without false-positives on other expression-tree types.- The arity-disjoint property (pinned
<T>vs covariant<TActual, T>) means noOverloadResolutionPrioritypolyfill is needed. That's a clean design. - Three-scenario test coverage in
CovariantGenericExtensionInferenceTests(exact-type, subclass, fully-inferable) covers the critical correctness surface.
Summary
Items 1 and 3 are informational. Item 2 (ORP comment) is a low-cost documentation improvement. Item 4 (negated test) is the only actionable gap. None are blockers — the core design is sound and the implementation is minimal and well-tested. Good PR.
|
Thanks for the second pass. Went through all four findings (commit #2 — ORP comment near #3 — delegate-inside-collection conservatism → ✅ Applied. Documented the known conservatism in #1 — #4 — no negated-pinned compile-guard test → Pushing back: the negated path is the same emit code — Also noting your correction on prior item #1 (the |
There was a problem hiding this comment.
Code Review — PR #6196 (third pass)
This is a follow-up pass after the author's response to the previous two reviews. All prior findings were either addressed or pushed back on with solid reasoning. This pass checks the current state of the code for anything remaining.
Prior items — status
Closure vs explicit parameters (#1 from first review): Resolved. EmitMethod is a proper private static void EmitMethod(StringBuilder, AssertionExtensionData, IMethodSymbol, bool, bool) — all dependencies explicit.
IsGenericType && TypeParameters.Length > 0 (#1 from second review): The author's rebuttal is correct. In Roslyn, INamedTypeSymbol.IsGenericType returns true if the type itself or any enclosing type has type parameters. So for a non-generic nested class class Outer<T> { class Inner : Assertion<Concrete> }, Inner.IsGenericType == true but Inner.TypeParameters.Length == 0. The dual check is load-bearing, not redundant. The same pattern already appears at line 289 (pre-existing code), confirming it's intentional.
ORP comment (#2 from second review): Addressed — comment added at the effectivePriority block.
Delegate-inside-collection conservatism (#3 from second review): Addressed — documented explicitly in AppearsInInferablePosition's XML summary.
Negated pinned test (#4 from second review): The author's pushback is correct. The negated flag only flips the emitted method name; the emitPinned decision and type-parameter handling are identical for both the positive and negated arms. A negated call-site test would cover the exact same overload-resolution machinery, so this is intentional shared coverage rather than a gap.
Remaining observations (informational only, not blockers)
isNullableOverload is dead parameter state. GenerateExtensionMethod accepts bool isNullableOverload but it is never called with true in this generator — only EmitMethod calls it, and both call sites pass isNullableOverload: false. The parameter exists for historical reasons (it may be future scaffolding), but as-is, the three if (isNullableOverload) branches inside GenerateExtensionMethod are unreachable. This is pre-existing and out of scope for this PR, but worth noting for a future cleanup pass.
OwnGenericsAreInferable with a context-only constructor. If a generic Assertion<T> subclass has only the AssertionContext<T> parameter (no value params), valueParameters after Skip(1) is empty. The method then returns false for each own type parameter, setting emitPinned = true. The resulting pinned overload Foo<T>(this IAssertionSource<Exception> source) has no value params to infer T from, so T remains unresolvable at the call site. This is a degenerate / nonsensical assertion design that wouldn't be useful in practice, and the conservative bias is documented, so this is not a real concern — just noting the edge case is handled consistently with the conservative policy.
No test for a class with multiple constructors where one has inferable own generics and another does not. For example, class Foo<T> : Assertion<Exception> with ctor1(ctx, Func<T,bool>) (non-inferable) and ctor2(ctx, T value) (inferable). This would emit a pinned overload for ctor1 but not ctor2. The resulting API is correct (the per-constructor emitPinned computation is independent and sound), but no test covers this mixed case. Low risk.
Summary
The implementation is correct and well-motivated. All actionable findings from prior reviews were addressed. The rebuttals on items #1 (IsGenericType) and #4 (negated test) are technically sound and I agree with both. The remaining observations above are informational — none warrant changes. This PR is ready to merge.
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.51.0 to 1.53.0. <details> <summary>Release notes</summary> _Sourced from [TUnit's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.53.0 <!-- Release notes generated using configuration in .github/release.yml at v1.53.0 --> ## What's Changed ### Other Changes * feat(assertions): return typed value from IsAssignableTo<T> (#6184) by @thomhurst in thomhurst/TUnit#6187 * fix: stop doubling backslashes in source-gen emitted FilePath (breaks HTML report source links) by @thomhurst in thomhurst/TUnit#6193 * feat(assertions): add ContainsKey().And.Value drill-in for dictionaries (#6185) by @thomhurst in thomhurst/TUnit#6188 * fix(tests): snapshot ExecutionLog under lock to fix parallel race by @thomhurst in thomhurst/TUnit#6194 * fix(engine): run lifecycle hooks before test class construction (#6192) by @thomhurst in thomhurst/TUnit#6195 * feat(assertions): inference-friendly pinned overload for covariant [AssertionExtension] with own generic (#5922) by @thomhurst in thomhurst/TUnit#6196 * feat: add DeferEnumeration to defer data-source expansion to runtime (#5833) by @thomhurst in thomhurst/TUnit#6197 ### Dependencies * chore(deps): update tunit to 1.51.0 by @thomhurst in thomhurst/TUnit#6186 * chore(deps): update microsoft.testing to 18.8.0 by @thomhurst in thomhurst/TUnit#6191 * chore(deps): update aspire to 13.4.3 by @thomhurst in thomhurst/TUnit#6198 **Full Changelog**: thomhurst/TUnit@v1.51.0...v1.53.0 Commits viewable in [compare view](thomhurst/TUnit@v1.51.0...v1.53.0). </details> Updated [TUnit.AspNetCore](https://github.com/thomhurst/TUnit) from 1.51.0 to 1.53.0. <details> <summary>Release notes</summary> _Sourced from [TUnit.AspNetCore's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.53.0 <!-- Release notes generated using configuration in .github/release.yml at v1.53.0 --> ## What's Changed ### Other Changes * feat(assertions): return typed value from IsAssignableTo<T> (#6184) by @thomhurst in thomhurst/TUnit#6187 * fix: stop doubling backslashes in source-gen emitted FilePath (breaks HTML report source links) by @thomhurst in thomhurst/TUnit#6193 * feat(assertions): add ContainsKey().And.Value drill-in for dictionaries (#6185) by @thomhurst in thomhurst/TUnit#6188 * fix(tests): snapshot ExecutionLog under lock to fix parallel race by @thomhurst in thomhurst/TUnit#6194 * fix(engine): run lifecycle hooks before test class construction (#6192) by @thomhurst in thomhurst/TUnit#6195 * feat(assertions): inference-friendly pinned overload for covariant [AssertionExtension] with own generic (#5922) by @thomhurst in thomhurst/TUnit#6196 * feat: add DeferEnumeration to defer data-source expansion to runtime (#5833) by @thomhurst in thomhurst/TUnit#6197 ### Dependencies * chore(deps): update tunit to 1.51.0 by @thomhurst in thomhurst/TUnit#6186 * chore(deps): update microsoft.testing to 18.8.0 by @thomhurst in thomhurst/TUnit#6191 * chore(deps): update aspire to 13.4.3 by @thomhurst in thomhurst/TUnit#6198 **Full Changelog**: thomhurst/TUnit@v1.51.0...v1.53.0 Commits viewable in [compare view](thomhurst/TUnit@v1.51.0...v1.53.0). </details> Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Summary
Resolves the call-site ergonomics issue in #5922 (the follow-up to the #5918 emit fix).
When an
[AssertionExtension]class declares its own generic parameter and derives fromAssertion<TConcrete>for a non-sealed concrete (covariance-candidate) receiver, the generator emitted only a covariant overload:Because
IAssertionSource<T>is invariant, the covariantTActualis genuinely required soAssert.That(subclass)can bind. But C# forbids partial type-argument specification, so a caller naming the class's own (non-inferable) argument had to repeat the receiver type:Change
The generator now additionally emits an inference-friendly pinned-receiver overload — but only for this exact shape (covariance-candidate receiver and own generic params):
So the common exact-receiver call site only needs the class's own type argument, while the covariant overload is kept for subclass receivers:
No
OverloadResolutionPriorityis needed: C#'s "more specific parameter types" rule prefers the concrete-receiver overload when both apply (the same wayM(int)beatsM<T>(T)). Dropping the attribute also avoids a polyfill dependency in the generated code.MethodAssertionGeneratoris intentionally left unchanged — it already suppresses covariance for own-generic methods (documented there, citing CS1929).Files
AssertionExtensionGenerator.cs— addpinnedReceiverpath; emit the extra overload only for the covariance-candidate + own-generics shape (via a small localEmitMethodhelper).ConcreteReceiverWithExtraGeneric.*.verified.txt— updated snapshots (all TFMs); strengthened the generator test to pin both overload signatures alongside the compile-clean gate.CovariantGenericExtensionInferenceTests.cs(new) — compile-time guard proving overload resolution: pinned binds for exact type, covariant binds for subclass, and no CS0121 ambiguity when both apply.Verification