From 87d5072f2b04145fcb27594d781c7eee0a097fcf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E8=90=BD=E7=AC=94?=
<46271592+Skymly@users.noreply.github.com>
Date: Thu, 4 Jun 2026 20:20:06 +0800
Subject: [PATCH 1/2] Add DP024 analyzer for unregistered handler
implementations.
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
1、Introduce UnregisteredHandlerAnalyzer aligned with DP006 and DP023
2、Recognize generic HandlerOrderAttribute metadata in attribute detection
3、Add analyzer tests and document DP024 in ChainOfResponsibility and ROADMAP
---
.pr-body.md | 11 +
AGENTS.md | 5 +-
.../HandlerAnalysisConstants.cs | 7 +
.../UnregisteredHandlerAnalyzer.cs | 198 ++++++++++++++++++
DesignPatterns.Diagnostics/DiagnosticIds.cs | 1 +
.../AnalyzerReleases.Unshipped.md | 1 +
docs/ChainOfResponsibility.md | 1 +
docs/ROADMAP.md | 2 +-
.../UnregisteredHandlerAnalyzerTests.cs | 115 ++++++++++
9 files changed, 338 insertions(+), 3 deletions(-)
create mode 100644 .pr-body.md
create mode 100644 DesignPatterns.Analyzers/HandlerAnalysisConstants.cs
create mode 100644 DesignPatterns.Analyzers/UnregisteredHandlerAnalyzer.cs
create mode 100644 tests/DesignPatterns.Analyzers.Tests/UnregisteredHandlerAnalyzerTests.cs
diff --git a/.pr-body.md b/.pr-body.md
new file mode 100644
index 0000000..d405af7
--- /dev/null
+++ b/.pr-body.md
@@ -0,0 +1,11 @@
+## Summary
+
+- Adds `samples/DependencyInjection.Sample` demonstrating generated `RegisterDi` for Strategy, Factory, and Handler pipelines.
+- Wires the sample into `DesignPatterns.slnx` and documents it in README / DEVELOPMENT.
+
+Closes #5
+
+## Test plan
+
+- [x] `dotnet build` + `dotnet run` on `DependencyInjection.Sample`
+- [ ] CI `BuildSamples` green
diff --git a/AGENTS.md b/AGENTS.md
index 395c2b2..f9b8a2f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -28,9 +28,9 @@
```
DesignPatterns.slnx
├── DesignPatterns/ # 运行时核心(netstandard2.0 + net8.0)
-├── DesignPatterns.Diagnostics/ # DiagnosticIds 常量(DP001–DP023)
+├── DesignPatterns.Diagnostics/ # DiagnosticIds 常量(DP001–DP024)
├── DesignPatterns.SourceGenerators/ # 增量源生成器
-├── DesignPatterns.Analyzers/ # DP006、DP023 Analyzer
+├── DesignPatterns.Analyzers/ # DP006、DP023、DP024 Analyzer
├── DesignPatterns.CodeFixes/ # CodeFixProvider
├── DesignPatterns.Extensions.DependencyInjection/ # MSDI 扩展 + DI 生成器 targets
├── DesignPatterns.Package/ # NuGet 元包(PackageId=DesignPatterns)
@@ -120,6 +120,7 @@ dotnet test DesignPatterns.slnx -c Release
| DP005、DP008–DP009 | HandlerOrder(生成器) |
| DP006 | 未注册策略(Analyzer) |
| DP023 | 未注册工厂(Analyzer) |
+| DP024 | 未注册 Handler(Analyzer) |
| DP010–DP015 | CompositePart(生成器) |
| DP016–DP019 | Decorator(生成器) |
| DP020–DP022 | RegisterFactory(生成器) |
diff --git a/DesignPatterns.Analyzers/HandlerAnalysisConstants.cs b/DesignPatterns.Analyzers/HandlerAnalysisConstants.cs
new file mode 100644
index 0000000..82c86d0
--- /dev/null
+++ b/DesignPatterns.Analyzers/HandlerAnalysisConstants.cs
@@ -0,0 +1,7 @@
+namespace DesignPatterns.Analyzers;
+
+internal static class HandlerAnalysisConstants
+{
+ internal const string HandlerOrderMetadataName = "DesignPatterns.Behavioral.HandlerOrderAttribute";
+ internal const string HandlerOrderGenericMetadataName = "DesignPatterns.Behavioral.HandlerOrderAttribute`1";
+}
diff --git a/DesignPatterns.Analyzers/UnregisteredHandlerAnalyzer.cs b/DesignPatterns.Analyzers/UnregisteredHandlerAnalyzer.cs
new file mode 100644
index 0000000..d97b960
--- /dev/null
+++ b/DesignPatterns.Analyzers/UnregisteredHandlerAnalyzer.cs
@@ -0,0 +1,198 @@
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using DesignPatterns.Diagnostics;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace DesignPatterns.Analyzers;
+
+///
+/// Reports concrete handler implementations that implement IHandler<TContext> for a registered context but lack [HandlerOrder].
+///
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public sealed class UnregisteredHandlerAnalyzer : DiagnosticAnalyzer
+{
+ private static readonly DiagnosticDescriptor Rule = new(
+ DiagnosticIds.HandlerOrderUnregisteredImplementation,
+ title: "Handler implementation is not registered",
+ messageFormat: "Type '{0}' implements IHandler<{1}> but is missing [HandlerOrder]",
+ category: "DesignPatterns.Analyzers",
+ defaultSeverity: DiagnosticSeverity.Info,
+ isEnabledByDefault: true);
+
+ ///
+ public override ImmutableArray SupportedDiagnostics =>
+ ImmutableArray.Create(Rule);
+
+ ///
+ public override void Initialize(AnalysisContext context)
+ {
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.EnableConcurrentExecution();
+ context.RegisterCompilationStartAction(OnCompilationStart);
+ }
+
+ private static void OnCompilationStart(CompilationStartAnalysisContext context)
+ {
+ var registeredContexts = CollectRegisteredContexts(context.Compilation);
+ if (registeredContexts.IsEmpty)
+ {
+ return;
+ }
+
+ context.RegisterSymbolAction(
+ symbolContext => AnalyzeNamedType(symbolContext, registeredContexts),
+ SymbolKind.NamedType);
+ }
+
+ private static void AnalyzeNamedType(SymbolAnalysisContext context, ImmutableHashSet registeredContexts)
+ {
+ if (context.Symbol is not INamedTypeSymbol typeSymbol)
+ {
+ return;
+ }
+
+ if (typeSymbol.TypeKind != TypeKind.Class || typeSymbol.IsAbstract)
+ {
+ return;
+ }
+
+ if (typeSymbol.DeclaredAccessibility == Accessibility.Private && typeSymbol.ContainingType is not null)
+ {
+ return;
+ }
+
+ foreach (var contextType in GetHandlerContextTypes(typeSymbol))
+ {
+ if (!registeredContexts.Contains(contextType))
+ {
+ continue;
+ }
+
+ if (HasHandlerOrderForContext(typeSymbol, contextType))
+ {
+ continue;
+ }
+
+ var location = typeSymbol.Locations.FirstOrDefault() ?? Location.None;
+ context.ReportDiagnostic(Diagnostic.Create(
+ Rule,
+ location,
+ typeSymbol.Name,
+ contextType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)));
+ }
+ }
+
+ private static ImmutableHashSet CollectRegisteredContexts(Compilation compilation)
+ {
+ var builder = ImmutableHashSet.CreateBuilder(SymbolEqualityComparer.Default);
+
+ foreach (var typeSymbol in GetAllTypes(compilation.Assembly.GlobalNamespace))
+ {
+ foreach (var contextType in GetHandlerOrderContextTypes(typeSymbol))
+ {
+ builder.Add(contextType);
+ }
+ }
+
+ return builder.ToImmutable();
+ }
+
+ private static IEnumerable GetHandlerContextTypes(INamedTypeSymbol typeSymbol)
+ {
+ foreach (var iface in typeSymbol.AllInterfaces)
+ {
+ if (iface.Name != "IHandler" || iface.TypeArguments.Length != 1)
+ {
+ continue;
+ }
+
+ if (iface.TypeArguments[0] is INamedTypeSymbol contextType)
+ {
+ yield return contextType;
+ }
+ }
+ }
+
+ private static bool HasHandlerOrderForContext(INamedTypeSymbol typeSymbol, INamedTypeSymbol contextType) =>
+ GetHandlerOrderContextTypes(typeSymbol).Any(
+ registeredContext => SymbolEqualityComparer.Default.Equals(registeredContext, contextType));
+
+ private static IEnumerable GetHandlerOrderContextTypes(INamedTypeSymbol typeSymbol)
+ {
+ foreach (var attribute in typeSymbol.GetAttributes())
+ {
+ if (!IsHandlerOrderAttribute(attribute.AttributeClass))
+ {
+ continue;
+ }
+
+ var context = TryGetContextFromAttribute(attribute);
+ if (context is not null)
+ {
+ yield return context;
+ }
+ }
+ }
+
+ private static bool IsHandlerOrderAttribute(INamedTypeSymbol? attributeClass)
+ {
+ if (attributeClass is null)
+ {
+ return false;
+ }
+
+ var metadataName = attributeClass.MetadataName;
+ if (metadataName == "HandlerOrderAttribute")
+ {
+ return true;
+ }
+
+ return attributeClass.OriginalDefinition.MetadataName == "HandlerOrderAttribute`1";
+ }
+
+ private static INamedTypeSymbol? TryGetContextFromAttribute(AttributeData attribute)
+ {
+ if (attribute.AttributeClass?.IsGenericType == true)
+ {
+ return attribute.AttributeClass.TypeArguments.Length == 1
+ ? attribute.AttributeClass.TypeArguments[0] as INamedTypeSymbol
+ : null;
+ }
+
+ if (attribute.ConstructorArguments.Length >= 2 &&
+ attribute.ConstructorArguments[1].Kind == TypedConstantKind.Type &&
+ attribute.ConstructorArguments[1].Value is INamedTypeSymbol nonGenericContext)
+ {
+ return nonGenericContext;
+ }
+
+ return null;
+ }
+
+ private static IEnumerable GetAllTypes(INamespaceSymbol namespaceSymbol)
+ {
+ foreach (var member in namespaceSymbol.GetMembers())
+ {
+ switch (member)
+ {
+ case INamespaceSymbol nestedNamespace:
+ foreach (var nested in GetAllTypes(nestedNamespace))
+ {
+ yield return nested;
+ }
+
+ break;
+ case INamedTypeSymbol typeSymbol:
+ yield return typeSymbol;
+ foreach (var nestedType in typeSymbol.GetTypeMembers())
+ {
+ yield return nestedType;
+ }
+
+ break;
+ }
+ }
+ }
+}
diff --git a/DesignPatterns.Diagnostics/DiagnosticIds.cs b/DesignPatterns.Diagnostics/DiagnosticIds.cs
index d9eb3e0..4e43d79 100644
--- a/DesignPatterns.Diagnostics/DiagnosticIds.cs
+++ b/DesignPatterns.Diagnostics/DiagnosticIds.cs
@@ -28,4 +28,5 @@ public static class DiagnosticIds
public const string RegisterFactoryContractMismatch = "DP021";
public const string RegisterFactoryMissingParameterlessConstructor = "DP022";
public const string RegisterFactoryUnregisteredImplementation = "DP023";
+ public const string HandlerOrderUnregisteredImplementation = "DP024";
}
diff --git a/DesignPatterns.SourceGenerators/AnalyzerReleases.Unshipped.md b/DesignPatterns.SourceGenerators/AnalyzerReleases.Unshipped.md
index 2d5260d..de0967e 100644
--- a/DesignPatterns.SourceGenerators/AnalyzerReleases.Unshipped.md
+++ b/DesignPatterns.SourceGenerators/AnalyzerReleases.Unshipped.md
@@ -25,3 +25,4 @@ DP021 | DesignPatterns.Generators | Error | Factory implementation does no
DP022 | DesignPatterns.Generators | Error | Factory implementation missing public parameterless constructor
DP006 | DesignPatterns.Analyzers | Info | Strategy implementation missing RegisterStrategy attribute
DP023 | DesignPatterns.Analyzers | Info | Factory implementation missing RegisterFactory attribute
+DP024 | DesignPatterns.Analyzers | Info | Handler implementation missing HandlerOrder attribute
diff --git a/docs/ChainOfResponsibility.md b/docs/ChainOfResponsibility.md
index 3fb0172..597742c 100644
--- a/docs/ChainOfResponsibility.md
+++ b/docs/ChainOfResponsibility.md
@@ -92,6 +92,7 @@ public static partial class RequestContextHandlerPipeline
| DP005 | 同一 context 下重复的 Order |
| DP008 | 未实现 `IHandler` |
| DP009 | 缺少 public 无参构造 |
+| DP024 | Info | Analyzer | 实现了 `IHandler` 但未加 `[HandlerOrder]`(该 context 已有其它 handler 注册) |
## DI 集成
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 18f0e2b..dc9f413 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -39,7 +39,7 @@ Decorator 设计详见 [Decorator.md](Decorator.md)。
- M2 Decorator:`IDecorator`、`DecoratorStackBuilder`、`[Decorator]` 生成器、DP016–019、Sample
- P0–P2:Factory 文档/示例、集成测试、CI、Analyzers/CodeFix 拆分、`DesignPatterns.Diagnostics`
- CodeFix:无参构造、接口实现、RegisterStrategy、RegisterFactory、ICompositeBuildable
-- DP006 未注册策略 Analyzer;DP023 未注册工厂 Analyzer
+- DP006 未注册策略 Analyzer;DP023 未注册工厂 Analyzer;DP024 未注册 Handler Analyzer
## 明确不做
diff --git a/tests/DesignPatterns.Analyzers.Tests/UnregisteredHandlerAnalyzerTests.cs b/tests/DesignPatterns.Analyzers.Tests/UnregisteredHandlerAnalyzerTests.cs
new file mode 100644
index 0000000..98f4459
--- /dev/null
+++ b/tests/DesignPatterns.Analyzers.Tests/UnregisteredHandlerAnalyzerTests.cs
@@ -0,0 +1,115 @@
+using DesignPatterns.Analyzers;
+using Microsoft.CodeAnalysis;
+
+namespace DesignPatterns.Analyzers.Tests;
+
+public sealed class UnregisteredHandlerAnalyzerTests
+{
+ [Fact]
+ public async Task ReportsDp024WhenImplementationMissingHandlerOrder()
+ {
+ const string source = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using DesignPatterns.Behavioral;
+
+ namespace TestAssembly;
+
+ public sealed class RequestContext
+ {
+ }
+
+ [HandlerOrder(10)]
+ public sealed class LoggingHandler : IHandler
+ {
+ public ValueTask InvokeAsync(
+ RequestContext context,
+ HandlerDelegate next,
+ CancellationToken cancellationToken = default) =>
+ default;
+ }
+
+ public sealed class AuditHandler : IHandler
+ {
+ public ValueTask InvokeAsync(
+ RequestContext context,
+ HandlerDelegate next,
+ CancellationToken cancellationToken = default) =>
+ default;
+ }
+ """;
+
+ var diagnostics = await AnalyzerTestContext.RunAnalyzersAsync(
+ source,
+ new UnregisteredHandlerAnalyzer());
+
+ Assert.Contains(
+ diagnostics,
+ diagnostic =>
+ diagnostic.Id == "DP024" &&
+ diagnostic.GetMessage().Contains("AuditHandler", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task DoesNotReportWhenHandlerOrderPresent()
+ {
+ const string source = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using DesignPatterns.Behavioral;
+
+ namespace TestAssembly;
+
+ public sealed class RequestContext
+ {
+ }
+
+ [HandlerOrder(10)]
+ public sealed class LoggingHandler : IHandler
+ {
+ public ValueTask InvokeAsync(
+ RequestContext context,
+ HandlerDelegate next,
+ CancellationToken cancellationToken = default) =>
+ default;
+ }
+ """;
+
+ var diagnostics = await AnalyzerTestContext.RunAnalyzersAsync(
+ source,
+ new UnregisteredHandlerAnalyzer());
+
+ Assert.DoesNotContain(diagnostics, diagnostic => diagnostic.Id == "DP024");
+ }
+
+ [Fact]
+ public async Task DoesNotReportWhenNoHandlerOrderInCompilation()
+ {
+ const string source = """
+ using System.Threading;
+ using System.Threading.Tasks;
+ using DesignPatterns.Behavioral;
+
+ namespace TestAssembly;
+
+ public sealed class RequestContext
+ {
+ }
+
+ public sealed class OrphanHandler : IHandler
+ {
+ public ValueTask InvokeAsync(
+ RequestContext context,
+ HandlerDelegate next,
+ CancellationToken cancellationToken = default) =>
+ default;
+ }
+ """;
+
+ var diagnostics = await AnalyzerTestContext.RunAnalyzersAsync(
+ source,
+ new UnregisteredHandlerAnalyzer());
+
+ Assert.DoesNotContain(diagnostics, diagnostic => diagnostic.Id == "DP024");
+ }
+}
From 30fe19edea0b073d09aa1421e601545d8f1233ac Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E8=90=BD=E7=AC=94?=
<46271592+Skymly@users.noreply.github.com>
Date: Thu, 4 Jun 2026 20:20:16 +0800
Subject: [PATCH 2/2] Remove accidental PR body draft from repository.
---
.pr-body.md | 11 -----------
1 file changed, 11 deletions(-)
delete mode 100644 .pr-body.md
diff --git a/.pr-body.md b/.pr-body.md
deleted file mode 100644
index d405af7..0000000
--- a/.pr-body.md
+++ /dev/null
@@ -1,11 +0,0 @@
-## Summary
-
-- Adds `samples/DependencyInjection.Sample` demonstrating generated `RegisterDi` for Strategy, Factory, and Handler pipelines.
-- Wires the sample into `DesignPatterns.slnx` and documents it in README / DEVELOPMENT.
-
-Closes #5
-
-## Test plan
-
-- [x] `dotnet build` + `dotnet run` on `DependencyInjection.Sample`
-- [ ] CI `BuildSamples` green