From 4da0e0a1c31762c112013027222f55298ae3d784 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:47:25 +0100 Subject: [PATCH 1/3] fix(analyzers): decouple code fixers from Rules to prevent MissingFieldException 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. --- .../Base/BaseMigrationCodeFixProvider.cs | 8 ++ .../InheritsTestsCodeFixProvider.cs | 8 +- .../MSTestMigrationCodeFixProvider.cs | 2 +- .../MatrixDataSourceCodeFixProvider.cs | 2 +- .../NUnitMigrationCodeFixProvider.cs | 2 +- ...TimeoutCancellationTokenCodeFixProvider.cs | 2 +- .../VirtualHookOverrideCodeFixProvider.cs | 2 +- .../XUnitMigrationCodeFixProvider.cs | 4 +- .../CodeFixerRulesDecouplingTests.cs | 47 ++++++++ TUnit.Analyzers/DiagnosticIds.cs | 71 +++++++++++ TUnit.Analyzers/Rules.cs | 112 +++++++++--------- .../CodeFixerRulesDecouplingTests.cs | 46 +++++++ .../AwaitAssertionCodeFixProvider.cs | 2 +- .../CollectionIsEqualToCodeFixProvider.cs | 2 +- .../XUnitAssertionCodeFixProvider.cs | 2 +- TUnit.Assertions.Analyzers/DiagnosticIds.cs | 32 +++++ TUnit.Assertions.Analyzers/Rules.cs | 34 +++--- 17 files changed, 292 insertions(+), 86 deletions(-) create mode 100644 TUnit.Analyzers.Tests/CodeFixerRulesDecouplingTests.cs create mode 100644 TUnit.Analyzers/DiagnosticIds.cs create mode 100644 TUnit.Assertions.Analyzers.CodeFixers.Tests/CodeFixerRulesDecouplingTests.cs create mode 100644 TUnit.Assertions.Analyzers/DiagnosticIds.cs diff --git a/TUnit.Analyzers.CodeFixers/Base/BaseMigrationCodeFixProvider.cs b/TUnit.Analyzers.CodeFixers/Base/BaseMigrationCodeFixProvider.cs index eedfc5a6b3..7dc7bc8b9c 100644 --- a/TUnit.Analyzers.CodeFixers/Base/BaseMigrationCodeFixProvider.cs +++ b/TUnit.Analyzers.CodeFixers/Base/BaseMigrationCodeFixProvider.cs @@ -13,7 +13,15 @@ namespace TUnit.Analyzers.CodeFixers.Base; public abstract class BaseMigrationCodeFixProvider : CodeFixProvider { protected abstract string FrameworkName { get; } + + /// + /// The fixable diagnostic ID. Implementations MUST return a constant + /// (never Rules.X.Id): VS evaluates eagerly, and a runtime + /// field reference into TUnit.Analyzers can bind against a stale copy already loaded in the IDE, + /// throwing MissingFieldException. See https://github.com/thomhurst/TUnit/issues/6157. + /// protected abstract string DiagnosticId { get; } + protected abstract string CodeFixTitle { get; } public sealed override ImmutableArray FixableDiagnosticIds => diff --git a/TUnit.Analyzers.CodeFixers/InheritsTestsCodeFixProvider.cs b/TUnit.Analyzers.CodeFixers/InheritsTestsCodeFixProvider.cs index 1437836a79..4c20753375 100644 --- a/TUnit.Analyzers.CodeFixers/InheritsTestsCodeFixProvider.cs +++ b/TUnit.Analyzers.CodeFixers/InheritsTestsCodeFixProvider.cs @@ -16,8 +16,10 @@ namespace TUnit.Analyzers.CodeFixers; [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(InheritsTestsCodeFixProvider)), Shared] public class InheritsTestsCodeFixProvider : CodeFixProvider { + private const string CodeFixTitle = "Add [InheritsTests] attribute"; + public sealed override ImmutableArray FixableDiagnosticIds { get; } = - ImmutableArray.Create(Rules.DoesNotInheritTestsWarning.Id); + ImmutableArray.Create(DiagnosticIds.DoesNotInheritTestsWarning); public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; @@ -38,9 +40,9 @@ public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) context.RegisterCodeFix( CodeAction.Create( - title: Rules.DoesNotInheritTestsWarning.Title.ToString(), + title: CodeFixTitle, createChangedDocument: c => AddInheritsTests(context.Document, classDeclarationSyntax, c), - equivalenceKey: Rules.DoesNotInheritTestsWarning.Title.ToString()), + equivalenceKey: CodeFixTitle), diagnostic); } } diff --git a/TUnit.Analyzers.CodeFixers/MSTestMigrationCodeFixProvider.cs b/TUnit.Analyzers.CodeFixers/MSTestMigrationCodeFixProvider.cs index 5dcb09e5f6..f05c9809f8 100644 --- a/TUnit.Analyzers.CodeFixers/MSTestMigrationCodeFixProvider.cs +++ b/TUnit.Analyzers.CodeFixers/MSTestMigrationCodeFixProvider.cs @@ -14,7 +14,7 @@ namespace TUnit.Analyzers.CodeFixers; public class MSTestMigrationCodeFixProvider : BaseMigrationCodeFixProvider { protected override string FrameworkName => "MSTest"; - protected override string DiagnosticId => Rules.MSTestMigration.Id; + protected override string DiagnosticId => DiagnosticIds.MSTestMigration; protected override string CodeFixTitle => "Convert MSTest code to TUnit"; protected override bool ShouldAddTUnitUsings() => true; diff --git a/TUnit.Analyzers.CodeFixers/MatrixDataSourceCodeFixProvider.cs b/TUnit.Analyzers.CodeFixers/MatrixDataSourceCodeFixProvider.cs index e0caf920c4..eaed2b9d05 100644 --- a/TUnit.Analyzers.CodeFixers/MatrixDataSourceCodeFixProvider.cs +++ b/TUnit.Analyzers.CodeFixers/MatrixDataSourceCodeFixProvider.cs @@ -15,7 +15,7 @@ public class MatrixDataSourceCodeFixProvider : CodeFixProvider private const string Title = "Add [MatrixDataSource]"; public sealed override ImmutableArray FixableDiagnosticIds { get; } = - ImmutableArray.Create(Rules.MatrixDataSourceAttributeRequired.Id); + ImmutableArray.Create(DiagnosticIds.MatrixDataSourceAttributeRequired); public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; diff --git a/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs b/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs index 23519686cf..601042a1a1 100644 --- a/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs +++ b/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs @@ -14,7 +14,7 @@ namespace TUnit.Analyzers.CodeFixers; public class NUnitMigrationCodeFixProvider : BaseMigrationCodeFixProvider { protected override string FrameworkName => "NUnit"; - protected override string DiagnosticId => Rules.NUnitMigration.Id; + protected override string DiagnosticId => DiagnosticIds.NUnitMigration; protected override string CodeFixTitle => "Convert NUnit code to TUnit"; protected override AttributeRewriter CreateAttributeRewriter(Compilation compilation) diff --git a/TUnit.Analyzers.CodeFixers/TimeoutCancellationTokenCodeFixProvider.cs b/TUnit.Analyzers.CodeFixers/TimeoutCancellationTokenCodeFixProvider.cs index 69da900156..de5c34d82a 100644 --- a/TUnit.Analyzers.CodeFixers/TimeoutCancellationTokenCodeFixProvider.cs +++ b/TUnit.Analyzers.CodeFixers/TimeoutCancellationTokenCodeFixProvider.cs @@ -17,7 +17,7 @@ public class TimeoutCancellationTokenCodeFixProvider : CodeFixProvider private const string ParameterName = "cancellationToken"; public sealed override ImmutableArray FixableDiagnosticIds { get; } = - ImmutableArray.Create(Rules.MissingTimeoutCancellationTokenAttributes.Id); + ImmutableArray.Create(DiagnosticIds.MissingTimeoutCancellationTokenAttributes); public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; diff --git a/TUnit.Analyzers.CodeFixers/VirtualHookOverrideCodeFixProvider.cs b/TUnit.Analyzers.CodeFixers/VirtualHookOverrideCodeFixProvider.cs index 4c58dc9e84..eba7c7eab6 100644 --- a/TUnit.Analyzers.CodeFixers/VirtualHookOverrideCodeFixProvider.cs +++ b/TUnit.Analyzers.CodeFixers/VirtualHookOverrideCodeFixProvider.cs @@ -15,7 +15,7 @@ public class VirtualHookOverrideCodeFixProvider : CodeFixProvider private const string Title = "Remove redundant hook attribute"; public sealed override ImmutableArray FixableDiagnosticIds { get; } = - ImmutableArray.Create(Rules.RedundantHookAttributeOnOverride.Id); + ImmutableArray.Create(DiagnosticIds.RedundantHookAttributeOnOverride); public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; diff --git a/TUnit.Analyzers.CodeFixers/XUnitMigrationCodeFixProvider.cs b/TUnit.Analyzers.CodeFixers/XUnitMigrationCodeFixProvider.cs index 87e55ed2ec..75a2178a63 100644 --- a/TUnit.Analyzers.CodeFixers/XUnitMigrationCodeFixProvider.cs +++ b/TUnit.Analyzers.CodeFixers/XUnitMigrationCodeFixProvider.cs @@ -13,8 +13,8 @@ namespace TUnit.Analyzers.CodeFixers; public class XUnitMigrationCodeFixProvider : BaseMigrationCodeFixProvider { protected override string FrameworkName => "XUnit"; - protected override string DiagnosticId => Rules.XunitMigration.Id; - protected override string CodeFixTitle => Rules.XunitMigration.Title.ToString(); + protected override string DiagnosticId => DiagnosticIds.XunitMigration; + protected override string CodeFixTitle => "Convert xUnit code to TUnit"; protected override bool ShouldAddTUnitUsings() => true; diff --git a/TUnit.Analyzers.Tests/CodeFixerRulesDecouplingTests.cs b/TUnit.Analyzers.Tests/CodeFixerRulesDecouplingTests.cs new file mode 100644 index 0000000000..fb9f6d74ac --- /dev/null +++ b/TUnit.Analyzers.Tests/CodeFixerRulesDecouplingTests.cs @@ -0,0 +1,47 @@ +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using TUnit.Analyzers.CodeFixers; + +namespace TUnit.Analyzers.Tests; + +/// +/// Guards against https://github.com/thomhurst/TUnit/issues/6157. +/// +/// TUnit.Analyzers.CodeFixers.dll ships in the version-agnostic analyzers/dotnet/cs folder and +/// resolves its TUnit.Analyzers dependency at runtime by simple name. Visual Studio cannot unload +/// analyzer assemblies, so after a package update (or with mixed TUnit versions in one VS session) +/// the new code fixers can bind against a stale TUnit.Analyzers.dll. Any IL reference to the +/// Rules type (e.g. Rules.MSTestMigration.Id inside the eagerly-evaluated +/// FixableDiagnosticIds) then throws MissingFieldException for rules the stale assembly +/// doesn't have. Code fixers must use the compile-time-baked DiagnosticIds constants instead. +/// +public class CodeFixerRulesDecouplingTests +{ + [Test] + public async Task CodeFixers_Assembly_Has_No_Reference_To_Rules_Type() + { + var assemblyPath = typeof(MSTestMigrationCodeFixProvider).Assembly.Location; + + using var stream = File.OpenRead(assemblyPath); + using var peReader = new PEReader(stream); + var metadata = peReader.GetMetadataReader(); + + var rulesReferences = new List(); + + foreach (var handle in metadata.TypeReferences) + { + var typeReference = metadata.GetTypeReference(handle); + + if (metadata.GetString(typeReference.Name) == "Rules" && + metadata.GetString(typeReference.Namespace) == "TUnit.Analyzers") + { + rulesReferences.Add($"{metadata.GetString(typeReference.Namespace)}.{metadata.GetString(typeReference.Name)}"); + } + } + + await TUnit.Assertions.Assert.That(rulesReferences) + .IsEmpty() + .Because("TUnit.Analyzers.CodeFixers must not reference TUnit.Analyzers.Rules at runtime - " + + "use DiagnosticIds constants instead (see issue #6157)"); + } +} diff --git a/TUnit.Analyzers/DiagnosticIds.cs b/TUnit.Analyzers/DiagnosticIds.cs new file mode 100644 index 0000000000..4612aa6f02 --- /dev/null +++ b/TUnit.Analyzers/DiagnosticIds.cs @@ -0,0 +1,71 @@ +namespace TUnit.Analyzers; + +/// +/// Diagnostic ID constants for all TUnit analyzer rules. +/// +/// +/// Code fix providers (TUnit.Analyzers.CodeFixers) MUST reference these constants instead of +/// Rules.X.Id. Constants are baked into the consuming assembly at compile time, so the +/// code fixers carry no runtime reference to the type. A runtime reference +/// can bind against a stale TUnit.Analyzers.dll already loaded in Visual Studio (analyzers +/// cannot be unloaded), throwing for newly added +/// rules. See https://github.com/thomhurst/TUnit/issues/6157. +/// +public static class DiagnosticIds +{ + public const string WrongArgumentTypeTestData = "TUnit0001"; + public const string NoTestDataProvided = "TUnit0002"; + public const string NoMethodFound = "TUnit0004"; + public const string MethodParameterBadNullability = "TUnit0005"; + public const string MethodMustBeStatic = "TUnit0007"; + public const string MethodMustBePublic = "TUnit0008"; + public const string MethodMustNotBeAbstract = "TUnit0009"; + public const string MethodMustBeParameterless = "TUnit0010"; + public const string MethodMustReturnData = "TUnit0011"; + public const string TooManyArgumentsInTestMethod = "TUnit0013"; + public const string PublicMethodMissingTestAttribute = "TUnit0014"; + public const string MissingTimeoutCancellationTokenAttributes = "TUnit0015"; + public const string MethodMustNotBeStatic = "TUnit0016"; + public const string ConflictingExplicitAttributes = "TUnit0017"; + public const string InstanceAssignmentInTestClass = "TUnit0018"; + public const string MissingTestAttribute = "TUnit0019"; + public const string Dispose_Member_In_Cleanup = "TUnit0023"; + public const string UnknownParameters = "TUnit0027"; + public const string DoNotOverrideAttributeUsageMetadata = "TUnit0028"; + public const string DuplicateSingleAttribute = "TUnit0029"; + public const string DoesNotInheritTestsWarning = "TUnit0030"; + public const string AsyncVoidMethod = "TUnit0031"; + public const string DependsOnConflicts = "TUnit0033"; + public const string NoMainMethod = "TUnit0034"; + public const string NoDataSourceProvided = "TUnit0038"; + public const string SingleTestContextParameterRequired = "TUnit0039"; + public const string SingleClassHookContextParameterRequired = "TUnit0040"; + public const string SingleAssemblyHookContextParameterRequired = "TUnit0041"; + public const string GlobalHooksSeparateClass = "TUnit0042"; + public const string PropertyRequiredNotSet = "TUnit0043"; + public const string MustHavePropertySetter = "TUnit0044"; + public const string TooManyDataAttributes = "TUnit0045"; + public const string ReturnFunc = "TUnit0046"; + public const string AsyncLocalCallFlowValues = "TUnit0047"; + public const string InstanceTestMethod = "TUnit0048"; + public const string MatrixDataSourceAttributeRequired = "TUnit0049"; + public const string TooManyArguments = "TUnit0050"; + public const string TypeMustBePublic = "TUnit0051"; + public const string MultipleConstructorsWithoutTestConstructor = "TUnit0052"; + public const string XunitMigration = "TUXU0001"; + public const string NUnitMigration = "TUNU0001"; + public const string MSTestMigration = "TUMS0001"; + public const string OverwriteConsole = "TUnit0055"; + public const string InstanceMethodSource = "TUnit0056"; + public const string HookContextParameterOptional = "TUnit0057"; + public const string HookUnknownParameters = "TUnit0058"; + public const string AbstractTestClassWithDataSources = "TUnit0059"; + public const string PotentialEmptyDataSource = "TUnit0060"; + public const string NoAccessibleConstructor = "TUnit0061"; + public const string CancellationTokenMustBeLastParameter = "TUnit0062"; + public const string CombinedDataSourceAttributeRequired = "TUnit0070"; + public const string CombinedDataSourceMissingParameterDataSource = "TUnit0071"; + public const string CombinedDataSourceConflictWithMatrix = "TUnit0072"; + public const string MissingPolyfillPackage = "TUnit0073"; + public const string RedundantHookAttributeOnOverride = "TUnit0074"; +} diff --git a/TUnit.Analyzers/Rules.cs b/TUnit.Analyzers/Rules.cs index 0b6cba34a9..e6f7d8fffc 100644 --- a/TUnit.Analyzers/Rules.cs +++ b/TUnit.Analyzers/Rules.cs @@ -1,4 +1,4 @@ -using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis; namespace TUnit.Analyzers; @@ -7,171 +7,171 @@ public static class Rules private const string UsageCategory = "Usage"; public static readonly DiagnosticDescriptor WrongArgumentTypeTestData = - CreateDescriptor("TUnit0001", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.WrongArgumentTypeTestData, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor NoTestDataProvided = - CreateDescriptor("TUnit0002", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.NoTestDataProvided, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor NoMethodFound = - CreateDescriptor("TUnit0004", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.NoMethodFound, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor MethodParameterBadNullability = - CreateDescriptor("TUnit0005", UsageCategory, DiagnosticSeverity.Warning); + CreateDescriptor(DiagnosticIds.MethodParameterBadNullability, UsageCategory, DiagnosticSeverity.Warning); public static readonly DiagnosticDescriptor MethodMustBeStatic = - CreateDescriptor("TUnit0007", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.MethodMustBeStatic, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor MethodMustBePublic = - CreateDescriptor("TUnit0008", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.MethodMustBePublic, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor MethodMustNotBeAbstract = - CreateDescriptor("TUnit0009", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.MethodMustNotBeAbstract, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor MethodMustBeParameterless = - CreateDescriptor("TUnit0010", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.MethodMustBeParameterless, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor MethodMustReturnData = - CreateDescriptor("TUnit0011", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.MethodMustReturnData, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor TooManyArgumentsInTestMethod = - CreateDescriptor("TUnit0013", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.TooManyArgumentsInTestMethod, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor PublicMethodMissingTestAttribute = - CreateDescriptor("TUnit0014", UsageCategory, DiagnosticSeverity.Warning); + CreateDescriptor(DiagnosticIds.PublicMethodMissingTestAttribute, UsageCategory, DiagnosticSeverity.Warning); public static readonly DiagnosticDescriptor MissingTimeoutCancellationTokenAttributes = - CreateDescriptor("TUnit0015", UsageCategory, DiagnosticSeverity.Warning); + CreateDescriptor(DiagnosticIds.MissingTimeoutCancellationTokenAttributes, UsageCategory, DiagnosticSeverity.Warning); public static readonly DiagnosticDescriptor CancellationTokenMustBeLastParameter = - CreateDescriptor("TUnit0062", UsageCategory, DiagnosticSeverity.Warning); + CreateDescriptor(DiagnosticIds.CancellationTokenMustBeLastParameter, UsageCategory, DiagnosticSeverity.Warning); public static readonly DiagnosticDescriptor MethodMustNotBeStatic = - CreateDescriptor("TUnit0016", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.MethodMustNotBeStatic, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor ConflictingExplicitAttributes = - CreateDescriptor("TUnit0017", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.ConflictingExplicitAttributes, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor InstanceAssignmentInTestClass = - CreateDescriptor("TUnit0018", UsageCategory, DiagnosticSeverity.Warning); + CreateDescriptor(DiagnosticIds.InstanceAssignmentInTestClass, UsageCategory, DiagnosticSeverity.Warning); public static readonly DiagnosticDescriptor MissingTestAttribute = - CreateDescriptor("TUnit0019", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.MissingTestAttribute, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor Dispose_Member_In_Cleanup = - CreateDescriptor("TUnit0023", UsageCategory, DiagnosticSeverity.Warning); + CreateDescriptor(DiagnosticIds.Dispose_Member_In_Cleanup, UsageCategory, DiagnosticSeverity.Warning); public static readonly DiagnosticDescriptor UnknownParameters = - CreateDescriptor("TUnit0027", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.UnknownParameters, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor DoNotOverrideAttributeUsageMetadata = - CreateDescriptor("TUnit0028", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.DoNotOverrideAttributeUsageMetadata, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor DuplicateSingleAttribute = - CreateDescriptor("TUnit0029", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.DuplicateSingleAttribute, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor DoesNotInheritTestsWarning = - CreateDescriptor("TUnit0030", UsageCategory, DiagnosticSeverity.Warning); + CreateDescriptor(DiagnosticIds.DoesNotInheritTestsWarning, UsageCategory, DiagnosticSeverity.Warning); public static readonly DiagnosticDescriptor AsyncVoidMethod = - CreateDescriptor("TUnit0031", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.AsyncVoidMethod, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor DependsOnConflicts = - CreateDescriptor("TUnit0033", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.DependsOnConflicts, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor NoMainMethod = - CreateDescriptor("TUnit0034", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.NoMainMethod, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor NoDataSourceProvided = - CreateDescriptor("TUnit0038", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.NoDataSourceProvided, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor SingleTestContextParameterRequired = - CreateDescriptor("TUnit0039", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.SingleTestContextParameterRequired, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor SingleClassHookContextParameterRequired = - CreateDescriptor("TUnit0040", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.SingleClassHookContextParameterRequired, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor SingleAssemblyHookContextParameterRequired = - CreateDescriptor("TUnit0041", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.SingleAssemblyHookContextParameterRequired, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor GlobalHooksSeparateClass = - CreateDescriptor("TUnit0042", UsageCategory, DiagnosticSeverity.Warning); + CreateDescriptor(DiagnosticIds.GlobalHooksSeparateClass, UsageCategory, DiagnosticSeverity.Warning); public static readonly DiagnosticDescriptor PropertyRequiredNotSet = - CreateDescriptor("TUnit0043", UsageCategory, DiagnosticSeverity.Info); + CreateDescriptor(DiagnosticIds.PropertyRequiredNotSet, UsageCategory, DiagnosticSeverity.Info); public static readonly DiagnosticDescriptor MustHavePropertySetter = - CreateDescriptor("TUnit0044", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.MustHavePropertySetter, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor TooManyDataAttributes = - CreateDescriptor("TUnit0045", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.TooManyDataAttributes, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor ReturnFunc = - CreateDescriptor("TUnit0046", UsageCategory, DiagnosticSeverity.Warning); + CreateDescriptor(DiagnosticIds.ReturnFunc, UsageCategory, DiagnosticSeverity.Warning); public static readonly DiagnosticDescriptor AsyncLocalCallFlowValues = - CreateDescriptor("TUnit0047", UsageCategory, DiagnosticSeverity.Warning); + CreateDescriptor(DiagnosticIds.AsyncLocalCallFlowValues, UsageCategory, DiagnosticSeverity.Warning); public static DiagnosticDescriptor InstanceTestMethod = - CreateDescriptor("TUnit0048", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.InstanceTestMethod, UsageCategory, DiagnosticSeverity.Error); public static DiagnosticDescriptor MatrixDataSourceAttributeRequired = - CreateDescriptor("TUnit0049", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.MatrixDataSourceAttributeRequired, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor TooManyArguments = - CreateDescriptor("TUnit0050", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.TooManyArguments, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor TypeMustBePublic = - CreateDescriptor("TUnit0051", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.TypeMustBePublic, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor MultipleConstructorsWithoutTestConstructor = - CreateDescriptor("TUnit0052", UsageCategory, DiagnosticSeverity.Warning); + CreateDescriptor(DiagnosticIds.MultipleConstructorsWithoutTestConstructor, UsageCategory, DiagnosticSeverity.Warning); public static readonly DiagnosticDescriptor XunitMigration = - CreateDescriptor("TUXU0001", UsageCategory, DiagnosticSeverity.Info); + CreateDescriptor(DiagnosticIds.XunitMigration, UsageCategory, DiagnosticSeverity.Info); public static readonly DiagnosticDescriptor NUnitMigration = - CreateDescriptor("TUNU0001", UsageCategory, DiagnosticSeverity.Info); + CreateDescriptor(DiagnosticIds.NUnitMigration, UsageCategory, DiagnosticSeverity.Info); public static readonly DiagnosticDescriptor MSTestMigration = - CreateDescriptor("TUMS0001", UsageCategory, DiagnosticSeverity.Info); + CreateDescriptor(DiagnosticIds.MSTestMigration, UsageCategory, DiagnosticSeverity.Info); public static readonly DiagnosticDescriptor OverwriteConsole = - CreateDescriptor("TUnit0055", UsageCategory, DiagnosticSeverity.Warning); + CreateDescriptor(DiagnosticIds.OverwriteConsole, UsageCategory, DiagnosticSeverity.Warning); public static readonly DiagnosticDescriptor CombinedDataSourceAttributeRequired = - CreateDescriptor("TUnit0070", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.CombinedDataSourceAttributeRequired, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor CombinedDataSourceMissingParameterDataSource = - CreateDescriptor("TUnit0071", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.CombinedDataSourceMissingParameterDataSource, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor CombinedDataSourceConflictWithMatrix = - CreateDescriptor("TUnit0072", UsageCategory, DiagnosticSeverity.Warning); + CreateDescriptor(DiagnosticIds.CombinedDataSourceConflictWithMatrix, UsageCategory, DiagnosticSeverity.Warning); public static readonly DiagnosticDescriptor InstanceMethodSource = - CreateDescriptor("TUnit0056", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.InstanceMethodSource, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor HookContextParameterOptional = - CreateDescriptor("TUnit0057", UsageCategory, DiagnosticSeverity.Info); + CreateDescriptor(DiagnosticIds.HookContextParameterOptional, UsageCategory, DiagnosticSeverity.Info); public static readonly DiagnosticDescriptor HookUnknownParameters = - CreateDescriptor("TUnit0058", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.HookUnknownParameters, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor AbstractTestClassWithDataSources = - CreateDescriptor("TUnit0059", UsageCategory, DiagnosticSeverity.Warning); + CreateDescriptor(DiagnosticIds.AbstractTestClassWithDataSources, UsageCategory, DiagnosticSeverity.Warning); public static readonly DiagnosticDescriptor PotentialEmptyDataSource = - CreateDescriptor("TUnit0060", UsageCategory, DiagnosticSeverity.Info); + CreateDescriptor(DiagnosticIds.PotentialEmptyDataSource, UsageCategory, DiagnosticSeverity.Info); public static readonly DiagnosticDescriptor NoAccessibleConstructor = - CreateDescriptor("TUnit0061", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.NoAccessibleConstructor, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor MissingPolyfillPackage = - CreateDescriptor("TUnit0073", UsageCategory, DiagnosticSeverity.Error, + CreateDescriptor(DiagnosticIds.MissingPolyfillPackage, UsageCategory, DiagnosticSeverity.Error, customTags: [WellKnownDiagnosticTags.CompilationEnd], helpLinkUri: "https://www.nuget.org/packages/Polyfill"); public static readonly DiagnosticDescriptor RedundantHookAttributeOnOverride = - CreateDescriptor("TUnit0074", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.RedundantHookAttributeOnOverride, UsageCategory, DiagnosticSeverity.Error); private static DiagnosticDescriptor CreateDescriptor(string diagnosticId, string category, DiagnosticSeverity severity, string[]? customTags = null, string? helpLinkUri = null) diff --git a/TUnit.Assertions.Analyzers.CodeFixers.Tests/CodeFixerRulesDecouplingTests.cs b/TUnit.Assertions.Analyzers.CodeFixers.Tests/CodeFixerRulesDecouplingTests.cs new file mode 100644 index 0000000000..487817ac41 --- /dev/null +++ b/TUnit.Assertions.Analyzers.CodeFixers.Tests/CodeFixerRulesDecouplingTests.cs @@ -0,0 +1,46 @@ +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +namespace TUnit.Assertions.Analyzers.CodeFixers.Tests; + +/// +/// Guards against https://github.com/thomhurst/TUnit/issues/6157. +/// +/// TUnit.Assertions.Analyzers.CodeFixers.dll ships in the version-agnostic analyzers/dotnet/cs +/// folder and resolves its TUnit.Assertions.Analyzers dependency at runtime by simple name. +/// Visual Studio cannot unload analyzer assemblies, so after a package update the new code fixers +/// can bind against a stale TUnit.Assertions.Analyzers.dll. Any IL reference to the Rules +/// type inside the eagerly-evaluated FixableDiagnosticIds then throws +/// MissingFieldException for rules the stale assembly doesn't have. Code fixers must use the +/// compile-time-baked DiagnosticIds constants instead. +/// +public class CodeFixerRulesDecouplingTests +{ + [Test] + public async Task CodeFixers_Assembly_Has_No_Reference_To_Rules_Type() + { + var assemblyPath = typeof(AwaitAssertionCodeFixProvider).Assembly.Location; + + using var stream = File.OpenRead(assemblyPath); + using var peReader = new PEReader(stream); + var metadata = peReader.GetMetadataReader(); + + var rulesReferences = new List(); + + foreach (var handle in metadata.TypeReferences) + { + var typeReference = metadata.GetTypeReference(handle); + + if (metadata.GetString(typeReference.Name) == "Rules" && + metadata.GetString(typeReference.Namespace) == "TUnit.Assertions.Analyzers") + { + rulesReferences.Add($"{metadata.GetString(typeReference.Namespace)}.{metadata.GetString(typeReference.Name)}"); + } + } + + await Assert.That(rulesReferences) + .IsEmpty() + .Because("TUnit.Assertions.Analyzers.CodeFixers must not reference TUnit.Assertions.Analyzers.Rules at runtime - " + + "use DiagnosticIds constants instead (see issue #6157)"); + } +} diff --git a/TUnit.Assertions.Analyzers.CodeFixers/AwaitAssertionCodeFixProvider.cs b/TUnit.Assertions.Analyzers.CodeFixers/AwaitAssertionCodeFixProvider.cs index 0924421d73..911041bf06 100644 --- a/TUnit.Assertions.Analyzers.CodeFixers/AwaitAssertionCodeFixProvider.cs +++ b/TUnit.Assertions.Analyzers.CodeFixers/AwaitAssertionCodeFixProvider.cs @@ -14,7 +14,7 @@ namespace TUnit.Assertions.Analyzers.CodeFixers; public class AwaitAssertionCodeFixProvider : CodeFixProvider { public sealed override ImmutableArray FixableDiagnosticIds { get; } = - ImmutableArray.Create(Rules.AwaitAssertion.Id); + ImmutableArray.Create(DiagnosticIds.AwaitAssertion); public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; diff --git a/TUnit.Assertions.Analyzers.CodeFixers/CollectionIsEqualToCodeFixProvider.cs b/TUnit.Assertions.Analyzers.CodeFixers/CollectionIsEqualToCodeFixProvider.cs index 08f29ca1ba..f64c59ad30 100644 --- a/TUnit.Assertions.Analyzers.CodeFixers/CollectionIsEqualToCodeFixProvider.cs +++ b/TUnit.Assertions.Analyzers.CodeFixers/CollectionIsEqualToCodeFixProvider.cs @@ -12,7 +12,7 @@ namespace TUnit.Assertions.Analyzers.CodeFixers; public class CollectionIsEqualToCodeFixProvider : CodeFixProvider { public sealed override ImmutableArray FixableDiagnosticIds { get; } = - ImmutableArray.Create(Rules.CollectionIsEqualToUsesReferenceEquality.Id); + ImmutableArray.Create(DiagnosticIds.CollectionIsEqualToUsesReferenceEquality); public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; diff --git a/TUnit.Assertions.Analyzers.CodeFixers/XUnitAssertionCodeFixProvider.cs b/TUnit.Assertions.Analyzers.CodeFixers/XUnitAssertionCodeFixProvider.cs index bb8e5ee8d0..0952c090f7 100644 --- a/TUnit.Assertions.Analyzers.CodeFixers/XUnitAssertionCodeFixProvider.cs +++ b/TUnit.Assertions.Analyzers.CodeFixers/XUnitAssertionCodeFixProvider.cs @@ -14,7 +14,7 @@ namespace TUnit.Assertions.Analyzers.CodeFixers; public class XUnitAssertionCodeFixProvider : CodeFixProvider { public sealed override ImmutableArray FixableDiagnosticIds { get; } = - ImmutableArray.Create(Rules.XUnitAssertion.Id); + ImmutableArray.Create(DiagnosticIds.XUnitAssertion); public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; diff --git a/TUnit.Assertions.Analyzers/DiagnosticIds.cs b/TUnit.Assertions.Analyzers/DiagnosticIds.cs new file mode 100644 index 0000000000..37f3615426 --- /dev/null +++ b/TUnit.Assertions.Analyzers/DiagnosticIds.cs @@ -0,0 +1,32 @@ +namespace TUnit.Assertions.Analyzers; + +/// +/// Diagnostic ID constants for all TUnit assertions analyzer rules. +/// +/// +/// Code fix providers (TUnit.Assertions.Analyzers.CodeFixers) MUST reference these constants instead +/// of Rules.X.Id. Constants are baked into the consuming assembly at compile time, so the +/// code fixers carry no runtime reference to the type. A runtime reference +/// can bind against a stale TUnit.Assertions.Analyzers.dll already loaded in Visual Studio +/// (analyzers cannot be unloaded), throwing for newly +/// added rules. See https://github.com/thomhurst/TUnit/issues/6157. +/// +public static class DiagnosticIds +{ + public const string MixAndOrConditionsAssertion = "TUnitAssertions0001"; + public const string AwaitAssertion = "TUnitAssertions0002"; + public const string CompilerArgumentsPopulated = "TUnitAssertions0003"; + public const string DisposableUsingMultiple = "TUnitAssertions0004"; + public const string ConstantValueInAssertThat = "TUnitAssertions0005"; + public const string ObjectEqualsBaseMethod = "TUnitAssertions0006"; + public const string DynamicValueInAssertThat = "TUnitAssertions0007"; + public const string AwaitValueTaskInAssertThat = "TUnitAssertions0008"; + public const string XUnitAssertion = "TUnitAssertions0009"; + public const string GenerateAssertionMethodMustBeStatic = "TUnitAssertions0010"; + public const string GenerateAssertionMethodMustHaveParameter = "TUnitAssertions0011"; + public const string GenerateAssertionInvalidReturnType = "TUnitAssertions0012"; + public const string GenerateAssertionShouldBeExtensionMethod = "TUnitAssertions0013"; + public const string PreferIsNullOverIsEqualToNull = "TUnitAssertions0014"; + public const string PreferIsTrueOrIsFalseOverIsEqualToBool = "TUnitAssertions0015"; + public const string CollectionIsEqualToUsesReferenceEquality = "TUnitAssertions0016"; +} diff --git a/TUnit.Assertions.Analyzers/Rules.cs b/TUnit.Assertions.Analyzers/Rules.cs index b58ef58988..56cd26480c 100644 --- a/TUnit.Assertions.Analyzers/Rules.cs +++ b/TUnit.Assertions.Analyzers/Rules.cs @@ -1,4 +1,4 @@ -using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis; namespace TUnit.Assertions.Analyzers; @@ -7,52 +7,52 @@ internal static class Rules private const string UsageCategory = "Usage"; public static readonly DiagnosticDescriptor MixAndOrConditionsAssertion = - CreateDescriptor("TUnitAssertions0001", UsageCategory, DiagnosticSeverity.Warning); + CreateDescriptor(DiagnosticIds.MixAndOrConditionsAssertion, UsageCategory, DiagnosticSeverity.Warning); public static readonly DiagnosticDescriptor AwaitAssertion = - CreateDescriptor("TUnitAssertions0002", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.AwaitAssertion, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor CompilerArgumentsPopulated = - CreateDescriptor("TUnitAssertions0003", UsageCategory, DiagnosticSeverity.Warning); + CreateDescriptor(DiagnosticIds.CompilerArgumentsPopulated, UsageCategory, DiagnosticSeverity.Warning); public static readonly DiagnosticDescriptor DisposableUsingMultiple = - CreateDescriptor("TUnitAssertions0004", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.DisposableUsingMultiple, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor ConstantValueInAssertThat = - CreateDescriptor("TUnitAssertions0005", UsageCategory, DiagnosticSeverity.Warning); + CreateDescriptor(DiagnosticIds.ConstantValueInAssertThat, UsageCategory, DiagnosticSeverity.Warning); public static readonly DiagnosticDescriptor ObjectEqualsBaseMethod = - CreateDescriptor("TUnitAssertions0006", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.ObjectEqualsBaseMethod, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor DynamicValueInAssertThat = - CreateDescriptor("TUnitAssertions0007", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.DynamicValueInAssertThat, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor AwaitValueTaskInAssertThat = - CreateDescriptor("TUnitAssertions0008", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.AwaitValueTaskInAssertThat, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor XUnitAssertion = - CreateDescriptor("TUnitAssertions0009", UsageCategory, DiagnosticSeverity.Info); + CreateDescriptor(DiagnosticIds.XUnitAssertion, UsageCategory, DiagnosticSeverity.Info); public static readonly DiagnosticDescriptor GenerateAssertionMethodMustBeStatic = - CreateDescriptor("TUnitAssertions0010", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.GenerateAssertionMethodMustBeStatic, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor GenerateAssertionMethodMustHaveParameter = - CreateDescriptor("TUnitAssertions0011", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.GenerateAssertionMethodMustHaveParameter, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor GenerateAssertionInvalidReturnType = - CreateDescriptor("TUnitAssertions0012", UsageCategory, DiagnosticSeverity.Error); + CreateDescriptor(DiagnosticIds.GenerateAssertionInvalidReturnType, UsageCategory, DiagnosticSeverity.Error); public static readonly DiagnosticDescriptor GenerateAssertionShouldBeExtensionMethod = - CreateDescriptor("TUnitAssertions0013", UsageCategory, DiagnosticSeverity.Warning); + CreateDescriptor(DiagnosticIds.GenerateAssertionShouldBeExtensionMethod, UsageCategory, DiagnosticSeverity.Warning); public static readonly DiagnosticDescriptor PreferIsNullOverIsEqualToNull = - CreateDescriptor("TUnitAssertions0014", UsageCategory, DiagnosticSeverity.Warning); + CreateDescriptor(DiagnosticIds.PreferIsNullOverIsEqualToNull, UsageCategory, DiagnosticSeverity.Warning); public static readonly DiagnosticDescriptor PreferIsTrueOrIsFalseOverIsEqualToBool = - CreateDescriptor("TUnitAssertions0015", UsageCategory, DiagnosticSeverity.Warning); + CreateDescriptor(DiagnosticIds.PreferIsTrueOrIsFalseOverIsEqualToBool, UsageCategory, DiagnosticSeverity.Warning); public static readonly DiagnosticDescriptor CollectionIsEqualToUsesReferenceEquality = - CreateDescriptor("TUnitAssertions0016", UsageCategory, DiagnosticSeverity.Info); + CreateDescriptor(DiagnosticIds.CollectionIsEqualToUsesReferenceEquality, UsageCategory, DiagnosticSeverity.Info); private static DiagnosticDescriptor CreateDescriptor(string diagnosticId, string category, DiagnosticSeverity severity) { From 1cf11e40d3daa1694f4b5f508878791be7b1c755 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:15:09 +0100 Subject: [PATCH 2/3] refactor: consolidate IL decoupling guard, cover AspNetCore codefixer, 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 --- SharedTestHelpers/RulesDecouplingVerifier.cs | 61 +++++++++++++++++++ .../Base/BaseMigrationCodeFixProvider.cs | 6 +- .../CodeFixerRulesDecouplingTests.cs | 34 ++--------- .../TUnit.Analyzers.Tests.csproj | 2 + TUnit.Analyzers/DiagnosticIds.cs | 13 ++-- TUnit.Analyzers/Rules.cs | 4 +- ...estWebApplicationFactoryCodeFixProvider.cs | 2 +- .../CodeFixerRulesDecouplingTests.cs | 23 +++++++ .../TUnit.AspNetCore.Analyzers.Tests.csproj | 2 + TUnit.AspNetCore.Analyzers/DiagnosticIds.cs | 16 +++++ TUnit.AspNetCore.Analyzers/Rules.cs | 6 +- .../CodeFixerRulesDecouplingTests.cs | 34 ++--------- ...sertions.Analyzers.CodeFixers.Tests.csproj | 2 + TUnit.Assertions.Analyzers/DiagnosticIds.cs | 13 ++-- 14 files changed, 134 insertions(+), 84 deletions(-) create mode 100644 SharedTestHelpers/RulesDecouplingVerifier.cs create mode 100644 TUnit.AspNetCore.Analyzers.Tests/CodeFixerRulesDecouplingTests.cs create mode 100644 TUnit.AspNetCore.Analyzers/DiagnosticIds.cs diff --git a/SharedTestHelpers/RulesDecouplingVerifier.cs b/SharedTestHelpers/RulesDecouplingVerifier.cs new file mode 100644 index 0000000000..ceb00488b1 --- /dev/null +++ b/SharedTestHelpers/RulesDecouplingVerifier.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +namespace TUnit.Tests.Shared; + +/// +/// Verifies that a code fixer assembly carries no IL reference to its analyzer project's +/// Rules type. Guards against https://github.com/thomhurst/TUnit/issues/6157. +/// +/// +/// Code fixer assemblies ship in the version-agnostic analyzers/dotnet/cs folder while the +/// analyzer assemblies ship per-Roslyn (analyzers/dotnet/roslyn4.x/cs), and the dependency +/// resolves at runtime by simple name. Visual Studio cannot unload analyzer assemblies, so after a +/// package update (or with mixed TUnit versions in one VS session) a new code fixer can bind +/// against a stale analyzer assembly. Any IL reference to the Rules type — e.g. +/// Rules.X.Id inside the eagerly-evaluated FixableDiagnosticIds — then throws +/// for rules the stale assembly doesn't have. Code +/// fixers must use the compile-time-baked DiagnosticIds constants instead, which this +/// helper enforces at the IL level: a TypeReference to Rules appears for any usage +/// (field access, typeof, method call), so an empty result proves full decoupling. +/// DiagnosticIds itself is also scanned — its members must stay const; changing one +/// to static readonly would silently reintroduce a runtime type reference, which surfaces +/// here as a TypeReference to DiagnosticIds. +/// +/// Linked into each code fixer test project via +/// <Compile Include="..\SharedTestHelpers\RulesDecouplingVerifier.cs">. +/// +/// +internal static class RulesDecouplingVerifier +{ + /// + /// Returns the fully-qualified names of all Rules or DiagnosticIds type + /// references in whose namespace is + /// . An empty list means the assembly is fully decoupled. + /// + public static List FindRulesTypeReferences(Assembly codeFixersAssembly, string rulesNamespace) + { + using var stream = File.OpenRead(codeFixersAssembly.Location); + using var peReader = new PEReader(stream); + var metadata = peReader.GetMetadataReader(); + + var rulesReferences = new List(); + + foreach (var handle in metadata.TypeReferences) + { + var typeReference = metadata.GetTypeReference(handle); + var name = metadata.GetString(typeReference.Name); + var typeNamespace = metadata.GetString(typeReference.Namespace); + + if (name is "Rules" or "DiagnosticIds" && typeNamespace == rulesNamespace) + { + rulesReferences.Add($"{typeNamespace}.{name}"); + } + } + + return rulesReferences; + } +} diff --git a/TUnit.Analyzers.CodeFixers/Base/BaseMigrationCodeFixProvider.cs b/TUnit.Analyzers.CodeFixers/Base/BaseMigrationCodeFixProvider.cs index 7dc7bc8b9c..44d36a0600 100644 --- a/TUnit.Analyzers.CodeFixers/Base/BaseMigrationCodeFixProvider.cs +++ b/TUnit.Analyzers.CodeFixers/Base/BaseMigrationCodeFixProvider.cs @@ -15,10 +15,8 @@ public abstract class BaseMigrationCodeFixProvider : CodeFixProvider protected abstract string FrameworkName { get; } /// - /// The fixable diagnostic ID. Implementations MUST return a constant - /// (never Rules.X.Id): VS evaluates eagerly, and a runtime - /// field reference into TUnit.Analyzers can bind against a stale copy already loaded in the IDE, - /// throwing MissingFieldException. See https://github.com/thomhurst/TUnit/issues/6157. + /// The fixable diagnostic ID. Implementations MUST return a constant, + /// never Rules.X.Id — see remarks (issue #6157). /// protected abstract string DiagnosticId { get; } diff --git a/TUnit.Analyzers.Tests/CodeFixerRulesDecouplingTests.cs b/TUnit.Analyzers.Tests/CodeFixerRulesDecouplingTests.cs index fb9f6d74ac..c7cd027112 100644 --- a/TUnit.Analyzers.Tests/CodeFixerRulesDecouplingTests.cs +++ b/TUnit.Analyzers.Tests/CodeFixerRulesDecouplingTests.cs @@ -1,43 +1,19 @@ -using System.Reflection.Metadata; -using System.Reflection.PortableExecutable; using TUnit.Analyzers.CodeFixers; +using TUnit.Tests.Shared; namespace TUnit.Analyzers.Tests; /// -/// Guards against https://github.com/thomhurst/TUnit/issues/6157. -/// -/// TUnit.Analyzers.CodeFixers.dll ships in the version-agnostic analyzers/dotnet/cs folder and -/// resolves its TUnit.Analyzers dependency at runtime by simple name. Visual Studio cannot unload -/// analyzer assemblies, so after a package update (or with mixed TUnit versions in one VS session) -/// the new code fixers can bind against a stale TUnit.Analyzers.dll. Any IL reference to the -/// Rules type (e.g. Rules.MSTestMigration.Id inside the eagerly-evaluated -/// FixableDiagnosticIds) then throws MissingFieldException for rules the stale assembly -/// doesn't have. Code fixers must use the compile-time-baked DiagnosticIds constants instead. +/// Code fixers must use DiagnosticIds constants, never Rules.X — see +/// for the full rationale (issue #6157). /// public class CodeFixerRulesDecouplingTests { [Test] public async Task CodeFixers_Assembly_Has_No_Reference_To_Rules_Type() { - var assemblyPath = typeof(MSTestMigrationCodeFixProvider).Assembly.Location; - - using var stream = File.OpenRead(assemblyPath); - using var peReader = new PEReader(stream); - var metadata = peReader.GetMetadataReader(); - - var rulesReferences = new List(); - - foreach (var handle in metadata.TypeReferences) - { - var typeReference = metadata.GetTypeReference(handle); - - if (metadata.GetString(typeReference.Name) == "Rules" && - metadata.GetString(typeReference.Namespace) == "TUnit.Analyzers") - { - rulesReferences.Add($"{metadata.GetString(typeReference.Namespace)}.{metadata.GetString(typeReference.Name)}"); - } - } + var rulesReferences = RulesDecouplingVerifier.FindRulesTypeReferences( + typeof(MSTestMigrationCodeFixProvider).Assembly, "TUnit.Analyzers"); await TUnit.Assertions.Assert.That(rulesReferences) .IsEmpty() diff --git a/TUnit.Analyzers.Tests/TUnit.Analyzers.Tests.csproj b/TUnit.Analyzers.Tests/TUnit.Analyzers.Tests.csproj index 1be1335763..bd0e79df7b 100644 --- a/TUnit.Analyzers.Tests/TUnit.Analyzers.Tests.csproj +++ b/TUnit.Analyzers.Tests/TUnit.Analyzers.Tests.csproj @@ -47,6 +47,8 @@ Visible="false" /> +