feat: TUnit0074 analyzer for redundant hook attributes on overrides#5459
feat: TUnit0074 analyzer for redundant hook attributes on overrides#5459
Conversation
When a method declares [Before(Test)] / [After(Test)] and overrides a base method that already declares the same hook attribute, both registrations are invoked via virtual dispatch on the same instance — the override's body runs twice per test. The previous fix (#5428 / #5441) deduplicated this at runtime via MethodInfo.GetBaseDefinition(), which silently hid the duplication instead of surfacing it to the author and didn't address the [InheritsTests] variant in #5450. Replace the runtime dedup with a compile-time analyzer (TUnit0074, Error) plus a code fix that removes the redundant attribute. The analyzer walks the full IMethodSymbol.OverriddenMethod chain so transitive cases (gap in the middle of the override chain) are also caught, and the [InheritsTests] + abstract intermediate shape from #5450 fails to compile at the intermediate's override. Revert the runtime dedup: drop InstanceHookMethod.BaseDefinition, ResolveBaseDefinition / IsOverriddenByMoreDerivedHook from HookDelegateBuilder, and the GetBaseDefinition() init in ReflectionHookDiscoveryService. PublicAPI snapshots updated accordingly. Regression tests (VirtualHookOverrideTests + Bugs/5450) rewritten to the "attribute only on base, override without attribute" shape — the only shape TUnit0074 still allows. Assertions are inlined inside the hook bodies because After-hooks are sorted derived-class-first across the type hierarchy, so a separate verification hook on the derived class would run before the base teardown.
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 17 |
TIP This summary will be updated as you push new changes. Give us feedback
There was a problem hiding this comment.
Code Review: TUnit0074 — Compile-time analyzer for redundant hook attributes on overrides
Overall this is a well-reasoned improvement: promoting a silent runtime workaround (#5441) to a loud compile-time error is strictly better UX and removes the complexity from the hot path. The implementation is clean and the test coverage is comprehensive. A few observations below.
Architecture concern: runtime dedup removal leaves a gap for pre-compiled assemblies
The most significant concern is that the runtime dedup is removed unconditionally, while the analyzer only guards against newly compiled code.
Consider the scenario:
- A test helper library
TestBase.dllwas compiled before TUnit0074 ships, and it contains the double-attribute pattern (base + override both declare[Before(Test)]). - A consuming project upgrades TUnit to the version that includes this PR.
- The consuming project gets no compiler error (the offending attribute is in the DLL, not in their source).
- At runtime, TUnit discovers both hook registrations and — without the runtime dedup — invokes the override's body twice per test.
The previous approach handled this transparently for any inheritance shape, regardless of where the attribute was compiled. The new approach only prevents it for source the user can recompile. This tradeoff is probably acceptable for the common case (people typically recompile when upgrading TUnit), but it's worth at least a note in the analyzer description or a migration guide entry, and possibly a [Obsolete] warning in a transitional release rather than hard removal.
An alternative that keeps both layers would be to retain the runtime dedup as a silent fallback but also add the analyzer error. That way, code that slips through at compile time (pre-compiled libraries, generated code) is still protected at runtime. The runtime cost is minimal since well-behaved code exits the loop immediately.
hookType!.Value — the suppression is technically correct but fragile
In VirtualHookOverrideAnalyzer.cs:
var conflictingBase = FindBaseWithMatchingHook(methodSymbol, hookType!.Value, context.Compilation);IsStandardHook is annotated [NotNullWhen(true)] on out HookType? hookType, so the ! is correct — the compiler just can't flow-analyse through the subsequent continue on hookLevel. This works but is relying on a null-forgiving operator rather than the normal control flow. A cleaner alternative:
if (!attributeData.IsStandardHook(compilation, out _, out var hookLevel, out var hookType)
|| hookLevel != HookLevel.Test
|| hookType is null)
{
continue;
}
var conflictingBase = FindBaseWithMatchingHook(methodSymbol, hookType.Value, context.Compilation);Combining the guards into one if lets the compiler track nullability without needing !, making the intent clearer.
Code fix — KeepNoTrivia silently discards leading comments
In VirtualHookOverrideCodeFixProvider.cs:
newRoot = root.RemoveNode(attributeList, SyntaxRemoveOptions.KeepNoTrivia)!;KeepNoTrivia is the right choice to avoid leaving a stray blank line, but it also silently discards any leading trivia (e.g. a // Setup for test X comment placed directly above the attribute). KeepLeadingTrivia would preserve that comment but re-attach it to the next node (the method declaration), which is arguably more correct. Worth considering which behaviour is preferred here — losing a comment is surprising.
Positives worth noting
- Transitive chain detection (
FindBaseWithMatchingHookwalks the fullOverriddenMethodchain) correctly handles the A→B(no attr)→C(attr) gap shape that a simpleOverriddenMethodcheck would miss. newmethod exclusion is handled by theIsOverride: trueguard at the top — the test case confirms this.- Mismatched hook types (
Beforeon base,Afteron override) are correctly allowed since they register distinct lifecycle phases. [InheritsTests]regression (Bugs/5450) is tested through the concrete subclass shape — the inline assertion pattern is a smart workaround for the After-hook ordering constraint.- Public API snapshots updated across all four TFMs — no drift.
- Analyzer release notes updated (
AnalyzerReleases.Unshipped.md) — good hygiene.
The resource strings give a clear, actionable error message that explains why the attribute is redundant and what to do instead.
Summary
The compile-time approach is the right long-term direction. The main architectural question is whether to fully remove the runtime fallback (acceptable if you're comfortable with the pre-compiled-assembly gap) or retain it as a backstop alongside the analyzer error. The other two items (hookType!.Value cleanup, trivia handling in the code fix) are minor quality improvements but not blockers.
…n code fix - Merge the IsStandardHook/HookLevel/hookType checks into one if so the compiler tracks nullability without needing the null-forgiving operator. - Use KeepLeadingTrivia when removing the attribute list so any leading comment above the attribute is preserved on the method declaration.
There was a problem hiding this comment.
Follow-up Review: TUnit0074 (after commit ed5c622)
The follow-up commit addresses two of the three items from the previous review — good turnaround.
Previous concerns: status
✅ Addressed — null guard cleanup in analyzer
The hookType!.Value null-forgiving operator is gone. The combined guard now reads:
if (!attributeData.IsStandardHook(context.Compilation, out _, out var hookLevel, out var hookType)
|| hookLevel != HookLevel.Test
|| hookType is null)
{
continue;
}The compiler can track non-nullability into hookType.Value without any suppression. Clean.
✅ Addressed — KeepLeadingTrivia in code fix
The single-attribute removal path now uses KeepLeadingTrivia, preserving any comment placed directly above the attribute list and re-attaching it to the method declaration. The inline comment explains the intent clearly.
The concern from the previous review remains: if a library compiled against an older TUnit version ships the double-attribute pattern in a DLL, consumers who upgrade to this version of TUnit will hit the double-invocation bug at runtime with no compiler error to guide them, because the analyzer only fires on source being compiled.
This is a deliberate tradeoff (compile-time is the right long-term home for this guard), but it deserves explicit documentation or a migration note — ideally in the TUnit0074 description or a CHANGELOG entry — so library authors know they need to recompile and fix their sources, not just upgrade the package.
No new concerns
The rest of the implementation looks solid:
- The full-chain walk in
FindBaseWithMatchingHookcorrectly handles the A→B(no attr)→C(attr) transitive case. BatchFixerforGetFixAllProvideris the right choice; it handles multiple diagnostics in the same document atomically.- The analyzer correctly skips non-Test hook levels (Assembly/Class hooks are enforced static elsewhere, so virtual dispatch isn't a factor for them).
- Test coverage for all 8 distinct shapes (error cases, no-error cases, abstract intermediate, transitive chain, mismatched types,
newshadow) is thorough.
The architecture is sound. The only remaining action item is documenting the pre-compiled-assembly gap so library authors are aware of it.
Summary
Replaces the runtime hook deduplication merged in #5441 with a compile-time analyzer that surfaces the duplication to the author and also catches the
[InheritsTests]variant from #5450.When a method declares
[Before(Test)]/[After(Test)]and overrides a base method that already declares the same hook attribute, both registrations are invoked via virtual dispatch — the override's body runs twice per test. The previous fix deduplicated this silently at runtime viaMethodInfo.GetBaseDefinition(); this PR errors at compile time instead.TUnit0074(Error, Usage): walks the fullIMethodSymbol.OverriddenMethodchain so transitive cases (gap in the middle of the chain) are also caught. The[InheritsTests]+ abstract intermediate shape from [Bug]: Virtual hook methods are called for base classes in inherited tests #5450 fails to compile at the intermediate's override.KeepNoTriviaso we don't leave a stray blank line above the method).InstanceHookMethod.BaseDefinition,ResolveBaseDefinition/IsOverriddenByMoreDerivedHookinHookDelegateBuilder, and theGetBaseDefinition()init inReflectionHookDiscoveryService. PublicAPI snapshots updated for all 4 TFMs.VirtualHookOverrideTests+Bugs/5450/InheritsTestsVirtualHookOverrideTests) rewritten to the "attribute only on base, override without attribute" shape — the only shape TUnit0074 still allows. Both run with[EngineTest(ExpectedResult.Pass)]so they're exercised in source-gen and reflection modes. Assertions are inlined inside the hook bodies because After-hooks are sorted derived-class-first across the type hierarchy, so a separate verification hook on the derived class would run before the base teardown.Closes #5450. Supersedes the runtime fix from #5441.
Test plan
TUnit.Analyzers.Tests— 8 new test cases underVirtualHookOverrideAnalyzerTestscovering: Before+Before error, After+After error, attribute only on base (ok), attribute only on override (ok), abstract intermediate + InheritsTests (error on intermediate), chain with gap A(attr)→B(no)→C(attr) (error on C via transitive ancestor), mismatched hook types (ok),newmethod (ok). Full analyzer suite passes.TUnit.PublicAPIsnapshot tests pass on all 4 TFMs after removing theBaseDefinitionline.VirtualHookOverrideTests.DerivedTestClass.Override_Runs_Exactly_Oncepasses in source-gen and reflection modes on net10.0.Bugs/5450/InheritsTestsVirtualHookOverrideTests(bothConcreteTestClassAandConcreteTestClassB) pass in source-gen and reflection modes on net10.0.