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
File renamed without changes.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -398,3 +398,6 @@ FodyWeavers.xsd

# JetBrains Rider
*.sln.iml

# Claude local settings
.claude/*.local.json
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ The source generator includes analyzers that help catch common issues at compile
| [XPC3001](XrmPluginCore.SourceGenerator/rules/XPC3001.md) | Warning | Prefer nameof over string literal for handler method |
| [XPC3002](XrmPluginCore.SourceGenerator/rules/XPC3002.md) | Info | Consider using modern image registration API |
| [XPC3003](XrmPluginCore.SourceGenerator/rules/XPC3003.md) | Warning | Image registration without method reference |
| [XPC3004](XrmPluginCore.SourceGenerator/rules/XPC3004.md) | Error | Do not use LocalPluginContext as TService in RegisterStep |
| [XPC4001](XrmPluginCore.SourceGenerator/rules/XPC4001.md) | Error | Handler method not found |
| [XPC4002](XrmPluginCore.SourceGenerator/rules/XPC4002.md) | Warning | Handler signature does not match registered images |
| [XPC4003](XrmPluginCore.SourceGenerator/rules/XPC4003.md) | Error | Handler signature does not match registered images |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
using FluentAssertions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;
using XrmPluginCore.SourceGenerator.Analyzers;
using XrmPluginCore.SourceGenerator.CodeFixes;
using XrmPluginCore.SourceGenerator.Tests.Helpers;
using Xunit;

namespace XrmPluginCore.SourceGenerator.Tests.DiagnosticTests;

/// <summary>
/// Tests for LocalPluginContextAsServiceAnalyzer that errors when LocalPluginContext is used as TService in RegisterStep.
/// </summary>
public class LocalPluginContextAsServiceAnalyzerTests : CodeFixTestBase
{
[Fact]
public async Task Should_Report_XPC3004_When_LocalPluginContext_Explicitly_Specified()
{
// Arrange
const string pluginSource = """

using XrmPluginCore;
using XrmPluginCore.Enums;
using Microsoft.Extensions.DependencyInjection;
using TestNamespace;

namespace TestNamespace
{
public class TestPlugin : Plugin
{
public TestPlugin()
{
RegisterStep<Contact, LocalPluginContext>(
EventOperation.Update,
ExecutionStage.PostOperation,
Execute);
}

private void Execute(LocalPluginContext context) { }

protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
=> services;
}
}
""";

var source = TestFixtures.GetCompleteSource(pluginSource);
var diagnostics = await GetDiagnosticsAsync(source);

// Assert
diagnostics.Should().ContainSingle(d => d.Id == "XPC3004");
var diagnostic = diagnostics.Single(d => d.Id == "XPC3004");
diagnostic.Severity.Should().Be(DiagnosticSeverity.Error);
diagnostic.GetMessage().Should().Contain("Contact");
diagnostic.GetMessage().Should().Contain("LocalPluginContext");
}

[Fact]
public async Task Should_Report_XPC3004_When_LocalPluginContext_Used_As_TService_With_Lambda()
{
// Arrange
const string pluginSource = """

using XrmPluginCore;
using XrmPluginCore.Enums;
using Microsoft.Extensions.DependencyInjection;
using TestNamespace;

namespace TestNamespace
{
public class TestPlugin : Plugin
{
public TestPlugin()
{
RegisterStep<Contact, LocalPluginContext>(
EventOperation.Update,
ExecutionStage.PostOperation,
ctx => ctx.TracingService.Trace("hello"));
}

protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
=> services;
}
}
""";

var source = TestFixtures.GetCompleteSource(pluginSource);
var diagnostics = await GetDiagnosticsAsync(source);

// Assert
diagnostics.Should().ContainSingle(d => d.Id == "XPC3004");
}

[Fact]
public async Task Should_Not_Report_XPC3004_When_DI_Service_Used()
{
// Arrange
const string pluginSource = """

using XrmPluginCore;
using XrmPluginCore.Enums;
using Microsoft.Extensions.DependencyInjection;
using TestNamespace;

namespace TestNamespace
{
public class TestPlugin : Plugin
{
public TestPlugin()
{
RegisterStep<Contact, ITestService>(
EventOperation.Update,
ExecutionStage.PostOperation,
nameof(ITestService.HandleUpdate));
}

protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
=> services.AddScoped<ITestService, TestService>();
}

public interface ITestService
{
void HandleUpdate();
}

public class TestService : ITestService
{
public void HandleUpdate() { }
}
}
""";

var source = TestFixtures.GetCompleteSource(pluginSource);
var diagnostics = await GetDiagnosticsAsync(source);

// Assert
diagnostics.Should().NotContain(d => d.Id == "XPC3004");
}

[Fact]
public async Task Should_Not_Report_XPC3004_For_SingleTypeParam_Overload()
{
// Arrange — RegisterStep<T> with a single type arg uses Action<IExtendedServiceProvider>
const string pluginSource = """

using XrmPluginCore;
using XrmPluginCore.Enums;
using Microsoft.Extensions.DependencyInjection;
using TestNamespace;

namespace TestNamespace
{
public class TestPlugin : Plugin
{
public TestPlugin()
{
RegisterStep<Contact>(
EventOperation.Update,
ExecutionStage.PostOperation,
sp => sp.GetRequiredService<object>());
}

protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
=> services;
}
}
""";

var source = TestFixtures.GetCompleteSource(pluginSource);
var diagnostics = await GetDiagnosticsAsync(source);

// Assert
diagnostics.Should().NotContain(d => d.Id == "XPC3004");
}

[Fact]
public async Task CodeFix_Should_Rewrite_To_RegisterPluginStep()
{
// Arrange
const string pluginSource = """

using XrmPluginCore;
using XrmPluginCore.Enums;
using Microsoft.Extensions.DependencyInjection;
using TestNamespace;

namespace TestNamespace
{
public class TestPlugin : Plugin
{
public TestPlugin()
{
RegisterStep<Contact, LocalPluginContext>(
EventOperation.Update,
ExecutionStage.PostOperation,
Execute);
}

private void Execute(LocalPluginContext context) { }

protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
=> services;
}
}
""";

var source = TestFixtures.GetCompleteSource(pluginSource);

// Act
var fixedSource = await ApplyCodeFixAsync(
source,
new LocalPluginContextAsServiceAnalyzer(),
new LocalPluginContextAsServiceCodeFixProvider(),
DiagnosticDescriptors.LocalPluginContextAsService.Id);

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

[Fact]
public async Task CodeFix_Should_Have_Correct_Title()
{
// Arrange
const string pluginSource = """

using XrmPluginCore;
using XrmPluginCore.Enums;
using Microsoft.Extensions.DependencyInjection;
using TestNamespace;

namespace TestNamespace
{
public class TestPlugin : Plugin
{
public TestPlugin()
{
RegisterStep<Contact, LocalPluginContext>(
EventOperation.Update,
ExecutionStage.PostOperation,
Execute);
}

private void Execute(LocalPluginContext context) { }

protected override IServiceCollection OnBeforeBuildServiceProvider(IServiceCollection services)
=> services;
}
}
""";

var source = TestFixtures.GetCompleteSource(pluginSource);

// Act
var codeActions = await GetCodeActionsAsync(
source,
new LocalPluginContextAsServiceAnalyzer(),
new LocalPluginContextAsServiceCodeFixProvider(),
DiagnosticDescriptors.LocalPluginContextAsService.Id);

// Assert
codeActions.Should().ContainSingle();
codeActions[0].Title.Should().Be("Use RegisterPluginStep<Contact> instead");
}

private static async Task<ImmutableArray<Diagnostic>> GetDiagnosticsAsync(string source)
{
var compilation = CompilationHelper.CreateCompilation(source);
var analyzer = new LocalPluginContextAsServiceAnalyzer();

var compilationWithAnalyzers = compilation.WithAnalyzers(
[analyzer]);

return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

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

### Removed Rules

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;
using XrmPluginCore.SourceGenerator.Helpers;

namespace XrmPluginCore.SourceGenerator.Analyzers;

/// <summary>
/// Analyzer that reports an error when LocalPluginContext is used as TService in RegisterStep calls.
/// This causes a runtime exception because LocalPluginContext is not registered in the DI container.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class LocalPluginContextAsServiceAnalyzer : DiagnosticAnalyzer
{
private const string LocalPluginContextFullName = "XrmPluginCore.LocalPluginContext";

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
ImmutableArray.Create(DiagnosticDescriptors.LocalPluginContextAsService);

public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression);
}

private void AnalyzeInvocation(SyntaxNodeAnalysisContext context)
{
var invocation = (InvocationExpressionSyntax)context.Node;

if (!RegisterStepHelper.IsRegisterStepCall(invocation, out var genericName))
{
return;
}

// Only fire for exactly 2 type args: RegisterStep<TEntity, TService>
if (genericName.TypeArgumentList.Arguments.Count != 2)
{
return;
}

// 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);
if (typeInfo.Type?.ToDisplayString() != LocalPluginContextFullName)
{
return;
}

var entityTypeName = genericName.TypeArgumentList.Arguments[0].ToString();

context.ReportDiagnostic(Diagnostic.Create(
DiagnosticDescriptors.LocalPluginContextAsService,
invocation.GetLocation(),
entityTypeName));
}
}
Loading
Loading