Skip to content

Commit b1ca7fb

Browse files
authored
Fix localplugincontext serviceprovider (#10)
* Fix: Actually set the ServiceProvider in the LocalPluginContext * Fix: Expand XPC3004 to handle RegisterStep<TEntity> as well
1 parent 2717ae6 commit b1ca7fb

6 files changed

Lines changed: 138 additions & 19 deletions

File tree

XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/LocalPluginContextAsServiceAnalyzerTests.cs

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ namespace XrmPluginCore.SourceGenerator.Tests.DiagnosticTests;
1414
/// </summary>
1515
public class LocalPluginContextAsServiceAnalyzerTests : CodeFixTestBase
1616
{
17-
[Fact]
18-
public async Task Should_Report_XPC3004_When_LocalPluginContext_Explicitly_Specified()
17+
[Theory]
18+
[InlineData("RegisterStep<Contact, LocalPluginContext>")]
19+
[InlineData("RegisterStep<Contact>")]
20+
public async Task Should_Report_XPC3004_When_LocalPluginContext_Specified(string registerStep)
1921
{
2022
// Arrange
21-
const string pluginSource = """
23+
string pluginSource = $$"""
2224
2325
using XrmPluginCore;
2426
using XrmPluginCore.Enums;
@@ -31,7 +33,7 @@ public class TestPlugin : Plugin
3133
{
3234
public TestPlugin()
3335
{
34-
RegisterStep<Contact, LocalPluginContext>(
36+
{{registerStep}}(
3537
EventOperation.Update,
3638
ExecutionStage.PostOperation,
3739
Execute);
@@ -56,11 +58,13 @@ protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceColle
5658
diagnostic.GetMessage().Should().Contain("LocalPluginContext");
5759
}
5860

59-
[Fact]
60-
public async Task Should_Report_XPC3004_When_LocalPluginContext_Used_As_TService_With_Lambda()
61+
[Theory]
62+
[InlineData("RegisterStep<Contact, LocalPluginContext>", "ctx => ctx.TracingService.Trace(\"hello\")")]
63+
[InlineData("RegisterStep<Contact>", "(LocalPluginContext ctx) => ctx.TracingService.Trace(\"hello\")")]
64+
public async Task Should_Report_XPC3004_When_LocalPluginContext_Used_As_TService_With_Lambda(string registerStep, string lambda)
6165
{
6266
// Arrange
63-
const string pluginSource = """
67+
string pluginSource = $$"""
6468
6569
using XrmPluginCore;
6670
using XrmPluginCore.Enums;
@@ -73,10 +77,10 @@ public class TestPlugin : Plugin
7377
{
7478
public TestPlugin()
7579
{
76-
RegisterStep<Contact, LocalPluginContext>(
80+
{{registerStep}}(
7781
EventOperation.Update,
7882
ExecutionStage.PostOperation,
79-
ctx => ctx.TracingService.Trace("hello"));
83+
{{lambda}});
8084
}
8185
8286
protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
@@ -174,11 +178,13 @@ protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceColle
174178
diagnostics.Should().NotContain(d => d.Id == "XPC3004");
175179
}
176180

177-
[Fact]
178-
public async Task CodeFix_Should_Rewrite_To_RegisterPluginStep()
181+
[Theory]
182+
[InlineData("RegisterStep<Contact, LocalPluginContext>")]
183+
[InlineData("RegisterStep<Contact>")]
184+
public async Task CodeFix_Should_Rewrite_To_RegisterPluginStep(string registerStep)
179185
{
180186
// Arrange
181-
const string pluginSource = """
187+
string pluginSource = $$"""
182188
183189
using XrmPluginCore;
184190
using XrmPluginCore.Enums;
@@ -191,7 +197,7 @@ public class TestPlugin : Plugin
191197
{
192198
public TestPlugin()
193199
{
194-
RegisterStep<Contact, LocalPluginContext>(
200+
{{registerStep}}(
195201
EventOperation.Update,
196202
ExecutionStage.PostOperation,
197203
Execute);
@@ -216,7 +222,7 @@ protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceColle
216222

217223
// Assert
218224
fixedSource.Should().Contain("RegisterPluginStep<Contact>");
219-
fixedSource.Should().NotContain("RegisterStep<Contact, LocalPluginContext>");
225+
fixedSource.Should().NotContain(registerStep);
220226
}
221227

222228
[Fact]

XrmPluginCore.SourceGenerator/Analyzers/LocalPluginContextAsServiceAnalyzer.cs

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Microsoft.CodeAnalysis.CSharp.Syntax;
44
using Microsoft.CodeAnalysis.Diagnostics;
55
using System.Collections.Immutable;
6+
using System.Linq;
67
using XrmPluginCore.SourceGenerator.Helpers;
78

89
namespace XrmPluginCore.SourceGenerator.Analyzers;
@@ -35,12 +36,23 @@ private void AnalyzeInvocation(SyntaxNodeAnalysisContext context)
3536
return;
3637
}
3738

38-
// Only fire for exactly 2 type args: RegisterStep<TEntity, TService>
39-
if (genericName.TypeArgumentList.Arguments.Count != 2)
39+
var typeArgCount = genericName.TypeArgumentList.Arguments.Count;
40+
41+
if (typeArgCount == 2)
4042
{
41-
return;
43+
CheckExplicitLocalPluginContextTypeArg(context, invocation, genericName);
4244
}
45+
else if (typeArgCount == 1)
46+
{
47+
CheckImplicitLocalPluginContextMethodGroup(context, invocation, genericName);
48+
}
49+
}
4350

51+
private static void CheckExplicitLocalPluginContextTypeArg(
52+
SyntaxNodeAnalysisContext context,
53+
InvocationExpressionSyntax invocation,
54+
GenericNameSyntax genericName)
55+
{
4456
// Use semantic model to check full type name (avoids false positives on user-defined LocalPluginContext)
4557
var serviceTypeArg = genericName.TypeArgumentList.Arguments[1];
4658
var typeInfo = context.SemanticModel.GetTypeInfo(serviceTypeArg);
@@ -56,4 +68,76 @@ private void AnalyzeInvocation(SyntaxNodeAnalysisContext context)
5668
invocation.GetLocation(),
5769
entityTypeName));
5870
}
71+
72+
private static void CheckImplicitLocalPluginContextMethodGroup(
73+
SyntaxNodeAnalysisContext context,
74+
InvocationExpressionSyntax invocation,
75+
GenericNameSyntax genericName)
76+
{
77+
var arguments = invocation.ArgumentList.Arguments;
78+
if (arguments.Count < 3)
79+
{
80+
return;
81+
}
82+
83+
var actionArg = arguments[2].Expression;
84+
85+
bool isLocalPluginContextUsage = actionArg is LambdaExpressionSyntax lambda
86+
? LambdaHasExplicitLocalPluginContextParameter(context, lambda)
87+
: MethodGroupUsesLocalPluginContext(context, actionArg);
88+
89+
if (!isLocalPluginContextUsage)
90+
{
91+
return;
92+
}
93+
94+
var entityTypeName = genericName.TypeArgumentList.Arguments[0].ToString();
95+
context.ReportDiagnostic(Diagnostic.Create(
96+
DiagnosticDescriptors.LocalPluginContextAsService,
97+
invocation.GetLocation(),
98+
entityTypeName));
99+
}
100+
101+
private static bool MethodGroupUsesLocalPluginContext(
102+
SyntaxNodeAnalysisContext context,
103+
ExpressionSyntax actionArg)
104+
{
105+
// We intentionally use GetMemberGroup rather than GetSymbolInfo.Symbol here.
106+
//
107+
// RegisterStep<TEntity> takes Action<IExtendedServiceProvider>. Plugin also inherits
108+
// IPlugin.Execute(IServiceProvider), so the method group "Execute" always contains at least
109+
// two candidates. Because IExtendedServiceProvider : IServiceProvider, the inherited
110+
// Execute(IServiceProvider) satisfies the delegate via contravariance — GetSymbolInfo.Symbol
111+
// resolves to that method, not to the user's Execute(LocalPluginContext). Using Symbol as
112+
// the primary check would therefore suppress the diagnostic precisely in the cases where it
113+
// is most needed: the user defined Execute(LocalPluginContext) intending to use it, but the
114+
// compiler silently picked the base-class method instead.
115+
//
116+
// GetMemberGroup returns the full candidate set regardless of conversion success, so it
117+
// correctly detects the LocalPluginContext overload even in this inherited-method scenario.
118+
return context.SemanticModel.GetMemberGroup(actionArg)
119+
.OfType<IMethodSymbol>()
120+
.Any(m => m.Parameters.Length == 1
121+
&& m.Parameters[0].Type.ToDisplayString() == LocalPluginContextFullName);
122+
}
123+
124+
private static bool LambdaHasExplicitLocalPluginContextParameter(
125+
SyntaxNodeAnalysisContext context,
126+
LambdaExpressionSyntax lambda)
127+
{
128+
// Only parenthesized lambdas can have explicit parameter types: (LocalPluginContext ctx) => ...
129+
if (lambda is not ParenthesizedLambdaExpressionSyntax { ParameterList.Parameters.Count: 1 } paren)
130+
{
131+
return false;
132+
}
133+
134+
var paramTypeSyntax = paren.ParameterList.Parameters[0].Type;
135+
if (paramTypeSyntax == null)
136+
{
137+
return false;
138+
}
139+
140+
var typeInfo = context.SemanticModel.GetTypeInfo(paramTypeSyntax);
141+
return typeInfo.Type?.ToDisplayString() == LocalPluginContextFullName;
142+
}
59143
}

XrmPluginCore.SourceGenerator/rules/XPC3004.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ This rule reports when `LocalPluginContext` is used as the `TService` type argum
1010

1111
This typically happens when migrating from the legacy `RegisterPluginStep<T>` API and mistakenly using `RegisterStep<TEntity, LocalPluginContext>` instead of the correct DI-based approach.
1212

13-
## ❌ Example of violation
13+
## ❌ Example of violation (explicit type argument)
1414

1515
```csharp
1616
public class ContactPlugin : Plugin
@@ -28,6 +28,26 @@ public class ContactPlugin : Plugin
2828
}
2929
```
3030

31+
## ❌ Example of violation (implicit — method group with LocalPluginContext parameter)
32+
33+
```csharp
34+
public class ContactPlugin : Plugin
35+
{
36+
public ContactPlugin()
37+
{
38+
// XPC3004: Execute(LocalPluginContext) cannot be converted to Action<IExtendedServiceProvider>
39+
RegisterStep<Contact>(
40+
EventOperation.Update,
41+
ExecutionStage.PostOperation,
42+
Execute);
43+
}
44+
45+
private void Execute(LocalPluginContext context) { }
46+
}
47+
```
48+
49+
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.
50+
3151
## ✅ How to fix (interim — keep LocalPluginContext logic)
3252

3353
Use `RegisterPluginStep<T>` which correctly wraps the `LocalPluginContext`:

XrmPluginCore.Tests/LocalPluginContextTests.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ public void ConstructorValidServiceProviderShouldInitializeCorrectly()
2222
var context = new LocalPluginContext(serviceProvider);
2323

2424
// Assert
25-
context.PluginExecutionContext.Should().Be(mockProvider.PluginExecutionContext);
2625
tracingService.Should().NotBeNull();
26+
27+
context.ServiceProvider.Should().Be(serviceProvider);
28+
context.PluginExecutionContext.Should().Be(mockProvider.PluginExecutionContext);
2729
context.TracingService.Should().Be(tracingService);
2830
context.OrganizationService.Should().Be(mockProvider.OrganizationService);
2931
context.OrganizationAdminService.Should().Be(mockProvider.OrganizationAdminService);

XrmPluginCore/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
### 1.2.8 - 30 April 2026
2+
* Fix: Set ServiceProvider property on LocalPluginContext
3+
* Fix: XPC3004: Detect and report usage of LocalPluginContext when implicitly passed
4+
15
### v1.2.7 - 22 April 2026
26
* Add: Ability to generate Pre and Post images with all attributes
37
* Add: Error XPC3004: Do not use LocalPluginContext as TService in RegisterStep

XrmPluginCore/LocalPluginContext.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ public LocalPluginContext(IExtendedServiceProvider serviceProvider)
2323
throw new ArgumentNullException(nameof(serviceProvider));
2424
}
2525

26+
// Store the service provider reference.
27+
ServiceProvider = serviceProvider;
28+
2629
// Obtain the execution context service from the service provider.
2730
PluginExecutionContext = serviceProvider.GetService<IPluginExecutionContext>();
2831

0 commit comments

Comments
 (0)