Skip to content

Commit 36d8e12

Browse files
authored
Add: XPC3004 error diagnostics, to avoid people using RegisterStep<Entity, LocalPluginContext> which fails at runtime. (#7)
* Add: XPC3004 error diagnostics, to avoid people using `RegisterStep<Entity, LocalPluginContext>` which fails at runtime. This fixes #6
1 parent 0a2cac4 commit 36d8e12

9 files changed

Lines changed: 541 additions & 0 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,3 +398,6 @@ FodyWeavers.xsd
398398

399399
# JetBrains Rider
400400
*.sln.iml
401+
402+
# Claude local settings
403+
.claude/*.local.json

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ The source generator includes analyzers that help catch common issues at compile
200200
| [XPC3001](XrmPluginCore.SourceGenerator/rules/XPC3001.md) | Warning | Prefer nameof over string literal for handler method |
201201
| [XPC3002](XrmPluginCore.SourceGenerator/rules/XPC3002.md) | Info | Consider using modern image registration API |
202202
| [XPC3003](XrmPluginCore.SourceGenerator/rules/XPC3003.md) | Warning | Image registration without method reference |
203+
| [XPC3004](XrmPluginCore.SourceGenerator/rules/XPC3004.md) | Error | Do not use LocalPluginContext as TService in RegisterStep |
203204
| [XPC4001](XrmPluginCore.SourceGenerator/rules/XPC4001.md) | Error | Handler method not found |
204205
| [XPC4002](XrmPluginCore.SourceGenerator/rules/XPC4002.md) | Warning | Handler signature does not match registered images |
205206
| [XPC4003](XrmPluginCore.SourceGenerator/rules/XPC4003.md) | Error | Handler signature does not match registered images |
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
using FluentAssertions;
2+
using Microsoft.CodeAnalysis;
3+
using Microsoft.CodeAnalysis.Diagnostics;
4+
using System.Collections.Immutable;
5+
using XrmPluginCore.SourceGenerator.Analyzers;
6+
using XrmPluginCore.SourceGenerator.CodeFixes;
7+
using XrmPluginCore.SourceGenerator.Tests.Helpers;
8+
using Xunit;
9+
10+
namespace XrmPluginCore.SourceGenerator.Tests.DiagnosticTests;
11+
12+
/// <summary>
13+
/// Tests for LocalPluginContextAsServiceAnalyzer that errors when LocalPluginContext is used as TService in RegisterStep.
14+
/// </summary>
15+
public class LocalPluginContextAsServiceAnalyzerTests : CodeFixTestBase
16+
{
17+
[Fact]
18+
public async Task Should_Report_XPC3004_When_LocalPluginContext_Explicitly_Specified()
19+
{
20+
// Arrange
21+
const string pluginSource = """
22+
23+
using XrmPluginCore;
24+
using XrmPluginCore.Enums;
25+
using Microsoft.Extensions.DependencyInjection;
26+
using TestNamespace;
27+
28+
namespace TestNamespace
29+
{
30+
public class TestPlugin : Plugin
31+
{
32+
public TestPlugin()
33+
{
34+
RegisterStep<Contact, LocalPluginContext>(
35+
EventOperation.Update,
36+
ExecutionStage.PostOperation,
37+
Execute);
38+
}
39+
40+
private void Execute(LocalPluginContext context) { }
41+
42+
protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
43+
=> services;
44+
}
45+
}
46+
""";
47+
48+
var source = TestFixtures.GetCompleteSource(pluginSource);
49+
var diagnostics = await GetDiagnosticsAsync(source);
50+
51+
// Assert
52+
diagnostics.Should().ContainSingle(d => d.Id == "XPC3004");
53+
var diagnostic = diagnostics.Single(d => d.Id == "XPC3004");
54+
diagnostic.Severity.Should().Be(DiagnosticSeverity.Error);
55+
diagnostic.GetMessage().Should().Contain("Contact");
56+
diagnostic.GetMessage().Should().Contain("LocalPluginContext");
57+
}
58+
59+
[Fact]
60+
public async Task Should_Report_XPC3004_When_LocalPluginContext_Used_As_TService_With_Lambda()
61+
{
62+
// Arrange
63+
const string pluginSource = """
64+
65+
using XrmPluginCore;
66+
using XrmPluginCore.Enums;
67+
using Microsoft.Extensions.DependencyInjection;
68+
using TestNamespace;
69+
70+
namespace TestNamespace
71+
{
72+
public class TestPlugin : Plugin
73+
{
74+
public TestPlugin()
75+
{
76+
RegisterStep<Contact, LocalPluginContext>(
77+
EventOperation.Update,
78+
ExecutionStage.PostOperation,
79+
ctx => ctx.TracingService.Trace("hello"));
80+
}
81+
82+
protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
83+
=> services;
84+
}
85+
}
86+
""";
87+
88+
var source = TestFixtures.GetCompleteSource(pluginSource);
89+
var diagnostics = await GetDiagnosticsAsync(source);
90+
91+
// Assert
92+
diagnostics.Should().ContainSingle(d => d.Id == "XPC3004");
93+
}
94+
95+
[Fact]
96+
public async Task Should_Not_Report_XPC3004_When_DI_Service_Used()
97+
{
98+
// Arrange
99+
const string pluginSource = """
100+
101+
using XrmPluginCore;
102+
using XrmPluginCore.Enums;
103+
using Microsoft.Extensions.DependencyInjection;
104+
using TestNamespace;
105+
106+
namespace TestNamespace
107+
{
108+
public class TestPlugin : Plugin
109+
{
110+
public TestPlugin()
111+
{
112+
RegisterStep<Contact, ITestService>(
113+
EventOperation.Update,
114+
ExecutionStage.PostOperation,
115+
nameof(ITestService.HandleUpdate));
116+
}
117+
118+
protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
119+
=> services.AddScoped<ITestService, TestService>();
120+
}
121+
122+
public interface ITestService
123+
{
124+
void HandleUpdate();
125+
}
126+
127+
public class TestService : ITestService
128+
{
129+
public void HandleUpdate() { }
130+
}
131+
}
132+
""";
133+
134+
var source = TestFixtures.GetCompleteSource(pluginSource);
135+
var diagnostics = await GetDiagnosticsAsync(source);
136+
137+
// Assert
138+
diagnostics.Should().NotContain(d => d.Id == "XPC3004");
139+
}
140+
141+
[Fact]
142+
public async Task Should_Not_Report_XPC3004_For_SingleTypeParam_Overload()
143+
{
144+
// Arrange — RegisterStep<T> with a single type arg uses Action<IExtendedServiceProvider>
145+
const string pluginSource = """
146+
147+
using XrmPluginCore;
148+
using XrmPluginCore.Enums;
149+
using Microsoft.Extensions.DependencyInjection;
150+
using TestNamespace;
151+
152+
namespace TestNamespace
153+
{
154+
public class TestPlugin : Plugin
155+
{
156+
public TestPlugin()
157+
{
158+
RegisterStep<Contact>(
159+
EventOperation.Update,
160+
ExecutionStage.PostOperation,
161+
sp => sp.GetRequiredService<object>());
162+
}
163+
164+
protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
165+
=> services;
166+
}
167+
}
168+
""";
169+
170+
var source = TestFixtures.GetCompleteSource(pluginSource);
171+
var diagnostics = await GetDiagnosticsAsync(source);
172+
173+
// Assert
174+
diagnostics.Should().NotContain(d => d.Id == "XPC3004");
175+
}
176+
177+
[Fact]
178+
public async Task CodeFix_Should_Rewrite_To_RegisterPluginStep()
179+
{
180+
// Arrange
181+
const string pluginSource = """
182+
183+
using XrmPluginCore;
184+
using XrmPluginCore.Enums;
185+
using Microsoft.Extensions.DependencyInjection;
186+
using TestNamespace;
187+
188+
namespace TestNamespace
189+
{
190+
public class TestPlugin : Plugin
191+
{
192+
public TestPlugin()
193+
{
194+
RegisterStep<Contact, LocalPluginContext>(
195+
EventOperation.Update,
196+
ExecutionStage.PostOperation,
197+
Execute);
198+
}
199+
200+
private void Execute(LocalPluginContext context) { }
201+
202+
protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
203+
=> services;
204+
}
205+
}
206+
""";
207+
208+
var source = TestFixtures.GetCompleteSource(pluginSource);
209+
210+
// Act
211+
var fixedSource = await ApplyCodeFixAsync(
212+
source,
213+
new LocalPluginContextAsServiceAnalyzer(),
214+
new LocalPluginContextAsServiceCodeFixProvider(),
215+
DiagnosticDescriptors.LocalPluginContextAsService.Id);
216+
217+
// Assert
218+
fixedSource.Should().Contain("RegisterPluginStep<Contact>");
219+
fixedSource.Should().NotContain("RegisterStep<Contact, LocalPluginContext>");
220+
}
221+
222+
[Fact]
223+
public async Task CodeFix_Should_Have_Correct_Title()
224+
{
225+
// Arrange
226+
const string pluginSource = """
227+
228+
using XrmPluginCore;
229+
using XrmPluginCore.Enums;
230+
using Microsoft.Extensions.DependencyInjection;
231+
using TestNamespace;
232+
233+
namespace TestNamespace
234+
{
235+
public class TestPlugin : Plugin
236+
{
237+
public TestPlugin()
238+
{
239+
RegisterStep<Contact, LocalPluginContext>(
240+
EventOperation.Update,
241+
ExecutionStage.PostOperation,
242+
Execute);
243+
}
244+
245+
private void Execute(LocalPluginContext context) { }
246+
247+
protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
248+
=> services;
249+
}
250+
}
251+
""";
252+
253+
var source = TestFixtures.GetCompleteSource(pluginSource);
254+
255+
// Act
256+
var codeActions = await GetCodeActionsAsync(
257+
source,
258+
new LocalPluginContextAsServiceAnalyzer(),
259+
new LocalPluginContextAsServiceCodeFixProvider(),
260+
DiagnosticDescriptors.LocalPluginContextAsService.Id);
261+
262+
// Assert
263+
codeActions.Should().ContainSingle();
264+
codeActions[0].Title.Should().Be("Use RegisterPluginStep<Contact> instead");
265+
}
266+
267+
private static async Task<ImmutableArray<Diagnostic>> GetDiagnosticsAsync(string source)
268+
{
269+
var compilation = CompilationHelper.CreateCompilation(source);
270+
var analyzer = new LocalPluginContextAsServiceAnalyzer();
271+
272+
var compilationWithAnalyzers = compilation.WithAnalyzers(
273+
[analyzer]);
274+
275+
return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync();
276+
}
277+
}

XrmPluginCore.SourceGenerator/AnalyzerReleases.Unshipped.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
Rule ID | Category | Severity | Notes
44
--------|----------|----------|-------
5+
XPC3004 | XrmPluginCore.SourceGenerator | Error | Do not use LocalPluginContext as TService in RegisterStep
56

67
### Removed Rules
78

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
using Microsoft.CodeAnalysis;
2+
using Microsoft.CodeAnalysis.CSharp;
3+
using Microsoft.CodeAnalysis.CSharp.Syntax;
4+
using Microsoft.CodeAnalysis.Diagnostics;
5+
using System.Collections.Immutable;
6+
using XrmPluginCore.SourceGenerator.Helpers;
7+
8+
namespace XrmPluginCore.SourceGenerator.Analyzers;
9+
10+
/// <summary>
11+
/// Analyzer that reports an error when LocalPluginContext is used as TService in RegisterStep calls.
12+
/// This causes a runtime exception because LocalPluginContext is not registered in the DI container.
13+
/// </summary>
14+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
15+
public class LocalPluginContextAsServiceAnalyzer : DiagnosticAnalyzer
16+
{
17+
private const string LocalPluginContextFullName = "XrmPluginCore.LocalPluginContext";
18+
19+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
20+
ImmutableArray.Create(DiagnosticDescriptors.LocalPluginContextAsService);
21+
22+
public override void Initialize(AnalysisContext context)
23+
{
24+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
25+
context.EnableConcurrentExecution();
26+
context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression);
27+
}
28+
29+
private void AnalyzeInvocation(SyntaxNodeAnalysisContext context)
30+
{
31+
var invocation = (InvocationExpressionSyntax)context.Node;
32+
33+
if (!RegisterStepHelper.IsRegisterStepCall(invocation, out var genericName))
34+
{
35+
return;
36+
}
37+
38+
// Only fire for exactly 2 type args: RegisterStep<TEntity, TService>
39+
if (genericName.TypeArgumentList.Arguments.Count != 2)
40+
{
41+
return;
42+
}
43+
44+
// Use semantic model to check full type name (avoids false positives on user-defined LocalPluginContext)
45+
var serviceTypeArg = genericName.TypeArgumentList.Arguments[1];
46+
var typeInfo = context.SemanticModel.GetTypeInfo(serviceTypeArg);
47+
if (typeInfo.Type?.ToDisplayString() != LocalPluginContextFullName)
48+
{
49+
return;
50+
}
51+
52+
var entityTypeName = genericName.TypeArgumentList.Arguments[0].ToString();
53+
54+
context.ReportDiagnostic(Diagnostic.Create(
55+
DiagnosticDescriptors.LocalPluginContextAsService,
56+
invocation.GetLocation(),
57+
entityTypeName));
58+
}
59+
}

0 commit comments

Comments
 (0)