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