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
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(生成器) |
Expand Down
7 changes: 7 additions & 0 deletions DesignPatterns.Analyzers/HandlerAnalysisConstants.cs
Original file line number Diff line number Diff line change
@@ -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";
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HandlerAnalysisConstants never referenced

Low Severity

HandlerAnalysisConstants defines fully qualified HandlerOrder metadata name strings but nothing in the analyzers project references them. The new analyzer duplicates the generator’s naming approach in a dead file instead of sharing or using those constants.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 30fe19e. Configure here.

198 changes: 198 additions & 0 deletions DesignPatterns.Analyzers/UnregisteredHandlerAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Reports concrete handler implementations that implement <c>IHandler&lt;TContext&gt;</c> for a registered context but lack <c>[HandlerOrder]</c>.
/// </summary>
[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);

/// <inheritdoc />
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
ImmutableArray.Create(Rule);

/// <inheritdoc />
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<INamedTypeSymbol> 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<INamedTypeSymbol> CollectRegisteredContexts(Compilation compilation)
{
var builder = ImmutableHashSet.CreateBuilder<INamedTypeSymbol>(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<INamedTypeSymbol> 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<INamedTypeSymbol> 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";
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unqualified HandlerOrder attribute matching

Low Severity

IsHandlerOrderAttribute treats any attribute type whose short MetadataName is HandlerOrderAttribute (or generic HandlerOrderAttribute1) as the design-patterns attribute, without checking namespace. Sibling unregistered analyzers compare fully qualified metadata names, so another HandlerOrderAttribute` in the compilation can skew registered contexts or suppress or trigger DP024 incorrectly.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 30fe19e. Configure here.


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<INamedTypeSymbol> 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;
}
}
}
}
1 change: 1 addition & 0 deletions DesignPatterns.Diagnostics/DiagnosticIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions docs/ChainOfResponsibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ public static partial class RequestContextHandlerPipeline
| DP005 | 同一 context 下重复的 Order |
| DP008 | 未实现 `IHandler<TContext>` |
| DP009 | 缺少 public 无参构造 |
| DP024 | Info | Analyzer | 实现了 `IHandler<TContext>` 但未加 `[HandlerOrder]`(该 context 已有其它 handler 注册) |

## DI 集成

Expand Down
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

## 明确不做

Expand Down
Original file line number Diff line number Diff line change
@@ -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<RequestContext>(10)]
public sealed class LoggingHandler : IHandler<RequestContext>
{
public ValueTask InvokeAsync(
RequestContext context,
HandlerDelegate<RequestContext> next,
CancellationToken cancellationToken = default) =>
default;
}

public sealed class AuditHandler : IHandler<RequestContext>
{
public ValueTask InvokeAsync(
RequestContext context,
HandlerDelegate<RequestContext> 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<RequestContext>(10)]
public sealed class LoggingHandler : IHandler<RequestContext>
{
public ValueTask InvokeAsync(
RequestContext context,
HandlerDelegate<RequestContext> 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<RequestContext>
{
public ValueTask InvokeAsync(
RequestContext context,
HandlerDelegate<RequestContext> next,
CancellationToken cancellationToken = default) =>
default;
}
""";

var diagnostics = await AnalyzerTestContext.RunAnalyzersAsync(
source,
new UnregisteredHandlerAnalyzer());

Assert.DoesNotContain(diagnostics, diagnostic => diagnostic.Id == "DP024");
}
}
Loading