fix(analyzers): decouple code fixers from Rules to prevent MissingFieldException in VS#6158
fix(analyzers): decouple code fixers from Rules to prevent MissingFieldException in VS#6158thomhurst wants to merge 3 commits into
Conversation
…ldException in VS (#6157) Code fixer assemblies ship in the version-agnostic analyzers/dotnet/cs folder and resolve their analyzer-assembly dependency at runtime by simple name. Visual Studio cannot unload analyzer assemblies, so after a package update (or with mixed TUnit versions in one session) the new code fixers can bind against a stale TUnit.Analyzers.dll. The eagerly evaluated FixableDiagnosticIds then JITs a field reference to Rules.MSTestMigration / Rules.NUnitMigration, which the stale assembly does not contain, throwing MissingFieldException on every lightbulb pass. Fix: introduce DiagnosticIds const classes (compile-time baked into consuming IL) and remove every Rules reference from both code fixer assemblies. Add IL-level regression tests asserting the built code fixer dlls contain no TypeReference to Rules.
There was a problem hiding this comment.
Code Review
This is a well-crafted fix for a subtle cross-assembly binding issue. The root cause analysis in the PR description is thorough, and the solution is architecturally correct.
What the fix does right
The const choice is the key design decision. Using public const string in DiagnosticIds (rather than static readonly) is deliberate and essential: the C# compiler inlines const values at the call site, so the consuming code fixer assembly ends up with zero IL TypeReferences to DiagnosticIds or Rules. static readonly would not have worked — it generates a runtime field load and keeps the type reference. This is exactly the right tool for this constraint.
IL-level regression tests are excellent. Using System.Reflection.Metadata / PEReader to assert the built assembly contains no TypeReference to Rules is a strong, code-path-independent guarantee. It prevents regressions that pass all behavioral tests but reintroduce the coupling (e.g., someone using Rules.X.Id inside a lazy helper that happens to be called only from RegisterCodeFixesAsync).
Both packages are fixed consistently. TUnit.Analyzers and TUnit.Assertions.Analyzers receive the same treatment.
Documentation in BaseMigrationCodeFixProvider is well-placed. The XML doc comment on DiagnosticId explains the constraint directly where a future contributor would override it.
One thing worth adding to DiagnosticIds.cs
The regression tests guard against Rules references, but they don't verify that DiagnosticIds constants remain const rather than drifting to static readonly. If someone later changes a field to static readonly (perhaps to derive a value), the compile-time baking guarantee silently breaks without failing the existing tests. Consider adding a comment near the top of each DiagnosticIds class:
// IMPORTANT: All members MUST be 'const', not 'static readonly'.
// consts are baked into consuming assemblies at compile time, eliminating
// the runtime TypeReference that causes MissingFieldException in VS after
// a package update (see issue #6157). Changing to 'static readonly' would
// silently reintroduce the coupling without failing the IL regression tests.This is a low-cost addition that preserves the intent for future maintainers.
Minor pre-existing inconsistency (not introduced here, not a blocker)
Rules.cs has two fields without readonly:
public static DiagnosticDescriptor InstanceTestMethodpublic static DiagnosticDescriptor MatrixDataSourceAttributeRequired
All other DiagnosticDescriptor fields use public static readonly. These mutable static fields are probably accidental. Worth fixing in a follow-up to close the door on mutation bugs.
Verdict
Approve. The fix is architecturally sound, the tests are creative and strong, and the change has no behavior impact (same IDs, same titles). The comment suggestion above is the only material addition worth considering.
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 0 |
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.
…, address review - Extract duplicated PEReader scan into SharedTestHelpers/RulesDecouplingVerifier (linked into all three codefixer test projects) - Cover the third vulnerable codefixer family: TUnit.AspNetCore.Analyzers gets DiagnosticIds consts + IL guard test (same #6157 pattern, was missed) - Verifier also flags DiagnosticIds type refs so const -> static readonly drift fails the test (review feedback) - Add missing readonly on InstanceTestMethod / MatrixDataSourceAttributeRequired - Collapse the repeated rationale doc comment to one canonical home
There was a problem hiding this comment.
Follow-up review
A previous review already approved this change and covered the design well. One additional finding worth flagging before merge:
Pre-existing diagnostic ID collision now surfaced in constants
The new DiagnosticIds classes make a pre-existing collision more visible. Both of these constants resolve to "TUnit0062":
TUnit.Analyzers.DiagnosticIds.CancellationTokenMustBeLastParameter = "TUnit0062"TUnit.AspNetCore.Analyzers.DiagnosticIds.FactoryAccessedTooEarly = "TUnit0062"
This collision predates the PR (both Rules.cs files already used the literal "TUnit0062"), so it is not introduced here. But extracting to named constants now makes the duplication structurally visible in a way that invites a fix.
Depending on the intended ID space:
- If these are intended to live in separate namespaces with no overlap concern (i.e.
TUnit.AspNetCore.AnalyzersIDs are deliberately separate fromTUnit.AnalyzersIDs), the current state is fine and just needs a comment clarifying that. - If they share a flat ID space, one of the two rules has the wrong ID and should be renumbered.
This is not a blocker for merging — the behavioral change from this PR is zero — but worth a follow-up issue to resolve the ambiguity.
Surfaced by review on #6158; renumbering is user-facing and tracked separately.
|
Re follow-up review: confirmed the |
There was a problem hiding this comment.
Code Review
This is a well-targeted, correctly-reasoned fix for a subtle cross-assembly version-skew problem. Overall the PR is in excellent shape.
What's working well
The root cause analysis and fix are correct. const values are inlined into call-site IL at compile time (CIL ldstr / constexpr substitution), so the code fixer binary carries zero TypeReference entries pointing at Rules. That's the only reliable solution — any static readonly or property access would still produce a TypeReference, and any Rules.X.Id field access would be a FieldReference.
The IL-level regression test is excellent. Using System.Reflection.Metadata/PEReader to scan TypeReference handles in the compiled assembly is a low-level, unforgeable guard. It catches typeof(Rules), field access, and method calls all at once. Crucially, DiagnosticIds itself is also scanned — if anyone changes a const to static readonly, a TypeReference to DiagnosticIds appears and the test fails. That's a subtle but important self-check.
Opportunistic readonly fixes are correct. Rules.InstanceTestMethod and Rules.MatrixDataSourceAttributeRequired were missing readonly — the PR fixes that consistently with the rest of the class.
The TUnit0062 collision is correctly scoped out. The NOTE comment in TUnit.AspNetCore.Analyzers/DiagnosticIds.cs documents the pre-existing collision clearly and explains why renumbering is a separate, user-facing change.
Minor concerns (non-blocking)
Hardcoded title strings can drift from resx.
In InheritsTestsCodeFixProvider.cs and XUnitMigrationCodeFixProvider.cs, the lazy Rules.X.Title.ToString() usages were replaced with string literals:
// before
title: Rules.DoesNotInheritTestsWarning.Title.ToString()
// after
private const string CodeFixTitle = "Add [InheritsTests] attribute";This is the correct trade-off for decoupling, but it means the title in the lightbulb and the title in the resource file (TUnit0030Title) can now diverge silently. If the resx title is ever updated (localization, rewording), the code fixer lightbulb won't track it.
A pattern worth considering for future cases: rather than duplicating the string in a private const, you could define a companion CodeFixTitles static class alongside DiagnosticIds, also with const strings, so there's still a single place to update both the analyzer and the fixer title. For this PR the two affected literals are stable enough that this isn't urgent — just worth noting for any future additions to the pattern.
RulesDecouplingVerifier XML doc verbosity.
The shared helper has unusually verbose XML docs for a test utility. That's fine here because the comment explains a non-obvious cross-assembly IL constraint that future maintainers will need to understand.
Summary
The fix is architecturally sound and the test coverage is thorough. The only open question is the long-term maintenance of the two hardcoded title strings — low risk for now, but worth tracking if more lazy Rules.X.Title usages need decoupling in the future. Ship it.
Fixes #6157
Root cause
TUnit.Analyzers.CodeFixers.dllships in the version-agnosticanalyzers/dotnet/csfolder whileTUnit.Analyzers.dllships per-Roslyn (analyzers/dotnet/roslyn4.x/cs), and the code fixers reference the analyzers assembly at runtime (ProjectReference, resolved by simple name — the assemblies are not strong-named).Visual Studio cannot unload analyzer assemblies. After a TUnit package update without a VS restart (or with mixed TUnit versions in one VS session), the new code fixers bind against a stale
TUnit.Analyzers.dll. VS evaluatesFixableDiagnosticIdseagerly on every lightbulb pass, which JITs the field referenceRules.MSTestMigration/Rules.NUnitMigration— fields added in #3195 that the stale assembly lacks — throwingMissingFieldException"at random parts of coding".The heavy migration machinery (
TUnit.Analyzers.Migrators.Base.*) is only touched lazily insideRegisterCodeFixesAsync, which can only run when the matching (new) analyzer reported the diagnostic — so decoupling the eager members removes the failure mode entirely.The same latent landmine existed in
TUnit.Assertions.Analyzers.CodeFixers.Fix
DiagnosticIdsclasses withpublic const stringper rule inTUnit.AnalyzersandTUnit.Assertions.Analyzers;Rules.csconsumes the constants (single source of truth).DiagnosticIds.X(consts are baked into the consuming IL at compile time) instead ofRules.X.Id; the two lazyRules.X.Titleusages replaced with literals. Both code fixer assemblies now carry zero references to theRulestype.System.Reflection.Metadata) assert the built code fixer dlls contain noTypeReferencetoRules— any futureRules.X.Idreintroduction fails the test.No behavior change: same diagnostic IDs, same code-fix titles (copied from resx).
Testing
TUnit.Analyzers.Tests: 705 passed, 0 failed, 1 skipped (pre-existing skip)TUnit.Assertions.Analyzers.CodeFixers.Tests: 19 passed, 0 failedDiagnosticIds.csUser-facing note
Existing affected sessions are environmental (stale assembly already loaded): restarting VS after updating TUnit clears it. This change makes future version-skew sessions benign.