From c46ed92145a773043ac31d6b2fb70f393fd634c7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:49:16 +0000 Subject: [PATCH 1/2] Initial plan From 7f01dd9bc1c97be1d5da09d3bc050c662538febd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:58:39 +0000 Subject: [PATCH 2/2] Add HandlerStyleAnalyzer to enforce nservicebus_handler_style editorconfig setting Agent-Logs-Url: https://github.com/Particular/NServiceBus/sessions/600fd45c-bd1f-434f-8824-0164a89dc1d8 Co-authored-by: DavidBoike <427110+DavidBoike@users.noreply.github.com> --- .../DiagnosticIds.cs | 4 + .../Handlers/HandlerStyleAnalyzer.cs | 95 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 src/NServiceBus.Core.Analyzer/Handlers/HandlerStyleAnalyzer.cs diff --git a/src/NServiceBus.Core.Analyzer/DiagnosticIds.cs b/src/NServiceBus.Core.Analyzer/DiagnosticIds.cs index d006581ff8..40f66c6d29 100644 --- a/src/NServiceBus.Core.Analyzer/DiagnosticIds.cs +++ b/src/NServiceBus.Core.Analyzer/DiagnosticIds.cs @@ -51,4 +51,8 @@ public static class DiagnosticIds public const string ConventionBasedHandlerNoAccessibleConstructor = "NSB0036"; public const string ConventionBasedHandlerAmbiguousConstructor = "NSB0037"; public const string HandlerClassCannotBeStatic = "NSB0038"; + + // HandlerStyleAnalyzer + public const string HandlerStyleConventionRequired = "NSB0039"; + public const string HandlerStyleIHandleMessagesRequired = "NSB0040"; } \ No newline at end of file diff --git a/src/NServiceBus.Core.Analyzer/Handlers/HandlerStyleAnalyzer.cs b/src/NServiceBus.Core.Analyzer/Handlers/HandlerStyleAnalyzer.cs new file mode 100644 index 0000000000..f6823d73ef --- /dev/null +++ b/src/NServiceBus.Core.Analyzer/Handlers/HandlerStyleAnalyzer.cs @@ -0,0 +1,95 @@ +#nullable enable + +namespace NServiceBus.Core.Analyzer.Handlers; + +using System; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class HandlerStyleAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics => + [ConventionRequired, IHandleMessagesRequired]; + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterSymbolAction(static context => + { + if (context.Symbol is not INamedTypeSymbol { TypeKind: TypeKind.Class } classType) + { + return; + } + + var syntaxRef = classType.DeclaringSyntaxReferences.IsDefaultOrEmpty + ? null + : classType.DeclaringSyntaxReferences[0]; + + if (syntaxRef is null) + { + return; + } + + var configOptions = context.Options.AnalyzerConfigOptionsProvider.GetOptions(syntaxRef.SyntaxTree); + if (!configOptions.TryGetValue("nservicebus_handler_style", out var handlerStyle) || string.IsNullOrWhiteSpace(handlerStyle)) + { + return; + } + + if (!HandlerKnownTypes.TryGet(context.Compilation, out var knownTypes)) + { + return; + } + + // Sagas are not subject to handler style enforcement + if (classType.ImplementsGenericType(knownTypes.SagaBase)) + { + return; + } + + if (string.Equals(handlerStyle, "Conventions", StringComparison.OrdinalIgnoreCase)) + { + // When convention-based style is required, flag any class implementing IHandleMessages + if (classType.ImplementsGenericInterface(knownTypes.IHandleMessages)) + { + var location = classType.GetClassIdentifierLocation(context.CancellationToken); + if (location is not null) + { + context.ReportDiagnostic(Diagnostic.Create(ConventionRequired, location, classType.Name)); + } + } + } + else if (string.Equals(handlerStyle, "IHandleMessages", StringComparison.OrdinalIgnoreCase)) + { + // When interface-based style is required, flag any class with [Handler] that does not implement IHandleMessages + if (classType.HasAttribute(knownTypes.HandlerAttribute) && !classType.ImplementsGenericInterface(knownTypes.IHandleMessages)) + { + var location = classType.GetClassIdentifierLocation(context.CancellationToken); + if (location is not null) + { + context.ReportDiagnostic(Diagnostic.Create(IHandleMessagesRequired, location, classType.Name)); + } + } + } + }, SymbolKind.NamedType); + } + + static readonly DiagnosticDescriptor ConventionRequired = new( + id: DiagnosticIds.HandlerStyleConventionRequired, + title: "Handler should use convention-based style", + messageFormat: "Handler '{0}' implements IHandleMessages, but the codebase requires convention-based Handle methods. Convert the handler to use a convention-based Handle method instead.", + category: "NServiceBus.Handlers", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + static readonly DiagnosticDescriptor IHandleMessagesRequired = new( + id: DiagnosticIds.HandlerStyleIHandleMessagesRequired, + title: "Handler should implement IHandleMessages", + messageFormat: "Handler '{0}' does not implement IHandleMessages, but the codebase requires interface-based handlers. Convert the handler to implement IHandleMessages instead.", + category: "NServiceBus.Handlers", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); +}