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 eedfc5a6b3..44d36a0600 100644 --- a/TUnit.Analyzers.CodeFixers/Base/BaseMigrationCodeFixProvider.cs +++ b/TUnit.Analyzers.CodeFixers/Base/BaseMigrationCodeFixProvider.cs @@ -13,7 +13,13 @@ 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 — see remarks (issue #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..c7cd027112 --- /dev/null +++ b/TUnit.Analyzers.Tests/CodeFixerRulesDecouplingTests.cs @@ -0,0 +1,23 @@ +using TUnit.Analyzers.CodeFixers; +using TUnit.Tests.Shared; + +namespace TUnit.Analyzers.Tests; + +/// +/// 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 rulesReferences = RulesDecouplingVerifier.FindRulesTypeReferences( + typeof(MSTestMigrationCodeFixProvider).Assembly, "TUnit.Analyzers"); + + 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.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" /> +