Skip to content

Commit 841c6a8

Browse files
authored
Refactor image registration analyzer and expand XPC3002 (#4)
* Refactor image registration analyzer and expand XPC3002 Refactored the image registration analyzer to always report XPC3002 for any AddImage usage, regardless of handler syntax (nameof, method reference, or lambda). XPC3003 is now only reported for lambda invocation with the modern API. Added comprehensive unit tests for all scenarios and updated XPC3002 documentation to reflect the broader rule scope Removed obsolete enum and clarified analyzer logic and comments.
1 parent 8a6419e commit 841c6a8

File tree

4 files changed

+206
-58
lines changed

4 files changed

+206
-58
lines changed

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,20 @@ public string AccountNumber => Entity.AccountNumber;
190190

191191
**Note:** This is optional. Without it, wrapper properties work normally but won't have XML documentation tooltips.
192192

193+
#### Analyzer Diagnostics
194+
195+
The source generator includes analyzers that help catch common issues at compile time:
196+
197+
| ID | Severity | Title |
198+
|----|----------|-------|
199+
| [XPC2001](XrmPluginCore.SourceGenerator/rules/XPC2001.md) | Warning | No parameterless constructor found |
200+
| [XPC3001](XrmPluginCore.SourceGenerator/rules/XPC3001.md) | Warning | Prefer nameof over string literal for handler method |
201+
| [XPC3002](XrmPluginCore.SourceGenerator/rules/XPC3002.md) | Info | Consider using modern image registration API |
202+
| [XPC3003](XrmPluginCore.SourceGenerator/rules/XPC3003.md) | Warning | Image registration without method reference |
203+
| [XPC4001](XrmPluginCore.SourceGenerator/rules/XPC4001.md) | Error | Handler method not found |
204+
| [XPC4002](XrmPluginCore.SourceGenerator/rules/XPC4002.md) | Warning | Handler signature does not match registered images |
205+
| [XPC4003](XrmPluginCore.SourceGenerator/rules/XPC4003.md) | Error | Handler signature does not match registered images |
206+
193207
### Using the LocalPluginContext wrapper (Legacy)
194208

195209
**NOTE**: This is only supported for legacy DAXIF/XrmFramework style plugins. It is recommended to use dependency injection based plugins instead.

XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DiagnosticReportingTests.cs

Lines changed: 124 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -658,9 +658,10 @@ public void DoSomething() { }
658658
}
659659

660660
[Fact]
661-
public async Task Should_Report_XPC3003_Not_XPC3002_When_WithPreImage_Used_Even_With_AddImage()
661+
public async Task Should_Report_Both_XPC3002_And_XPC3003_When_AddImage_And_WithPreImage_Used_With_Invocation()
662662
{
663-
// Arrange - Both WithPreImage (modern) and AddImage (legacy) used - should report XPC3003 since modern takes precedence
663+
// Arrange - Both WithPreImage (modern) and AddImage (legacy) used with lambda invocation
664+
// Should report both: XPC3002 for AddImage, XPC3003 for lambda invocation with modern API
664665
const string pluginSource = """
665666
666667
using XrmPluginCore;
@@ -703,7 +704,7 @@ public void DoSomething() { }
703704
// Act - Run analyzer instead of generator
704705
var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new ImageWithoutMethodReferenceAnalyzer());
705706

706-
// Assert - Should report XPC3003 (modern API takes precedence)
707+
// Assert - Should report both diagnostics
707708
var xpc3003Diagnostics = diagnostics
708709
.Where(d => d.Id == "XPC3003")
709710
.ToArray();
@@ -712,8 +713,126 @@ public void DoSomething() { }
712713
.Where(d => d.Id == "XPC3002")
713714
.ToArray();
714715

715-
xpc3003Diagnostics.Should().NotBeEmpty("XPC3003 should be reported when modern API (WithPreImage) is used");
716-
xpc3002Diagnostics.Should().BeEmpty("XPC3002 should NOT be reported when modern API is also present");
716+
xpc3003Diagnostics.Should().NotBeEmpty("XPC3003 should be reported when modern API is used with lambda invocation");
717+
xpc3002Diagnostics.Should().NotBeEmpty("XPC3002 should be reported for AddImage usage");
718+
}
719+
720+
[Fact]
721+
public async Task Should_Report_XPC3002_When_AddImage_Used_With_Nameof()
722+
{
723+
// Arrange - AddImage used with nameof() - should still suggest migration to modern API
724+
const string pluginSource = """
725+
726+
using XrmPluginCore;
727+
using XrmPluginCore.Enums;
728+
using Microsoft.Extensions.DependencyInjection;
729+
using TestNamespace;
730+
731+
namespace TestNamespace
732+
{
733+
public class TestPlugin : Plugin
734+
{
735+
public TestPlugin()
736+
{
737+
RegisterStep<Account, ITestService>(EventOperation.Update, ExecutionStage.PostOperation,
738+
nameof(ITestService.DoSomething))
739+
.AddImage(ImageType.PreImage, x => x.Name);
740+
}
741+
742+
protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
743+
{
744+
return services.AddScoped<ITestService, TestService>();
745+
}
746+
}
747+
748+
public interface ITestService
749+
{
750+
void DoSomething();
751+
}
752+
753+
public class TestService : ITestService
754+
{
755+
public void DoSomething() { }
756+
}
757+
}
758+
""";
759+
760+
var source = TestFixtures.GetCompleteSource(pluginSource);
761+
762+
// Act - Run analyzer instead of generator
763+
var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new ImageWithoutMethodReferenceAnalyzer());
764+
765+
// Assert - Should report XPC3002 for AddImage, but NOT XPC3003 (no lambda invocation)
766+
var xpc3002Diagnostics = diagnostics
767+
.Where(d => d.Id == "XPC3002")
768+
.ToArray();
769+
770+
var xpc3003Diagnostics = diagnostics
771+
.Where(d => d.Id == "XPC3003")
772+
.ToArray();
773+
774+
xpc3002Diagnostics.Should().NotBeEmpty("XPC3002 should be reported when AddImage is used, even with nameof()");
775+
xpc3002Diagnostics.Should().OnlyContain(d => d.Severity == DiagnosticSeverity.Info);
776+
xpc3003Diagnostics.Should().BeEmpty("XPC3003 should NOT be reported when using nameof()");
777+
}
778+
779+
[Fact]
780+
public async Task Should_Report_XPC3002_When_AddImage_Used_With_MethodReference()
781+
{
782+
// Arrange - AddImage used with method reference syntax (s => s.DoSomething without parentheses)
783+
const string pluginSource = """
784+
785+
using XrmPluginCore;
786+
using XrmPluginCore.Enums;
787+
using Microsoft.Extensions.DependencyInjection;
788+
using TestNamespace;
789+
790+
namespace TestNamespace
791+
{
792+
public class TestPlugin : Plugin
793+
{
794+
public TestPlugin()
795+
{
796+
RegisterStep<Account, ITestService>(EventOperation.Update, ExecutionStage.PostOperation,
797+
s => s.DoSomething)
798+
.AddImage(ImageType.PreImage, x => x.Name);
799+
}
800+
801+
protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
802+
{
803+
return services.AddScoped<ITestService, TestService>();
804+
}
805+
}
806+
807+
public interface ITestService
808+
{
809+
void DoSomething();
810+
}
811+
812+
public class TestService : ITestService
813+
{
814+
public void DoSomething() { }
815+
}
816+
}
817+
""";
818+
819+
var source = TestFixtures.GetCompleteSource(pluginSource);
820+
821+
// Act - Run analyzer instead of generator
822+
var diagnostics = await GetAnalyzerDiagnosticsAsync(source, new ImageWithoutMethodReferenceAnalyzer());
823+
824+
// Assert - Should report XPC3002 for AddImage, but NOT XPC3003 (method reference, not invocation)
825+
var xpc3002Diagnostics = diagnostics
826+
.Where(d => d.Id == "XPC3002")
827+
.ToArray();
828+
829+
var xpc3003Diagnostics = diagnostics
830+
.Where(d => d.Id == "XPC3003")
831+
.ToArray();
832+
833+
xpc3002Diagnostics.Should().NotBeEmpty("XPC3002 should be reported when AddImage is used, even with method reference");
834+
xpc3002Diagnostics.Should().OnlyContain(d => d.Severity == DiagnosticSeverity.Info);
835+
xpc3003Diagnostics.Should().BeEmpty("XPC3003 should NOT be reported when using method reference syntax");
717836
}
718837

719838
private static async Task<ImmutableArray<Diagnostic>> GetAnalyzerDiagnosticsAsync(string source, DiagnosticAnalyzer analyzer)

XrmPluginCore.SourceGenerator/Analyzers/ImageWithoutMethodReferenceAnalyzer.cs

Lines changed: 41 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,20 @@
22
using Microsoft.CodeAnalysis.CSharp;
33
using Microsoft.CodeAnalysis.CSharp.Syntax;
44
using Microsoft.CodeAnalysis.Diagnostics;
5+
using System.Collections.Generic;
56
using System.Collections.Immutable;
67
using XrmPluginCore.SourceGenerator.Helpers;
78

89
namespace XrmPluginCore.SourceGenerator.Analyzers;
910

1011
/// <summary>
11-
/// Analyzer that warns when lambda invocation syntax (s => s.Method()) is used with image registrations
12-
/// instead of method reference syntax (nameof(Service.Method)).
12+
/// Analyzer that reports:
13+
/// - XPC3002: When AddImage is used (suggesting migration to WithPreImage/WithPostImage)
14+
/// - XPC3003: When lambda invocation syntax (s => s.Method()) is used with modern API (WithPreImage/WithPostImage)
1315
/// </summary>
1416
[DiagnosticAnalyzer(LanguageNames.CSharp)]
1517
public class ImageWithoutMethodReferenceAnalyzer : DiagnosticAnalyzer
1618
{
17-
private enum ImageRegistrationType
18-
{
19-
None,
20-
Modern, // WithPreImage, WithPostImage
21-
Legacy // AddImage
22-
}
23-
2419
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
2520
ImmutableArray.Create(
2621
DiagnosticDescriptors.ImageWithoutMethodReference,
@@ -58,39 +53,36 @@ private void AnalyzeInvocation(SyntaxNodeAnalysisContext context)
5853

5954
var handlerArgument = arguments[2].Expression;
6055

61-
// Check if the 3rd argument is a lambda with an invocation body (s => s.Method())
62-
if (!IsLambdaWithInvocation(handlerArgument, out var methodName, out var hasArguments))
63-
{
64-
return;
65-
}
56+
// Get image registration info
57+
var (hasModernApi, addImageLocations) = GetImageRegistrationInfo(invocation);
6658

67-
// Check if the call chain has image registration and which type
68-
var imageType = GetImageRegistrationType(invocation);
69-
if (imageType == ImageRegistrationType.None)
59+
// XPC3002: Report for any AddImage usage (suggesting migration to modern API)
60+
foreach (var addImageLocation in addImageLocations)
7061
{
71-
return;
72-
}
62+
var diagnostic = Diagnostic.Create(
63+
DiagnosticDescriptors.LegacyImageRegistration,
64+
addImageLocation);
7365

74-
// Get the service type name (TService)
75-
var serviceType = genericName.TypeArgumentList.Arguments[1].ToString();
66+
context.ReportDiagnostic(diagnostic);
67+
}
7668

77-
// Create diagnostic properties for the code fix
78-
var properties = ImmutableDictionary.CreateBuilder<string, string>();
79-
properties.Add("ServiceType", serviceType);
80-
properties.Add("MethodName", methodName);
81-
properties.Add("HasArguments", hasArguments.ToString());
69+
// XPC3003: Report when modern API is used with lambda invocation syntax
70+
if (hasModernApi && IsLambdaWithInvocation(handlerArgument, out var methodName, out var hasArguments))
71+
{
72+
var serviceType = genericName.TypeArgumentList.Arguments[1].ToString();
8273

83-
// Select appropriate diagnostic based on API type
84-
var descriptor = imageType == ImageRegistrationType.Modern
85-
? DiagnosticDescriptors.ImageWithoutMethodReference
86-
: DiagnosticDescriptors.LegacyImageRegistration;
74+
var properties = ImmutableDictionary.CreateBuilder<string, string>();
75+
properties.Add("ServiceType", serviceType);
76+
properties.Add("MethodName", methodName ?? string.Empty);
77+
properties.Add("HasArguments", hasArguments.ToString());
8778

88-
var diagnostic = Diagnostic.Create(
89-
descriptor,
90-
handlerArgument.GetLocation(),
91-
properties.ToImmutable());
79+
var diagnostic = Diagnostic.Create(
80+
DiagnosticDescriptors.ImageWithoutMethodReference,
81+
handlerArgument.GetLocation(),
82+
properties.ToImmutable());
9283

93-
context.ReportDiagnostic(diagnostic);
84+
context.ReportDiagnostic(diagnostic);
85+
}
9486
}
9587

9688
private static bool IsLambdaWithInvocation(ExpressionSyntax expression, out string methodName, out bool hasArguments)
@@ -125,11 +117,18 @@ private static bool IsLambdaWithInvocation(ExpressionSyntax expression, out stri
125117
return false;
126118
}
127119

128-
private static ImageRegistrationType GetImageRegistrationType(InvocationExpressionSyntax registerStepInvocation)
120+
/// <summary>
121+
/// Checks the call chain for image registrations.
122+
/// Returns whether modern API is used and the locations of all AddImage calls.
123+
/// </summary>
124+
private static (bool hasModernApi, List<Location> addImageLocations) GetImageRegistrationInfo(
125+
InvocationExpressionSyntax registerStepInvocation)
129126
{
127+
var hasModernApi = false;
128+
var addImageLocations = new List<Location>();
129+
130130
// Walk up to find the full fluent call chain
131131
var current = registerStepInvocation.Parent;
132-
var result = ImageRegistrationType.None;
133132

134133
while (current != null)
135134
{
@@ -140,21 +139,19 @@ private static ImageRegistrationType GetImageRegistrationType(InvocationExpressi
140139
if (methodName == Constants.WithPreImageMethodName ||
141140
methodName == Constants.WithPostImageMethodName)
142141
{
143-
// Modern API takes precedence
144-
return ImageRegistrationType.Modern;
142+
hasModernApi = true;
145143
}
146-
147-
if (methodName == Constants.AddImageMethodName)
144+
else if (methodName == Constants.AddImageMethodName)
148145
{
149-
// Track legacy, but keep looking for modern
150-
result = ImageRegistrationType.Legacy;
146+
// Collect the location of the AddImage identifier for the diagnostic
147+
addImageLocations.Add(memberAccess.Name.GetLocation());
151148
}
152149
}
153150

154151
// Move up the syntax tree
155152
current = current.Parent;
156153
}
157154

158-
return result;
155+
return (hasModernApi, addImageLocations);
159156
}
160157
}

XrmPluginCore.SourceGenerator/rules/XPC3002.md

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,35 @@ Info (Suggestion)
66

77
## Description
88

9-
This rule reports when `AddImage()` is used with a lambda invocation expression (e.g., `s => s.HandleUpdate()`). While this is valid code, the modern `WithPreImage()` and `WithPostImage()` APIs with `nameof()` provide better type-safety and compile-time validation.
9+
This rule reports when `AddImage()` is used for image registration. While `AddImage()` is fully functional (especially when combined with `nameof()`), the modern `WithPreImage()` and `WithPostImage()` APIs provide a cleaner syntax and express intent more clearly.
1010

11-
## ❌ Example of code that triggers this suggestion
11+
## ❌ Examples of code that triggers this suggestion
12+
13+
### Using AddImage with nameof()
14+
15+
```csharp
16+
public class AccountPlugin : Plugin
17+
{
18+
public AccountPlugin()
19+
{
20+
// XPC3002 reported on AddImage
21+
RegisterStep<Account, IAccountService>(
22+
EventOperation.Update,
23+
ExecutionStage.PostOperation,
24+
nameof(IAccountService.HandleUpdate))
25+
.AddImage(ImageType.PreImage, x => x.Name);
26+
}
27+
}
28+
```
29+
30+
### Using AddImage with lambda invocation
1231

1332
```csharp
1433
public class AccountPlugin : Plugin
1534
{
1635
public AccountPlugin()
1736
{
18-
// XPC3002: Consider using modern image registration API
37+
// XPC3002 reported on AddImage
1938
RegisterStep<Account, IAccountService>(
2039
EventOperation.Update,
2140
ExecutionStage.PostOperation,
@@ -27,7 +46,7 @@ public class AccountPlugin : Plugin
2746

2847
## ✅ How to fix
2948

30-
Convert to the modern `WithPreImage()`/`WithPostImage()` API with `nameof()`:
49+
Convert `AddImage(ImageType.PreImage, ...)` to `WithPreImage(...)` and `AddImage(ImageType.PostImage, ...)` to `WithPostImage(...)`:
3150

3251
```csharp
3352
public class AccountPlugin : Plugin
@@ -45,18 +64,17 @@ public class AccountPlugin : Plugin
4564

4665
## Why this matters
4766

48-
1. **Type-safe wrapper generation**: Using `WithPreImage()`/`WithPostImage()` with `nameof()` enables the source generator to create strongly-typed `PreImage` and `PostImage` wrapper classes. These provide IntelliSense support and compile-time safety.
67+
1. **Cleaner API**: `WithPreImage()` and `WithPostImage()` are more readable and express intent more clearly than the generic `AddImage(ImageType.PreImage/PostImage, ...)` pattern.
4968

50-
2. **Signature validation**: The modern API enables compile-time validation that your handler method signature matches the registered images (XPC4002/XPC4003).
69+
2. **Type-safe wrapper generation**: Both APIs support type-safe wrapper generation when used with `nameof()` or method reference syntax.
5170

52-
3. **Cleaner API**: `WithPreImage()` and `WithPostImage()` are more readable and express intent more clearly than the generic `AddImage()` method.
71+
3. **Consistency**: Using the modern API ensures consistency across your codebase.
5372

5473
## When to suppress
5574

5675
This is an informational suggestion. You may choose to keep using `AddImage()` if:
5776
- You're maintaining legacy code and don't want to refactor
58-
- You intentionally don't need the type-safe wrappers
59-
- You're passing arguments to the handler method that would be lost in conversion
77+
- You prefer the explicit `ImageType` parameter
6078

6179
## See also
6280

0 commit comments

Comments
 (0)