diff --git a/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DuplicateStepRegistrationAnalyzerTests.cs b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DuplicateStepRegistrationAnalyzerTests.cs
new file mode 100644
index 0000000..e6bade8
--- /dev/null
+++ b/XrmPluginCore.SourceGenerator.Tests/DiagnosticTests/DuplicateStepRegistrationAnalyzerTests.cs
@@ -0,0 +1,443 @@
+using FluentAssertions;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using System.Collections.Immutable;
+using XrmPluginCore.SourceGenerator.Analyzers;
+using XrmPluginCore.SourceGenerator.Tests.Helpers;
+using Xunit;
+
+namespace XrmPluginCore.SourceGenerator.Tests.DiagnosticTests;
+
+///
+/// Tests for XPC2002 (duplicate plugin step registration for the same entity/operation/stage).
+///
+public class DuplicateStepRegistrationAnalyzerTests
+{
+ [Fact]
+ public async Task Should_Report_XPC2002_For_Duplicate_Entity_Operation_Stage()
+ {
+ var source = WrapPlugin("""
+ RegisterStep(EventOperation.Update, ExecutionStage.PostOperation,
+ nameof(ITestService.HandleA));
+ RegisterStep(EventOperation.Update, ExecutionStage.PostOperation,
+ nameof(ITestService.HandleB));
+ """);
+
+ var diagnostics = await GetDiagnosticsAsync(source);
+
+ var diagnostic = diagnostics.Should().ContainSingle(d => d.Id == "XPC2002").Subject;
+ diagnostic.Severity.Should().Be(DiagnosticSeverity.Error);
+ diagnostic.GetMessage().Should().Contain("Account/Update/PostOperation");
+ }
+
+ [Fact]
+ public async Task Should_Not_Report_When_Stage_Differs()
+ {
+ var source = WrapPlugin("""
+ RegisterStep(EventOperation.Update, ExecutionStage.PreOperation,
+ nameof(ITestService.HandleA));
+ RegisterStep(EventOperation.Update, ExecutionStage.PostOperation,
+ nameof(ITestService.HandleB));
+ """);
+
+ var diagnostics = await GetDiagnosticsAsync(source);
+
+ diagnostics.Should().NotContain(d => d.Id == "XPC2002");
+ }
+
+ [Fact]
+ public async Task Should_Not_Report_When_Operation_Differs()
+ {
+ var source = WrapPlugin("""
+ RegisterStep(EventOperation.Create, ExecutionStage.PostOperation,
+ nameof(ITestService.HandleA));
+ RegisterStep(EventOperation.Update, ExecutionStage.PostOperation,
+ nameof(ITestService.HandleB));
+ """);
+
+ var diagnostics = await GetDiagnosticsAsync(source);
+
+ diagnostics.Should().NotContain(d => d.Id == "XPC2002");
+ }
+
+ [Fact]
+ public async Task Should_Report_Once_Per_Extra_Duplicate()
+ {
+ // Three registrations of the same tuple -> two duplicates reported (2nd and 3rd).
+ var source = WrapPlugin("""
+ RegisterStep(EventOperation.Update, ExecutionStage.PostOperation,
+ nameof(ITestService.HandleA));
+ RegisterStep(EventOperation.Update, ExecutionStage.PostOperation,
+ nameof(ITestService.HandleB));
+ RegisterStep(EventOperation.Update, ExecutionStage.PostOperation,
+ nameof(ITestService.HandleC));
+ """);
+
+ var diagnostics = await GetDiagnosticsAsync(source);
+
+ diagnostics.Count(d => d.Id == "XPC2002").Should().Be(2);
+ }
+
+ [Fact]
+ public async Task Should_Report_For_Duplicate_Custom_Message_String()
+ {
+ var source = WrapPlugin(""""
+ RegisterStep("custom_DoThing", ExecutionStage.PostOperation, s => { });
+ RegisterStep("custom_DoThing", ExecutionStage.PostOperation, s => { });
+ """");
+
+ var diagnostics = await GetDiagnosticsAsync(source);
+
+ diagnostics.Should().ContainSingle(d => d.Id == "XPC2002");
+ }
+
+ [Fact]
+ public async Task Should_Report_For_Duplicate_RegisterPluginStep()
+ {
+ var source = WrapPlugin("""
+ RegisterPluginStep(EventOperation.Update, ExecutionStage.PostOperation, ctx => { });
+ RegisterPluginStep(EventOperation.Update, ExecutionStage.PostOperation, ctx => { });
+ """);
+
+ var diagnostics = await GetDiagnosticsAsync(source);
+
+ diagnostics.Should().ContainSingle(d => d.Id == "XPC2002");
+ }
+
+ [Fact]
+ public async Task Should_Report_For_Duplicate_Const_Message_Name()
+ {
+ var source = WrapPlugin("""
+ const string Msg = "custom_DoThing";
+ RegisterStep(Msg, ExecutionStage.PostOperation, s => { });
+ RegisterStep(Msg, ExecutionStage.PostOperation, s => { });
+ """);
+
+ var diagnostics = await GetDiagnosticsAsync(source);
+
+ diagnostics.Should().ContainSingle(d => d.Id == "XPC2002");
+ }
+
+ [Fact]
+ public async Task Should_Report_For_Duplicate_Const_Enum_Operation()
+ {
+ var source = WrapPlugin("""
+ const EventOperation Op = EventOperation.Update;
+ RegisterStep(Op, ExecutionStage.PostOperation,
+ nameof(ITestService.HandleA));
+ RegisterStep(Op, ExecutionStage.PostOperation,
+ nameof(ITestService.HandleB));
+ """);
+
+ var diagnostics = await GetDiagnosticsAsync(source);
+
+ diagnostics.Should().ContainSingle(d => d.Id == "XPC2002");
+ }
+
+ [Fact]
+ public async Task Should_Not_Report_When_Const_Enum_Operations_Differ()
+ {
+ var source = WrapPlugin("""
+ const EventOperation Create = EventOperation.Create;
+ const EventOperation Update = EventOperation.Update;
+ RegisterStep(Create, ExecutionStage.PostOperation,
+ nameof(ITestService.HandleA));
+ RegisterStep(Update, ExecutionStage.PostOperation,
+ nameof(ITestService.HandleB));
+ """);
+
+ var diagnostics = await GetDiagnosticsAsync(source);
+
+ diagnostics.Should().NotContain(d => d.Id == "XPC2002");
+ }
+
+ [Fact]
+ public async Task Should_Report_When_Different_Const_Members_Share_Message_Value()
+ {
+ // Two distinct const string members with the same value, referenced via member access. The
+ // runtime/wrapper naming keys off the string value, so these are duplicates even though the
+ // member identifiers differ.
+ const string source = """
+ using System;
+ using Microsoft.Xrm.Sdk;
+ using XrmPluginCore;
+ using XrmPluginCore.Enums;
+ using Microsoft.Extensions.DependencyInjection;
+ using XrmPluginCore.Tests.Context.BusinessDomain;
+
+ namespace TestNamespace
+ {
+ public static class Msgs
+ {
+ public const string A = "custom_DoThing";
+ public const string B = "custom_DoThing";
+ }
+
+ public class TestPlugin : Plugin
+ {
+ public TestPlugin()
+ {
+ RegisterStep(Msgs.A, ExecutionStage.PostOperation, s => { });
+ RegisterStep(Msgs.B, ExecutionStage.PostOperation, s => { });
+ }
+ }
+ }
+ """;
+
+ var diagnostics = await GetDiagnosticsAsync(source);
+
+ diagnostics.Should().ContainSingle(d => d.Id == "XPC2002");
+ }
+
+ [Fact]
+ public async Task Should_Not_Report_When_Const_Members_Have_Different_Message_Values()
+ {
+ const string source = """
+ using System;
+ using Microsoft.Xrm.Sdk;
+ using XrmPluginCore;
+ using XrmPluginCore.Enums;
+ using Microsoft.Extensions.DependencyInjection;
+ using XrmPluginCore.Tests.Context.BusinessDomain;
+
+ namespace TestNamespace
+ {
+ public static class Msgs
+ {
+ public const string A = "custom_DoThingA";
+ public const string B = "custom_DoThingB";
+ }
+
+ public class TestPlugin : Plugin
+ {
+ public TestPlugin()
+ {
+ RegisterStep(Msgs.A, ExecutionStage.PostOperation, s => { });
+ RegisterStep(Msgs.B, ExecutionStage.PostOperation, s => { });
+ }
+ }
+ }
+ """;
+
+ var diagnostics = await GetDiagnosticsAsync(source);
+
+ diagnostics.Should().NotContain(d => d.Id == "XPC2002");
+ }
+
+ [Fact]
+ public async Task Should_Report_For_Duplicate_Undefined_Enum_Cast_Operation()
+ {
+ // An undefined enum value resolves via Enum.ToString() to its integral text ("999"), so two such
+ // registrations are still recognized as duplicates.
+ var source = WrapPlugin("""
+ RegisterStep((EventOperation)999, ExecutionStage.PostOperation,
+ nameof(ITestService.HandleA));
+ RegisterStep((EventOperation)999, ExecutionStage.PostOperation,
+ nameof(ITestService.HandleB));
+ """);
+
+ var diagnostics = await GetDiagnosticsAsync(source);
+
+ diagnostics.Should().ContainSingle(d => d.Id == "XPC2002");
+ }
+
+ [Fact]
+ public async Task Should_Not_Report_When_Undefined_Enum_Cast_Operations_Differ()
+ {
+ var source = WrapPlugin("""
+ RegisterStep((EventOperation)998, ExecutionStage.PostOperation,
+ nameof(ITestService.HandleA));
+ RegisterStep((EventOperation)999, ExecutionStage.PostOperation,
+ nameof(ITestService.HandleB));
+ """);
+
+ var diagnostics = await GetDiagnosticsAsync(source);
+
+ diagnostics.Should().NotContain(d => d.Id == "XPC2002");
+ }
+
+ [Fact]
+ public async Task Should_Report_When_Duplicate_Split_Across_Partial_Declarations()
+ {
+ const string source = """
+ using System;
+ using Microsoft.Xrm.Sdk;
+ using XrmPluginCore;
+ using XrmPluginCore.Enums;
+ using Microsoft.Extensions.DependencyInjection;
+ using XrmPluginCore.Tests.Context.BusinessDomain;
+
+ namespace TestNamespace
+ {
+ public partial class TestPlugin : Plugin
+ {
+ public TestPlugin()
+ {
+ RegisterStep(EventOperation.Update, ExecutionStage.PostOperation,
+ nameof(ITestService.HandleA));
+ RegisterExtra();
+ }
+ }
+
+ public partial class TestPlugin
+ {
+ private void RegisterExtra()
+ {
+ RegisterStep(EventOperation.Update, ExecutionStage.PostOperation,
+ nameof(ITestService.HandleB));
+ }
+ }
+
+ public interface ITestService { void HandleA(); void HandleB(); }
+ }
+ """;
+
+ var diagnostics = await GetDiagnosticsAsync(source);
+
+ diagnostics.Should().ContainSingle(d => d.Id == "XPC2002");
+ }
+
+ [Fact]
+ public async Task Should_Report_When_Derived_Re_Registers_Inherited_Tuple()
+ {
+ const string source = """
+ using System;
+ using Microsoft.Xrm.Sdk;
+ using XrmPluginCore;
+ using XrmPluginCore.Enums;
+ using Microsoft.Extensions.DependencyInjection;
+ using XrmPluginCore.Tests.Context.BusinessDomain;
+
+ namespace TestNamespace
+ {
+ public class BasePlugin : Plugin
+ {
+ public BasePlugin()
+ {
+ RegisterStep(EventOperation.Update, ExecutionStage.PostOperation,
+ nameof(ITestService.HandleA));
+ }
+ }
+
+ public class DerivedPlugin : BasePlugin
+ {
+ public DerivedPlugin()
+ {
+ RegisterStep(EventOperation.Update, ExecutionStage.PostOperation,
+ nameof(ITestService.HandleB));
+ }
+ }
+
+ public interface ITestService { void HandleA(); void HandleB(); }
+ }
+ """;
+
+ var diagnostics = await GetDiagnosticsAsync(source);
+
+ var diagnostic = diagnostics.Should().ContainSingle(d => d.Id == "XPC2002").Subject;
+ diagnostic.GetMessage().Should().Contain("DerivedPlugin");
+ }
+
+ [Fact]
+ public async Task Should_Not_Report_For_Non_Plugin_Class_With_RegisterStep_Method()
+ {
+ // A class that is not a Plugin but happens to have a generic RegisterStep method must not
+ // be flagged.
+ const string source = """
+ using XrmPluginCore.Enums;
+
+ namespace TestNamespace
+ {
+ public class NotAPlugin
+ {
+ public NotAPlugin()
+ {
+ RegisterStep