Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ namespace XrmPluginCore.SourceGenerator.Tests.DiagnosticTests;
/// </summary>
public class LocalPluginContextAsServiceAnalyzerTests : CodeFixTestBase
{
[Fact]
public async Task Should_Report_XPC3004_When_LocalPluginContext_Explicitly_Specified()
[Theory]
[InlineData("RegisterStep<Contact, LocalPluginContext>")]
[InlineData("RegisterStep<Contact>")]
public async Task Should_Report_XPC3004_When_LocalPluginContext_Specified(string registerStep)
{
// Arrange
const string pluginSource = """
string pluginSource = $$"""

using XrmPluginCore;
using XrmPluginCore.Enums;
Expand All @@ -31,7 +33,7 @@ public class TestPlugin : Plugin
{
public TestPlugin()
{
RegisterStep<Contact, LocalPluginContext>(
{{registerStep}}(
EventOperation.Update,
ExecutionStage.PostOperation,
Execute);
Expand All @@ -56,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<Contact, LocalPluginContext>", "ctx => ctx.TracingService.Trace(\"hello\")")]
[InlineData("RegisterStep<Contact>", "(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;
Expand All @@ -73,10 +77,10 @@ public class TestPlugin : Plugin
{
public TestPlugin()
{
RegisterStep<Contact, LocalPluginContext>(
{{registerStep}}(
EventOperation.Update,
ExecutionStage.PostOperation,
ctx => ctx.TracingService.Trace("hello"));
{{lambda}});
}

protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
Expand Down Expand Up @@ -174,11 +178,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<Contact, LocalPluginContext>")]
[InlineData("RegisterStep<Contact>")]
public async Task CodeFix_Should_Rewrite_To_RegisterPluginStep(string registerStep)
{
// Arrange
const string pluginSource = """
string pluginSource = $$"""

using XrmPluginCore;
using XrmPluginCore.Enums;
Expand All @@ -191,7 +197,7 @@ public class TestPlugin : Plugin
{
public TestPlugin()
{
RegisterStep<Contact, LocalPluginContext>(
{{registerStep}}(
EventOperation.Update,
ExecutionStage.PostOperation,
Execute);
Expand All @@ -216,7 +222,7 @@ protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceColle

// Assert
fixedSource.Should().Contain("RegisterPluginStep<Contact>");
fixedSource.Should().NotContain("RegisterStep<Contact, LocalPluginContext>");
fixedSource.Should().NotContain(registerStep);
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -35,12 +36,23 @@ private void AnalyzeInvocation(SyntaxNodeAnalysisContext context)
return;
}

// Only fire for exactly 2 type args: RegisterStep<TEntity, TService>
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);
Expand All @@ -56,4 +68,76 @@ 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;

bool isLocalPluginContextUsage = actionArg is LambdaExpressionSyntax lambda
? LambdaHasExplicitLocalPluginContextParameter(context, lambda)
: MethodGroupUsesLocalPluginContext(context, actionArg);

Comment thread
mkholt marked this conversation as resolved.
if (!isLocalPluginContextUsage)
{
return;
}

var entityTypeName = genericName.TypeArgumentList.Arguments[0].ToString();
context.ReportDiagnostic(Diagnostic.Create(
DiagnosticDescriptors.LocalPluginContextAsService,
invocation.GetLocation(),
entityTypeName));
}

private static bool MethodGroupUsesLocalPluginContext(
SyntaxNodeAnalysisContext context,
ExpressionSyntax actionArg)
{
// We intentionally use GetMemberGroup rather than GetSymbolInfo.Symbol here.
//
// RegisterStep<TEntity> takes Action<IExtendedServiceProvider>. 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<IMethodSymbol>()
.Any(m => m.Parameters.Length == 1
&& m.Parameters[0].Type.ToDisplayString() == LocalPluginContextFullName);
}

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;
}
}
22 changes: 21 additions & 1 deletion XrmPluginCore.SourceGenerator/rules/XPC3004.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` API and mistakenly using `RegisterStep<TEntity, LocalPluginContext>` instead of the correct DI-based approach.

## ❌ Example of violation
## ❌ Example of violation (explicit type argument)

```csharp
public class ContactPlugin : Plugin
Expand All @@ -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(LocalPluginContext) cannot be converted to Action<IExtendedServiceProvider>
RegisterStep<Contact>(
EventOperation.Update,
ExecutionStage.PostOperation,
Execute);
}

private void Execute(LocalPluginContext context) { }
}
```

This form is also detected because `RegisterStep<TEntity>` expects `Action<IExtendedServiceProvider>`, 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<T>` if you need to keep `LocalPluginContext` as the entry point.

## ✅ How to fix (interim — keep LocalPluginContext logic)

Use `RegisterPluginStep<T>` which correctly wraps the `LocalPluginContext`:
Expand Down
4 changes: 3 additions & 1 deletion XrmPluginCore.Tests/LocalPluginContextTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions XrmPluginCore/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 3 additions & 0 deletions XrmPluginCore/LocalPluginContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IPluginExecutionContext>();

Expand Down
Loading