From 187a7c77daff4ba16fa5ddf33fa04e6080a50106 Mon Sep 17 00:00:00 2001 From: Richard Murillo <6811113+rjmurillo@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:05:38 -0600 Subject: [PATCH 1/5] refactor: convert NoMethodsInPropertySetupAnalyzer to IOperation Replace RegisterSyntaxNodeAction with RegisterCompilationStartAction and RegisterOperationAction using IInvocationOperation. Use MoqKnownSymbols for symbol-based detection of SetupGet, SetupSet, and SetupProperty methods instead of string-based matching. Add Mock1SetupGet, Mock1SetupSet, and Mock1SetupProperty properties to MoqKnownSymbols for resolving property setup method symbols. Fixes #337 Co-Authored-By: Claude Opus 4.6 --- .../NoMethodsInPropertySetupAnalyzer.cs | 68 ++++++++++++++++--- src/Common/WellKnown/MoqKnownSymbols.cs | 26 +++++++ 2 files changed, 83 insertions(+), 11 deletions(-) diff --git a/src/Analyzers/NoMethodsInPropertySetupAnalyzer.cs b/src/Analyzers/NoMethodsInPropertySetupAnalyzer.cs index 17f02afa9..13b3d8594 100644 --- a/src/Analyzers/NoMethodsInPropertySetupAnalyzer.cs +++ b/src/Analyzers/NoMethodsInPropertySetupAnalyzer.cs @@ -1,3 +1,6 @@ +using System.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + namespace Moq.Analyzers; /// @@ -28,23 +31,66 @@ public override void Initialize(AnalysisContext context) { context.EnableConcurrentExecution(); context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); - context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.InvocationExpression); + + context.RegisterCompilationStartAction(RegisterCompilationStartAction); + } + + private static void RegisterCompilationStartAction(CompilationStartAnalysisContext context) + { + MoqKnownSymbols knownSymbols = new(context.Compilation); + + if (!knownSymbols.IsMockReferenced()) + { + return; + } + + ImmutableArray propertySetupMethods = ImmutableArray.CreateRange([ + ..knownSymbols.Mock1SetupGet, + ..knownSymbols.Mock1SetupSet, + ..knownSymbols.Mock1SetupProperty]); + + if (propertySetupMethods.IsEmpty) + { + return; + } + + context.RegisterOperationAction( + operationAnalysisContext => Analyze(operationAnalysisContext, propertySetupMethods), + OperationKind.Invocation); } - private static void Analyze(SyntaxNodeAnalysisContext context) + private static void Analyze(OperationAnalysisContext context, ImmutableArray propertySetupMethods) { - InvocationExpressionSyntax setupGetOrSetInvocation = (InvocationExpressionSyntax)context.Node; + Debug.Assert(context.Operation is IInvocationOperation, "Expected IInvocationOperation"); + + if (context.Operation is not IInvocationOperation invocationOperation) + { + return; + } + + IMethodSymbol targetMethod = invocationOperation.TargetMethod; + if (!targetMethod.IsInstanceOf(propertySetupMethods)) + { + return; + } + + // The lambda argument to SetupGet/SetupSet/SetupProperty contains the mocked member access. + // If the lambda body is an invocation (method call), that is invalid for property setup. + InvocationExpressionSyntax? mockedMethodCall = + (invocationOperation.Syntax as InvocationExpressionSyntax).FindMockedMethodInvocationFromSetupMethod(); - if (setupGetOrSetInvocation.Expression is not MemberAccessExpressionSyntax setupGetOrSetMethod) return; - if (!string.Equals(setupGetOrSetMethod.Name.ToFullString(), "SetupGet", StringComparison.Ordinal) - && !string.Equals(setupGetOrSetMethod.Name.ToFullString(), "SetupSet", StringComparison.Ordinal) - && !string.Equals(setupGetOrSetMethod.Name.ToFullString(), "SetupProperty", StringComparison.Ordinal)) return; + if (mockedMethodCall == null) + { + return; + } - InvocationExpressionSyntax? mockedMethodCall = setupGetOrSetInvocation.FindMockedMethodInvocationFromSetupMethod(); - if (mockedMethodCall == null) return; + SemanticModel semanticModel = invocationOperation.SemanticModel!; + ISymbol? mockedMethodSymbol = semanticModel.GetSymbolInfo(mockedMethodCall, context.CancellationToken).Symbol; - ISymbol? mockedMethodSymbol = context.SemanticModel.GetSymbolInfo(mockedMethodCall, context.CancellationToken).Symbol; - if (mockedMethodSymbol == null) return; + if (mockedMethodSymbol == null) + { + return; + } Diagnostic diagnostic = mockedMethodCall.CreateDiagnostic(Rule, mockedMethodSymbol.Name); context.ReportDiagnostic(diagnostic); diff --git a/src/Common/WellKnown/MoqKnownSymbols.cs b/src/Common/WellKnown/MoqKnownSymbols.cs index c5d24e8da..4aa779baf 100644 --- a/src/Common/WellKnown/MoqKnownSymbols.cs +++ b/src/Common/WellKnown/MoqKnownSymbols.cs @@ -50,6 +50,21 @@ internal MoqKnownSymbols(Compilation compilation) /// internal ImmutableArray Mock1Setup => Mock1?.GetMembers("Setup").OfType().ToImmutableArray() ?? ImmutableArray.Empty; + /// + /// Gets the methods for Moq.Mock{T}.SetupGet. + /// + internal ImmutableArray Mock1SetupGet => Mock1?.GetMembers("SetupGet").OfType().ToImmutableArray() ?? ImmutableArray.Empty; + + /// + /// Gets the methods for Moq.Mock{T}.SetupSet. + /// + internal ImmutableArray Mock1SetupSet => Mock1?.GetMembers("SetupSet").OfType().ToImmutableArray() ?? ImmutableArray.Empty; + + /// + /// Gets the methods for Moq.Mock{T}.SetupProperty. + /// + internal ImmutableArray Mock1SetupProperty => Mock1?.GetMembers("SetupProperty").OfType().ToImmutableArray() ?? ImmutableArray.Empty; + /// /// Gets the methods for Moq.Mock{T}.SetupAdd. /// @@ -403,4 +418,15 @@ internal MoqKnownSymbols(Compilation compilation) /// Gets the methods for Moq.Times.Exactly. /// internal ImmutableArray TimesExactly => Times?.GetMembers("Exactly").OfType().ToImmutableArray() ?? ImmutableArray.Empty; + + /// + /// Gets the interface Microsoft.Extensions.Logging.ILogger. + /// + internal INamedTypeSymbol? ILogger => TypeProvider.GetOrCreateTypeByMetadataName("Microsoft.Extensions.Logging.ILogger"); + + /// + /// Gets the interface Microsoft.Extensions.Logging.ILogger{T}. + /// + internal INamedTypeSymbol? ILogger1 => TypeProvider.GetOrCreateTypeByMetadataName("Microsoft.Extensions.Logging.ILogger`1"); + } From db780203f136f6e98bcbfa81e31af1e8bf7f27eb Mon Sep 17 00:00:00 2001 From: Richard Murillo <6811113+rjmurillo@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:07:32 -0600 Subject: [PATCH 2/5] fix: handle nullable SemanticModel defensively Replace null-forgiving operator on SemanticModel with explicit null check, matching the established pattern used by all other IOperation analyzers in the codebase. Co-Authored-By: Claude Opus 4.6 --- src/Analyzers/NoMethodsInPropertySetupAnalyzer.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Analyzers/NoMethodsInPropertySetupAnalyzer.cs b/src/Analyzers/NoMethodsInPropertySetupAnalyzer.cs index 13b3d8594..8226da758 100644 --- a/src/Analyzers/NoMethodsInPropertySetupAnalyzer.cs +++ b/src/Analyzers/NoMethodsInPropertySetupAnalyzer.cs @@ -84,9 +84,13 @@ private static void Analyze(OperationAnalysisContext context, ImmutableArray Date: Sun, 1 Mar 2026 14:08:37 -0600 Subject: [PATCH 3/5] fix: remove unrelated ILogger symbols from MoqKnownSymbols Remove ILogger and ILogger1 properties that were accidentally included. These belong to a separate feature branch and should not be part of this refactoring. Co-Authored-By: Claude Opus 4.6 --- src/Common/WellKnown/MoqKnownSymbols.cs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/Common/WellKnown/MoqKnownSymbols.cs b/src/Common/WellKnown/MoqKnownSymbols.cs index 4aa779baf..edfb71f79 100644 --- a/src/Common/WellKnown/MoqKnownSymbols.cs +++ b/src/Common/WellKnown/MoqKnownSymbols.cs @@ -418,15 +418,4 @@ internal MoqKnownSymbols(Compilation compilation) /// Gets the methods for Moq.Times.Exactly. /// internal ImmutableArray TimesExactly => Times?.GetMembers("Exactly").OfType().ToImmutableArray() ?? ImmutableArray.Empty; - - /// - /// Gets the interface Microsoft.Extensions.Logging.ILogger. - /// - internal INamedTypeSymbol? ILogger => TypeProvider.GetOrCreateTypeByMetadataName("Microsoft.Extensions.Logging.ILogger"); - - /// - /// Gets the interface Microsoft.Extensions.Logging.ILogger{T}. - /// - internal INamedTypeSymbol? ILogger1 => TypeProvider.GetOrCreateTypeByMetadataName("Microsoft.Extensions.Logging.ILogger`1"); - } From 56866d6072fbe34e02de0aa9e81bf74812f30826 Mon Sep 17 00:00:00 2001 From: Richard Murillo <6811113+rjmurillo@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:04:16 -0600 Subject: [PATCH 4/5] test: add coverage for Moq-not-referenced guard branch Add Net80 reference assembly group (without Moq) to ReferenceAssemblyCatalog. Add ShouldNotAnalyzeWhenMoqNotReferenced test to exercise the IsMockReferenced() early exit in RegisterCompilationStartAction, covering the guard branches that were causing Codacy diff coverage to fall below the 95% threshold. Co-Authored-By: Claude Opus 4.6 --- .../Helpers/ReferenceAssemblyCatalog.cs | 9 +++++++ .../NoMethodsInPropertySetupAnalyzerTests.cs | 25 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/tests/Moq.Analyzers.Test/Helpers/ReferenceAssemblyCatalog.cs b/tests/Moq.Analyzers.Test/Helpers/ReferenceAssemblyCatalog.cs index 3d6af60ca..c7f74a2d8 100644 --- a/tests/Moq.Analyzers.Test/Helpers/ReferenceAssemblyCatalog.cs +++ b/tests/Moq.Analyzers.Test/Helpers/ReferenceAssemblyCatalog.cs @@ -14,6 +14,12 @@ namespace Moq.Analyzers.Test.Helpers; /// public static class ReferenceAssemblyCatalog { + /// + /// Gets the name of the reference assembly group for .NET 8.0 without Moq. + /// Used to test analyzer behavior when Moq is not referenced. + /// + public static string Net80 => nameof(Net80); + /// /// Gets the name of the reference assembly group for .NET 8.0 with an older version of Moq (4.8.2). /// @@ -32,6 +38,9 @@ public static class ReferenceAssemblyCatalog /// public static IReadOnlyDictionary Catalog { get; } = new Dictionary(StringComparer.Ordinal) { + // .NET 8.0 without Moq, used to verify analyzers short-circuit when Moq is not referenced. + { nameof(Net80), ReferenceAssemblies.Net.Net80 }, + // 4.8.2 was one of the first popular versions of Moq. Ensure this version is prior to 4.13.1, as it changed the internal // implementation of `.As()` (see https://github.com/devlooped/moq/commit/b552aeddd82090ee0f4743a1ab70a16f3e6d2d11). { nameof(Net80WithOldMoq), ReferenceAssemblies.Net.Net80.AddPackages([new PackageIdentity("Moq", "4.8.2")]) }, diff --git a/tests/Moq.Analyzers.Test/NoMethodsInPropertySetupAnalyzerTests.cs b/tests/Moq.Analyzers.Test/NoMethodsInPropertySetupAnalyzerTests.cs index ada8e1967..0ae2a0136 100644 --- a/tests/Moq.Analyzers.Test/NoMethodsInPropertySetupAnalyzerTests.cs +++ b/tests/Moq.Analyzers.Test/NoMethodsInPropertySetupAnalyzerTests.cs @@ -61,6 +61,31 @@ await Verifier.VerifyAnalyzerAsync( ReferenceAssemblyCatalog.Net80WithNewMoq); } + [Fact] + public async Task ShouldNotAnalyzeWhenMoqNotReferenced() + { + await Verifier.VerifyAnalyzerAsync( + """ + namespace Test + { + public interface IFoo + { + string Prop1 { get; set; } + string Method(); + } + + public class UnitTest + { + private void Test() + { + var x = new object(); + } + } + } + """, + ReferenceAssemblyCatalog.Net80); + } + [Fact] public async Task ShouldIncludeMethodNameInDiagnosticMessage() { From 962f7b5373eade35406c8e71b7a62f7bf0a95473 Mon Sep 17 00:00:00 2001 From: Richard Murillo <6811113+rjmurillo@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:15:00 -0600 Subject: [PATCH 5/5] test: suppress compiler diagnostics for Moq-not-referenced test The test harness injects a global using Moq directive, causing CS0246 when the Net80 reference assembly (without Moq) is used. Suppress compiler diagnostics since the test only validates analyzer behavior. Co-Authored-By: Claude Opus 4.6 --- .../NoMethodsInPropertySetupAnalyzerTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/Moq.Analyzers.Test/NoMethodsInPropertySetupAnalyzerTests.cs b/tests/Moq.Analyzers.Test/NoMethodsInPropertySetupAnalyzerTests.cs index 0ae2a0136..1bee778db 100644 --- a/tests/Moq.Analyzers.Test/NoMethodsInPropertySetupAnalyzerTests.cs +++ b/tests/Moq.Analyzers.Test/NoMethodsInPropertySetupAnalyzerTests.cs @@ -1,3 +1,5 @@ +using Microsoft.CodeAnalysis.Testing; + using Verifier = Moq.Analyzers.Test.Helpers.AnalyzerVerifier; namespace Moq.Analyzers.Test; @@ -83,7 +85,8 @@ private void Test() } } """, - ReferenceAssemblyCatalog.Net80); + ReferenceAssemblyCatalog.Net80, + CompilerDiagnostics.None); } [Fact]