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