From b7bea688dff8ac53e221dd2818782482a7982a6b Mon Sep 17 00:00:00 2001 From: manusoft Date: Fri, 25 Sep 2026 14:24:18 +0400 Subject: [PATCH] Add ILINK002 duplicate-handler detection Refactor MissingHandlerAnalyzer to detect and report duplicate request handlers (ILINK002) while retaining ILINK001 for missing handlers. Split diagnostics into MissingHandlerRule and DuplicateHandlerRule (MissingHandlerId / DuplicateHandlerId), build handlersByRequest to map request -> handlers, and report ILINK002 on each handler when more than one exists. Clean up symbol/type checks and collection usage; SupportedDiagnostics now includes both rules. Bump Interlink.Analyzers to 1.6.0. Update Interlink.Sample to consume the analyzer as a ProjectReference (OutputItemType="Analyzer", ReferenceOutputAssembly="false"), remove NuGet analyzer ref, add Microsoft.AspNetCore.OpenApi, and keep EF Core packages. Docs and samples: add ILINK002 to AnalyzerReleases.Shipped.md and README with example code, clarify ILINK001 example, and tweak release notes formatting. Move GetSomthing into its own sample file and add two handlers to intentionally trigger the duplicate-handler diagnostic. Minor XML comment and formatting fixes. --- sample/Interlink.Sample/Features/CreatePet.cs | 2 - .../Interlink.Sample/Features/GetSomthing.cs | 17 ++++ .../Interlink.Sample/Interlink.Sample.csproj | 10 +-- .../AnalyzerReleases.Shipped.md | 12 ++- .../AnalyzerReleases.Unshipped.md | 2 +- .../Interlink.Analyzers.csproj | 2 +- .../MissingHandlerAnalyzer.cs | 89 ++++++++++++++----- src/Interlink.Analyzers/README.md | 26 +++++- 8 files changed, 123 insertions(+), 37 deletions(-) create mode 100644 sample/Interlink.Sample/Features/GetSomthing.cs diff --git a/sample/Interlink.Sample/Features/CreatePet.cs b/sample/Interlink.Sample/Features/CreatePet.cs index 5fbe0f7..f793fd0 100644 --- a/sample/Interlink.Sample/Features/CreatePet.cs +++ b/sample/Interlink.Sample/Features/CreatePet.cs @@ -19,5 +19,3 @@ public async Task Handle(Command request, CancellationToken cancellationTok } } } - -public record GetSomthing() : IRequest; \ No newline at end of file diff --git a/sample/Interlink.Sample/Features/GetSomthing.cs b/sample/Interlink.Sample/Features/GetSomthing.cs new file mode 100644 index 0000000..4472b44 --- /dev/null +++ b/sample/Interlink.Sample/Features/GetSomthing.cs @@ -0,0 +1,17 @@ +using Interlink.Contracts; + +namespace Interlink.Sample.Features; + +public record GetSomthing : IRequest; + +public class Handler1 : IRequestHandler +{ + public Task Handle(GetSomthing request, CancellationToken cancellationToken) + => Unit.Task; +} + +public class Handler2 : IRequestHandler // duplicate +{ + public Task Handle(GetSomthing request, CancellationToken cancellationToken) + => Unit.Task; +} \ No newline at end of file diff --git a/sample/Interlink.Sample/Interlink.Sample.csproj b/sample/Interlink.Sample/Interlink.Sample.csproj index 7a1d56d..896abe3 100644 --- a/sample/Interlink.Sample/Interlink.Sample.csproj +++ b/sample/Interlink.Sample/Interlink.Sample.csproj @@ -7,17 +7,17 @@ - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + - + + diff --git a/src/Interlink.Analyzers/AnalyzerReleases.Shipped.md b/src/Interlink.Analyzers/AnalyzerReleases.Shipped.md index 4df7488..7aa2134 100644 --- a/src/Interlink.Analyzers/AnalyzerReleases.Shipped.md +++ b/src/Interlink.Analyzers/AnalyzerReleases.Shipped.md @@ -1,10 +1,18 @@ ; Shipped analyzer releases ; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md -## Release 1.5.0 +## Release 1.5.1 ### New Rules Rule ID | Category | Severity | Notes --------|----------|----------|------- -ILINK001 | Interlink | Warning | Missing request handler \ No newline at end of file +ILINK001 | Interlink | Warning | Missing request handler + +## Release 1.6.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +ILINK002 | Interlink | Warning | Duplicate request handler diff --git a/src/Interlink.Analyzers/AnalyzerReleases.Unshipped.md b/src/Interlink.Analyzers/AnalyzerReleases.Unshipped.md index f2b7fad..d6d1ffd 100644 --- a/src/Interlink.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/Interlink.Analyzers/AnalyzerReleases.Unshipped.md @@ -1,2 +1,2 @@ ; Unshipped analyzer release -; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md \ No newline at end of file diff --git a/src/Interlink.Analyzers/Interlink.Analyzers.csproj b/src/Interlink.Analyzers/Interlink.Analyzers.csproj index 5da6210..7540cc5 100644 --- a/src/Interlink.Analyzers/Interlink.Analyzers.csproj +++ b/src/Interlink.Analyzers/Interlink.Analyzers.csproj @@ -9,7 +9,7 @@ Interlink.Analyzers Interlink.Analyzers Interlink.Analyzers - 1.5.4 + 1.6.0 Roslyn analyzer that detects requests without corresponding handlers for the Interlink mediator library. Copyright © 2025-2026 ManuHub Manojbabu diff --git a/src/Interlink.Analyzers/MissingHandlerAnalyzer.cs b/src/Interlink.Analyzers/MissingHandlerAnalyzer.cs index abab11f..f45c29f 100644 --- a/src/Interlink.Analyzers/MissingHandlerAnalyzer.cs +++ b/src/Interlink.Analyzers/MissingHandlerAnalyzer.cs @@ -7,19 +7,23 @@ namespace Interlink.Analyzers; /// -/// Analyzer that reports a diagnostic when a type implementing IRequest<TResponse> -/// has no corresponding IRequestHandler<TRequest, TResponse> in the compilation. +/// Analyzer that reports diagnostics for Interlink request/handler consistency: +/// +/// — request with no handler +/// — more than one handler for the same request +/// /// [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class MissingHandlerAnalyzer : DiagnosticAnalyzer { - /// - /// The diagnostic identifier for a missing request handler. - /// - public const string DiagnosticId = "ILINK001"; + /// Diagnostic id for a missing request handler. + public const string MissingHandlerId = "ILINK001"; - private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor( - id: DiagnosticId, + /// Diagnostic id for duplicate request handlers. + public const string DuplicateHandlerId = "ILINK002"; + + private static readonly DiagnosticDescriptor MissingHandlerRule = new DiagnosticDescriptor( + id: MissingHandlerId, title: "Missing request handler", messageFormat: "No handler found for request type '{0}'. Implement IRequestHandler<{0}, TResponse>.", category: "Interlink", @@ -29,9 +33,20 @@ public sealed class MissingHandlerAnalyzer : DiagnosticAnalyzer helpLinkUri: null, customTags: new[] { WellKnownDiagnosticTags.CompilationEnd }); + private static readonly DiagnosticDescriptor DuplicateHandlerRule = new DiagnosticDescriptor( + id: DuplicateHandlerId, + title: "Duplicate request handler", + messageFormat: "Multiple handlers found for request type '{0}'. Only one IRequestHandler should exist per request type.", + category: "Interlink", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Each IRequest should have exactly one IRequestHandler implementation in the compilation.", + helpLinkUri: null, + customTags: new[] { WellKnownDiagnosticTags.CompilationEnd }); + /// public override ImmutableArray SupportedDiagnostics => - ImmutableArray.Create(Rule); + ImmutableArray.Create(MissingHandlerRule, DuplicateHandlerRule); /// public override void Initialize(AnalysisContext context) @@ -48,16 +63,16 @@ private static void AnalyzeCompilation(CompilationAnalysisContext context) var requestInterface = compilation.GetTypeByMetadataName("Interlink.Contracts.IRequest`1"); var handlerInterface = compilation.GetTypeByMetadataName("Interlink.IRequestHandler`2"); - // Interlink not referenced in this compilation if (requestInterface is null || handlerInterface is null) return; var requestTypes = new List(); - var handledRequestTypes = new HashSet(SymbolEqualityComparer.Default); + // request type -> list of handler types that handle it + var handlersByRequest = new Dictionary>(SymbolEqualityComparer.Default); foreach (var type in GetAllTypes(compilation.GlobalNamespace)) { - if (type.TypeKind is not (TypeKind.Class or TypeKind.Struct)) + if (type.TypeKind != TypeKind.Class && type.TypeKind != TypeKind.Struct) continue; if (type.IsAbstract) @@ -65,36 +80,62 @@ private static void AnalyzeCompilation(CompilationAnalysisContext context) foreach (var iface in type.AllInterfaces) { - // Collect request types: class/record implementing IRequest if (SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, requestInterface) && iface.TypeArguments.Length == 1) { requestTypes.Add(type); } - // Collect handled request types from IRequestHandler if (SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, handlerInterface) && iface.TypeArguments.Length == 2 && iface.TypeArguments[0] is INamedTypeSymbol handledRequest) { - handledRequestTypes.Add(handledRequest); + if (!handlersByRequest.TryGetValue(handledRequest, out var list)) + { + list = new List(); + handlersByRequest[handledRequest] = list; + } + + list.Add(type); } } } + var reportedRequests = new HashSet(SymbolEqualityComparer.Default); + foreach (var requestType in requestTypes) { - if (handledRequestTypes.Contains(requestType)) - continue; + if (!handlersByRequest.TryGetValue(requestType, out var handlers) || handlers.Count == 0) + { + if (!reportedRequests.Add(requestType)) + continue; + + var location = requestType.Locations.FirstOrDefault(l => l.IsInSource) ?? Location.None; + context.ReportDiagnostic( + Diagnostic.Create( + MissingHandlerRule, + location, + requestType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat))); + } + } - var location = requestType.Locations.FirstOrDefault(l => l.IsInSource) ?? Location.None; + foreach (var pair in handlersByRequest) + { + if (pair.Value.Count <= 1) + continue; - var diagnostic = Diagnostic.Create( - Rule, - location, - requestType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)); + var requestName = pair.Key.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); - context.ReportDiagnostic(diagnostic); + // Report on each duplicate handler + foreach (var handlerType in pair.Value) + { + var location = handlerType.Locations.FirstOrDefault(l => l.IsInSource) ?? Location.None; + context.ReportDiagnostic( + Diagnostic.Create( + DuplicateHandlerRule, + location, + requestName)); + } } } @@ -123,4 +164,4 @@ private static IEnumerable GetAllTypesRecursive(INamedTypeSymb yield return t; } } -} \ No newline at end of file +} diff --git a/src/Interlink.Analyzers/README.md b/src/Interlink.Analyzers/README.md index 3bfb83d..2a618b8 100644 --- a/src/Interlink.Analyzers/README.md +++ b/src/Interlink.Analyzers/README.md @@ -16,12 +16,14 @@ This is a development dependency (analyzer only). It does not add any runtime as ## What it detects -**ILINK001** – Missing request handler +**ILINK001** – Missing request handler + +**ILINK002** – Duplicate request handler (more than one handler for the same request type) Raised when a type implements `IRequest` but no corresponding `IRequestHandler` is found in the compilation. -### Example +### Example of ILINK001 - No Handler ```csharp // Warning ILINK001: No handler found for request type 'GetAllPets.Query' @@ -38,6 +40,26 @@ public sealed class GetAllPetsHandler : IRequestHandler +{ + public Task Handle(GetSomthing request, CancellationToken cancellationToken) + => Unit.Task; +} + +public class Handler2 : IRequestHandler // duplicate +{ + public Task Handle(GetSomthing request, CancellationToken cancellationToken) + => Unit.Task; +} +``` + +Remove one of the handlers to clear the diagnostic. + ## License MIT © ManuHub \ No newline at end of file