From a65e5dbc220e00d924295adfe672339455a344b3 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Thu, 30 Apr 2026 10:41:06 +0200 Subject: [PATCH 1/6] Fix: Actually set the ServiceProvider in the LocalPluginContext --- XrmPluginCore.Tests/LocalPluginContextTests.cs | 4 +++- XrmPluginCore/LocalPluginContext.cs | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/XrmPluginCore.Tests/LocalPluginContextTests.cs b/XrmPluginCore.Tests/LocalPluginContextTests.cs index 47aede9..1354a78 100644 --- a/XrmPluginCore.Tests/LocalPluginContextTests.cs +++ b/XrmPluginCore.Tests/LocalPluginContextTests.cs @@ -22,8 +22,10 @@ public void ConstructorValidServiceProviderShouldInitializeCorrectly() var context = new LocalPluginContext(serviceProvider); // Assert - context.PluginExecutionContext.Should().Be(mockProvider.PluginExecutionContext); tracingService.Should().NotBeNull(); + + context.ServiceProvider.Should().Be(serviceProvider); + context.PluginExecutionContext.Should().Be(mockProvider.PluginExecutionContext); context.TracingService.Should().Be(tracingService); context.OrganizationService.Should().Be(mockProvider.OrganizationService); context.OrganizationAdminService.Should().Be(mockProvider.OrganizationAdminService); diff --git a/XrmPluginCore/LocalPluginContext.cs b/XrmPluginCore/LocalPluginContext.cs index aa9ffe7..a9349f9 100644 --- a/XrmPluginCore/LocalPluginContext.cs +++ b/XrmPluginCore/LocalPluginContext.cs @@ -23,6 +23,9 @@ public LocalPluginContext(IExtendedServiceProvider serviceProvider) throw new ArgumentNullException(nameof(serviceProvider)); } + // Store the service provider reference. + ServiceProvider = serviceProvider; + // Obtain the execution context service from the service provider. PluginExecutionContext = serviceProvider.GetService(); From de1c977e4f238fbdf500acd48c1ca58450f6c4ec Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Thu, 30 Apr 2026 10:54:09 +0200 Subject: [PATCH 2/6] Fix: Expand XPC3004 to handle RegisterStep as well --- ...ocalPluginContextAsServiceAnalyzerTests.cs | 22 +++++--- .../LocalPluginContextAsServiceAnalyzer.cs | 56 ++++++++++++++++++- .../rules/XPC3004.md | 22 +++++++- 3 files changed, 87 insertions(+), 13 deletions(-) diff --git a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/LocalPluginContextAsServiceAnalyzerTests.cs b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/LocalPluginContextAsServiceAnalyzerTests.cs index 5ee43db..efb2d07 100644 --- a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/LocalPluginContextAsServiceAnalyzerTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/LocalPluginContextAsServiceAnalyzerTests.cs @@ -14,11 +14,13 @@ namespace XrmPluginCore.SourceGenerator.Tests.DiagnosticTests; /// public class LocalPluginContextAsServiceAnalyzerTests : CodeFixTestBase { - [Fact] - public async Task Should_Report_XPC3004_When_LocalPluginContext_Explicitly_Specified() + [Theory] + [InlineData("RegisterStep")] + [InlineData("RegisterStep")] + public async Task Should_Report_XPC3004_When_LocalPluginContext_Explicitly_Specified(string registerStep) { // Arrange - const string pluginSource = """ + string pluginSource = $$""" using XrmPluginCore; using XrmPluginCore.Enums; @@ -31,7 +33,7 @@ public class TestPlugin : Plugin { public TestPlugin() { - RegisterStep( + ${{registerStep}}( EventOperation.Update, ExecutionStage.PostOperation, Execute); @@ -174,11 +176,13 @@ protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceColle diagnostics.Should().NotContain(d => d.Id == "XPC3004"); } - [Fact] - public async Task CodeFix_Should_Rewrite_To_RegisterPluginStep() + [Theory] + [InlineData("RegisterStep")] + [InlineData("RegisterStep")] + public async Task CodeFix_Should_Rewrite_To_RegisterPluginStep(string registerStep) { // Arrange - const string pluginSource = """ + string pluginSource = $$""" using XrmPluginCore; using XrmPluginCore.Enums; @@ -191,7 +195,7 @@ public class TestPlugin : Plugin { public TestPlugin() { - RegisterStep( + ${{registerStep}}( EventOperation.Update, ExecutionStage.PostOperation, Execute); @@ -216,7 +220,7 @@ protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceColle // Assert fixedSource.Should().Contain("RegisterPluginStep"); - fixedSource.Should().NotContain("RegisterStep"); + fixedSource.Should().NotContain(registerStep); } [Fact] diff --git a/XrmPluginCore.SourceGenerator/Analyzers/LocalPluginContextAsServiceAnalyzer.cs b/XrmPluginCore.SourceGenerator/Analyzers/LocalPluginContextAsServiceAnalyzer.cs index c5b4c7f..ff57f63 100644 --- a/XrmPluginCore.SourceGenerator/Analyzers/LocalPluginContextAsServiceAnalyzer.cs +++ b/XrmPluginCore.SourceGenerator/Analyzers/LocalPluginContextAsServiceAnalyzer.cs @@ -3,6 +3,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; using System.Collections.Immutable; +using System.Linq; using XrmPluginCore.SourceGenerator.Helpers; namespace XrmPluginCore.SourceGenerator.Analyzers; @@ -35,12 +36,23 @@ private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) return; } - // Only fire for exactly 2 type args: RegisterStep - if (genericName.TypeArgumentList.Arguments.Count != 2) + var typeArgCount = genericName.TypeArgumentList.Arguments.Count; + + if (typeArgCount == 2) { - return; + CheckExplicitLocalPluginContextTypeArg(context, invocation, genericName); + } + else if (typeArgCount == 1) + { + CheckImplicitLocalPluginContextMethodGroup(context, invocation, genericName); } + } + private static void CheckExplicitLocalPluginContextTypeArg( + SyntaxNodeAnalysisContext context, + InvocationExpressionSyntax invocation, + GenericNameSyntax genericName) + { // Use semantic model to check full type name (avoids false positives on user-defined LocalPluginContext) var serviceTypeArg = genericName.TypeArgumentList.Arguments[1]; var typeInfo = context.SemanticModel.GetTypeInfo(serviceTypeArg); @@ -56,4 +68,42 @@ private void AnalyzeInvocation(SyntaxNodeAnalysisContext context) invocation.GetLocation(), entityTypeName)); } + + private static void CheckImplicitLocalPluginContextMethodGroup( + SyntaxNodeAnalysisContext context, + InvocationExpressionSyntax invocation, + GenericNameSyntax genericName) + { + var arguments = invocation.ArgumentList.Arguments; + if (arguments.Count < 3) + { + return; + } + + var actionArg = arguments[2].Expression; + + // Only fire for method groups, not lambdas + if (actionArg is LambdaExpressionSyntax) + { + return; + } + + // Get all methods in the method group and check if any take LocalPluginContext + var memberGroup = context.SemanticModel.GetMemberGroup(actionArg); + var hasLocalPluginContextParam = memberGroup + .OfType() + .Any(m => m.Parameters.Length == 1 + && m.Parameters[0].Type.ToDisplayString() == LocalPluginContextFullName); + + if (!hasLocalPluginContextParam) + { + return; + } + + var entityTypeName = genericName.TypeArgumentList.Arguments[0].ToString(); + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.LocalPluginContextAsService, + invocation.GetLocation(), + entityTypeName)); + } } diff --git a/XrmPluginCore.SourceGenerator/rules/XPC3004.md b/XrmPluginCore.SourceGenerator/rules/XPC3004.md index 1c4188d..8f0b7a2 100644 --- a/XrmPluginCore.SourceGenerator/rules/XPC3004.md +++ b/XrmPluginCore.SourceGenerator/rules/XPC3004.md @@ -10,7 +10,7 @@ This rule reports when `LocalPluginContext` is used as the `TService` type argum This typically happens when migrating from the legacy `RegisterPluginStep` API and mistakenly using `RegisterStep` instead of the correct DI-based approach. -## ❌ Example of violation +## ❌ Example of violation (explicit type argument) ```csharp public class ContactPlugin : Plugin @@ -28,6 +28,26 @@ public class ContactPlugin : Plugin } ``` +## ❌ Example of violation (implicit — method group with LocalPluginContext parameter) + +```csharp +public class ContactPlugin : Plugin +{ + public ContactPlugin() + { + // XPC3004: Execute takes LocalPluginContext, which is not in DI + RegisterStep( + EventOperation.Update, + ExecutionStage.PostOperation, + Execute); + } + + private void Execute(LocalPluginContext context) { } +} +``` + +This form is also detected because passing a method group whose parameter is `LocalPluginContext` has the same runtime failure: the framework will attempt to construct a `LocalPluginContext` through DI, which throws `InvalidOperationException`. + ## ✅ How to fix (interim — keep LocalPluginContext logic) Use `RegisterPluginStep` which correctly wraps the `LocalPluginContext`: From 341bed6e0ebd1009919e6dcea5a1033a2c1f4c81 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Thu, 30 Apr 2026 11:06:42 +0200 Subject: [PATCH 3/6] Fix: Interpolation and test naming --- .../LocalPluginContextAsServiceAnalyzerTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/LocalPluginContextAsServiceAnalyzerTests.cs b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/LocalPluginContextAsServiceAnalyzerTests.cs index efb2d07..20ca9b4 100644 --- a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/LocalPluginContextAsServiceAnalyzerTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/LocalPluginContextAsServiceAnalyzerTests.cs @@ -17,7 +17,7 @@ public class LocalPluginContextAsServiceAnalyzerTests : CodeFixTestBase [Theory] [InlineData("RegisterStep")] [InlineData("RegisterStep")] - public async Task Should_Report_XPC3004_When_LocalPluginContext_Explicitly_Specified(string registerStep) + public async Task Should_Report_XPC3004_When_LocalPluginContext_Specified(string registerStep) { // Arrange string pluginSource = $$""" @@ -33,7 +33,7 @@ public class TestPlugin : Plugin { public TestPlugin() { - ${{registerStep}}( + {{registerStep}}( EventOperation.Update, ExecutionStage.PostOperation, Execute); @@ -195,7 +195,7 @@ public class TestPlugin : Plugin { public TestPlugin() { - ${{registerStep}}( + {{registerStep}}( EventOperation.Update, ExecutionStage.PostOperation, Execute); From 2a768d2432ed4540b0678157f6af049deda04799 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Thu, 30 Apr 2026 11:22:10 +0200 Subject: [PATCH 4/6] Fix: Also check for syntax where the LocalPluginContext is passed as a lambda function --- ...ocalPluginContextAsServiceAnalyzerTests.cs | 12 +++--- .../LocalPluginContextAsServiceAnalyzer.cs | 42 ++++++++++++------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/LocalPluginContextAsServiceAnalyzerTests.cs b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/LocalPluginContextAsServiceAnalyzerTests.cs index 20ca9b4..aa6bbb6 100644 --- a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/LocalPluginContextAsServiceAnalyzerTests.cs +++ b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/LocalPluginContextAsServiceAnalyzerTests.cs @@ -58,11 +58,13 @@ protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceColle diagnostic.GetMessage().Should().Contain("LocalPluginContext"); } - [Fact] - public async Task Should_Report_XPC3004_When_LocalPluginContext_Used_As_TService_With_Lambda() + [Theory] + [InlineData("RegisterStep", "ctx => ctx.TracingService.Trace(\"hello\")")] + [InlineData("RegisterStep", "(LocalPluginContext ctx) => ctx.TracingService.Trace(\"hello\")")] + public async Task Should_Report_XPC3004_When_LocalPluginContext_Used_As_TService_With_Lambda(string registerStep, string lambda) { // Arrange - const string pluginSource = """ + string pluginSource = $$""" using XrmPluginCore; using XrmPluginCore.Enums; @@ -75,10 +77,10 @@ public class TestPlugin : Plugin { public TestPlugin() { - RegisterStep( + {{registerStep}}( EventOperation.Update, ExecutionStage.PostOperation, - ctx => ctx.TracingService.Trace("hello")); + {{lambda}}); } protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services) diff --git a/XrmPluginCore.SourceGenerator/Analyzers/LocalPluginContextAsServiceAnalyzer.cs b/XrmPluginCore.SourceGenerator/Analyzers/LocalPluginContextAsServiceAnalyzer.cs index ff57f63..379c9f9 100644 --- a/XrmPluginCore.SourceGenerator/Analyzers/LocalPluginContextAsServiceAnalyzer.cs +++ b/XrmPluginCore.SourceGenerator/Analyzers/LocalPluginContextAsServiceAnalyzer.cs @@ -82,20 +82,14 @@ private static void CheckImplicitLocalPluginContextMethodGroup( var actionArg = arguments[2].Expression; - // Only fire for method groups, not lambdas - if (actionArg is LambdaExpressionSyntax) - { - return; - } - - // Get all methods in the method group and check if any take LocalPluginContext - var memberGroup = context.SemanticModel.GetMemberGroup(actionArg); - var hasLocalPluginContextParam = memberGroup - .OfType() - .Any(m => m.Parameters.Length == 1 - && m.Parameters[0].Type.ToDisplayString() == LocalPluginContextFullName); - - if (!hasLocalPluginContextParam) + bool isLocalPluginContextUsage = actionArg is LambdaExpressionSyntax lambda + ? LambdaHasExplicitLocalPluginContextParameter(context, lambda) + : context.SemanticModel.GetMemberGroup(actionArg) + .OfType() + .Any(m => m.Parameters.Length == 1 + && m.Parameters[0].Type.ToDisplayString() == LocalPluginContextFullName); + + if (!isLocalPluginContextUsage) { return; } @@ -106,4 +100,24 @@ private static void CheckImplicitLocalPluginContextMethodGroup( invocation.GetLocation(), entityTypeName)); } + + private static bool LambdaHasExplicitLocalPluginContextParameter( + SyntaxNodeAnalysisContext context, + LambdaExpressionSyntax lambda) + { + // Only parenthesized lambdas can have explicit parameter types: (LocalPluginContext ctx) => ... + if (lambda is not ParenthesizedLambdaExpressionSyntax { ParameterList.Parameters.Count: 1 } paren) + { + return false; + } + + var paramTypeSyntax = paren.ParameterList.Parameters[0].Type; + if (paramTypeSyntax == null) + { + return false; + } + + var typeInfo = context.SemanticModel.GetTypeInfo(paramTypeSyntax); + return typeInfo.Type?.ToDisplayString() == LocalPluginContextFullName; + } } From 8fc449ae678d179c2b53a343b80496b078ecc923 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Thu, 30 Apr 2026 12:15:23 +0200 Subject: [PATCH 5/6] Fix: Update XPC documentation --- .../LocalPluginContextAsServiceAnalyzer.cs | 28 ++++++++++++++++--- .../rules/XPC3004.md | 4 +-- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/XrmPluginCore.SourceGenerator/Analyzers/LocalPluginContextAsServiceAnalyzer.cs b/XrmPluginCore.SourceGenerator/Analyzers/LocalPluginContextAsServiceAnalyzer.cs index 379c9f9..67c3e80 100644 --- a/XrmPluginCore.SourceGenerator/Analyzers/LocalPluginContextAsServiceAnalyzer.cs +++ b/XrmPluginCore.SourceGenerator/Analyzers/LocalPluginContextAsServiceAnalyzer.cs @@ -84,10 +84,7 @@ private static void CheckImplicitLocalPluginContextMethodGroup( bool isLocalPluginContextUsage = actionArg is LambdaExpressionSyntax lambda ? LambdaHasExplicitLocalPluginContextParameter(context, lambda) - : context.SemanticModel.GetMemberGroup(actionArg) - .OfType() - .Any(m => m.Parameters.Length == 1 - && m.Parameters[0].Type.ToDisplayString() == LocalPluginContextFullName); + : MethodGroupUsesLocalPluginContext(context, actionArg); if (!isLocalPluginContextUsage) { @@ -101,6 +98,29 @@ private static void CheckImplicitLocalPluginContextMethodGroup( entityTypeName)); } + private static bool MethodGroupUsesLocalPluginContext( + SyntaxNodeAnalysisContext context, + ExpressionSyntax actionArg) + { + // We intentionally use GetMemberGroup rather than GetSymbolInfo.Symbol here. + // + // RegisterStep takes Action. Plugin also inherits + // IPlugin.Execute(IServiceProvider), so the method group "Execute" always contains at least + // two candidates. Because IExtendedServiceProvider : IServiceProvider, the inherited + // Execute(IServiceProvider) satisfies the delegate via contravariance — GetSymbolInfo.Symbol + // resolves to that method, not to the user's Execute(LocalPluginContext). Using Symbol as + // the primary check would therefore suppress the diagnostic precisely in the cases where it + // is most needed: the user defined Execute(LocalPluginContext) intending to use it, but the + // compiler silently picked the base-class method instead. + // + // GetMemberGroup returns the full candidate set regardless of conversion success, so it + // correctly detects the LocalPluginContext overload even in this inherited-method scenario. + return context.SemanticModel.GetMemberGroup(actionArg) + .OfType() + .Any(m => m.Parameters.Length == 1 + && m.Parameters[0].Type.ToDisplayString() == LocalPluginContextFullName); + } + private static bool LambdaHasExplicitLocalPluginContextParameter( SyntaxNodeAnalysisContext context, LambdaExpressionSyntax lambda) diff --git a/XrmPluginCore.SourceGenerator/rules/XPC3004.md b/XrmPluginCore.SourceGenerator/rules/XPC3004.md index 8f0b7a2..871b77c 100644 --- a/XrmPluginCore.SourceGenerator/rules/XPC3004.md +++ b/XrmPluginCore.SourceGenerator/rules/XPC3004.md @@ -35,7 +35,7 @@ public class ContactPlugin : Plugin { public ContactPlugin() { - // XPC3004: Execute takes LocalPluginContext, which is not in DI + // XPC3004: Execute(LocalPluginContext) cannot be converted to Action RegisterStep( EventOperation.Update, ExecutionStage.PostOperation, @@ -46,7 +46,7 @@ public class ContactPlugin : Plugin } ``` -This form is also detected because passing a method group whose parameter is `LocalPluginContext` has the same runtime failure: the framework will attempt to construct a `LocalPluginContext` through DI, which throws `InvalidOperationException`. +This form is also detected because `RegisterStep` expects `Action`, and a method group or lambda whose parameter is `LocalPluginContext` is not assignable to that delegate type. The code fails to compile with a delegate mismatch error. Use `RegisterPluginStep` if you need to keep `LocalPluginContext` as the entry point. ## ✅ How to fix (interim — keep LocalPluginContext logic) From 988b99fdae29a79d0dae12a549f3d2a0e2113c87 Mon Sep 17 00:00:00 2001 From: Morten Holt Date: Thu, 30 Apr 2026 12:20:55 +0200 Subject: [PATCH 6/6] CHORE: Update changelog --- XrmPluginCore/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/XrmPluginCore/CHANGELOG.md b/XrmPluginCore/CHANGELOG.md index 72741ec..5af4004 100644 --- a/XrmPluginCore/CHANGELOG.md +++ b/XrmPluginCore/CHANGELOG.md @@ -1,3 +1,7 @@ +### 1.2.8 - 30 April 2026 +* Fix: Set ServiceProvider property on LocalPluginContext +* Fix: XPC3004: Detect and report usage of LocalPluginContext when implicitly passed + ### v1.2.7 - 22 April 2026 * Add: Ability to generate Pre and Post images with all attributes * Add: Error XPC3004: Do not use LocalPluginContext as TService in RegisterStep