From 9ee9cbefb7ba3af01bbb90ddbe5b0f674a57c6b4 Mon Sep 17 00:00:00 2001 From: Yander Santiesteban Rojas Date: Sat, 13 Jun 2026 22:32:39 -0300 Subject: [PATCH 01/11] fix(analyzer): widen DSOFT006 location to the full type header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anchor the PreferCqrsInterface (DSOFT006) suggestion to the whole type header — identifier through base list — instead of just the type name, so the IDE offers the ConvertToCqrs lightbulb when hovering the offending IRequest base type too. DSOFT006 stays Info severity (suggestion dots, no squiggle); this only widens the lightbulb reach. Mirrors CqrsSemanticAnalyzerEnterprise (DiagnosticLocations.TypeHeader). Analyzer-only change: emits no source and does not alter compiled output, so there is no runtime impact. --- .../CqrsSemanticAnalyzer.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/DSoftStudio.Mediator.Generators/CqrsSemanticAnalyzer.cs b/src/DSoftStudio.Mediator.Generators/CqrsSemanticAnalyzer.cs index cdf1420..8f12f00 100644 --- a/src/DSoftStudio.Mediator.Generators/CqrsSemanticAnalyzer.cs +++ b/src/DSoftStudio.Mediator.Generators/CqrsSemanticAnalyzer.cs @@ -72,11 +72,22 @@ public void Initialize(IncrementalGeneratorInitializationContext context) if (!implementsRequest || hasCqrsMarker) return default; + // Span the whole type header — identifier through base list — + // so the IDE offers the ConvertToCqrs fix when hovering the + // offending `IRequest` base type too, not just the type + // name. DSOFT006 is Info severity: VS renders suggestion dots + // only at the span start, so the wider span adds lightbulb + // reach without squiggle noise. Mirrors the Enterprise + // CqrsSemanticAnalyzerEnterprise (DiagnosticLocations.TypeHeader). + var headerSpan = typeDecl.BaseList is { } baseList + ? TextSpan.FromBounds(typeDecl.Identifier.SpanStart, baseList.Span.End) + : typeDecl.Identifier.Span; + return new CqrsCandidate( symbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), responseType ?? "TResponse", typeDecl.SyntaxTree.FilePath, - typeDecl.Identifier.Span); + headerSpan); }) .Where(static c => c.FilePath is not null); From c37b919b171036677531f6efa885c0fd4be21785 Mon Sep 17 00:00:00 2001 From: Yander Santiesteban Rojas Date: Wed, 17 Jun 2026 23:03:45 -0300 Subject: [PATCH 02/11] feat(analyzer): add DSOFT008 + fix DSOFT007 via DiagnosticAnalyzer conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registration-API analyzer was an [Generator], which cannot see other generators' output. So it could not resolve the generated RegisterMediatorHandlers() or the AddMediator(builder) overload — DSOFT007 was effectively dead in real projects (it only fired against hand-written stubs in tests). Convert it to a DiagnosticAnalyzer: it runs after all source generators on the final compilation, so the semantic model resolves the generated members. Analysis is now per method body (operation-block) instead of compilation-wide. - Fixes DSOFT007: now fires on real builds, not just stubs. - Adds DSOFT008: parameterless AddMediator() that never registers handlers in the scope (no builder overload, no RegisterMediatorHandlers(), no manual AddTransient,...>()) while handlers exist -> the runtime 'No service for IRequestHandler<...>' failure, caught at compile time. - Recognizes manual handler registration to avoid false positives. - Analyzer-only change: the runtime assembly and generated code are untouched (zero runtime/allocation impact). Tests: stub logic tests rerun via WithAnalyzers; new high-fidelity integration tests run the real DependencyInjectionGenerator + analyzer to lock the regression. Deliberate 'no handler' self-tests suppressed with #pragma. Full suite green. --- .../AnalyzerReleases.Unshipped.md | 5 + .../DiagnosticDescriptors.cs | 17 + .../MixedRegistrationApiAnalyzer.cs | 395 ++++++++++++------ .../MixedRegistrationApiAnalyzerTests.cs | 173 +++++++- .../MixedRegistrationApiIntegrationTests.cs | 245 +++++++++++ .../Coverage/MissingPathsCoverageTests.cs | 2 + .../EdgeCases/MissingPipelineTests.cs | 5 +- .../Security/HandlerSpoofingTests.cs | 3 + .../Validation/HandlerValidationTests.cs | 4 + 9 files changed, 713 insertions(+), 136 deletions(-) create mode 100644 tests/DSoftStudio.Mediator.Tests/Analyzers/MixedRegistrationApiIntegrationTests.cs diff --git a/src/DSoftStudio.Mediator.Generators/AnalyzerReleases.Unshipped.md b/src/DSoftStudio.Mediator.Generators/AnalyzerReleases.Unshipped.md index 018ff4a..4201af7 100644 --- a/src/DSoftStudio.Mediator.Generators/AnalyzerReleases.Unshipped.md +++ b/src/DSoftStudio.Mediator.Generators/AnalyzerReleases.Unshipped.md @@ -1,3 +1,8 @@ ; Unshipped analyzer changes ; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +DSOFT008 | DSoftStudio.Mediator.Usage | Warning | AddMediator() registers core services but no handlers diff --git a/src/DSoftStudio.Mediator.Generators/DiagnosticDescriptors.cs b/src/DSoftStudio.Mediator.Generators/DiagnosticDescriptors.cs index ede0ab3..bdba11d 100644 --- a/src/DSoftStudio.Mediator.Generators/DiagnosticDescriptors.cs +++ b/src/DSoftStudio.Mediator.Generators/DiagnosticDescriptors.cs @@ -92,5 +92,22 @@ internal static class DiagnosticDescriptors + "when using the builder overload causes double registration. " + "Use either the builder overload (recommended) or the individual methods, " + "but not both."); + + public static readonly DiagnosticDescriptor MissingHandlerRegistration = new( + id: "DSOFT008", + title: "AddMediator() registers core services but no handlers", + messageFormat: "'AddMediator()' registers only the core services and leaves handlers unregistered. " + + "Use 'AddMediator(builder => { })' (recommended) or chain '.RegisterMediatorHandlers()'. " + + "Otherwise handler resolution throws at runtime (\"No service for type IRequestHandler<...>\").", + category: "DSoftStudio.Mediator.Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "The parameterless AddMediator() overload registers only the core mediator services " + + "(IMediator / ISender / IPublisher). It does not register request, notification, or " + + "stream handlers. When handlers exist in the compilation (locally or in referenced " + + "assemblies) but neither AddMediator(Action) nor " + + "RegisterMediatorHandlers() is called, those handlers are never added to DI and the " + + "first dispatch fails at runtime. Use the builder overload (single entry point) or " + + "call RegisterMediatorHandlers() explicitly."); } } diff --git a/src/DSoftStudio.Mediator.Generators/MixedRegistrationApiAnalyzer.cs b/src/DSoftStudio.Mediator.Generators/MixedRegistrationApiAnalyzer.cs index 1ddd78b..425be28 100644 --- a/src/DSoftStudio.Mediator.Generators/MixedRegistrationApiAnalyzer.cs +++ b/src/DSoftStudio.Mediator.Generators/MixedRegistrationApiAnalyzer.cs @@ -2,144 +2,223 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. using System; -using System.Linq; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; namespace DSoftStudio.Mediator.Generators; /// -/// Incremental generator that detects mixed usage of the mediator registration APIs. +/// Validates how the mediator registration APIs are used, per registration scope (method body). /// -/// AddMediator(Action<MediatorBuilder>) is a single entry point that -/// registers core services, handlers, and precompiled pipelines in one call. -/// Calling RegisterMediatorHandlers() or PrecompilePipelines() -/// separately when using the builder overload causes double registration. +/// AddMediator(Action<MediatorBuilder>) is a single entry point that registers core +/// services, handlers, and precompiled pipelines in one call. Calling +/// RegisterMediatorHandlers() or PrecompilePipelines() alongside it is redundant. +/// The parameterless AddMediator() registers only the core services; handlers must then be +/// registered with RegisterMediatorHandlers() (or manually) or the build will fail at +/// runtime on the first dispatch. /// /// -/// Emits DSOFT007 when both the builder overload and individual -/// registration methods are detected in the same compilation. +/// Emits: +/// +/// DSOFT007 — the builder overload is used together with the individual registration +/// methods in the same scope (redundant / double registration). +/// DSOFT008 — the parameterless AddMediator() is used in a scope that never +/// registers handlers (no builder overload, no RegisterMediatorHandlers(), and no manual +/// AddTransient<IRequestHandler<,>,…>()), while handlers exist in the +/// compilation — the handlers are left unregistered. +/// +/// +/// +/// This is a (not a source generator) on purpose: the builder +/// overload, RegisterMediatorHandlers(), and PrecompilePipelines() are emitted by +/// sibling source generators. A generator cannot see another generator's output, so it could not +/// resolve those calls. An analyzer runs after all generators, on the final compilation, so the +/// semantic model resolves the generated members correctly. /// /// -[Generator] -public sealed class MixedRegistrationApiAnalyzer : IIncrementalGenerator +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class MixedRegistrationApiAnalyzer : DiagnosticAnalyzer { - public void Initialize(IncrementalGeneratorInitializationContext context) - { - // Scan all invocation expressions in user code (non-generated) for - // mediator registration method calls. - var registrationCalls = context.SyntaxProvider - .CreateSyntaxProvider( - predicate: static (node, _) => IsRegistrationCandidate(node), - transform: static (ctx, ct) => - { - var invocation = (InvocationExpressionSyntax)ctx.Node; - var symbol = ctx.SemanticModel.GetSymbolInfo(invocation, ct).Symbol as IMethodSymbol; - if (symbol is null) - return default; + private const string MediatorNamespacePrefix = "DSoftStudio.Mediator"; - var name = symbol.Name; + private const string MediatorHandlerRegistrationAttributeFullName = + "DSoftStudio.Mediator.Abstractions.MediatorHandlerRegistrationAttribute"; - if (name == "AddMediator" && HasMediatorBuilderParameter(symbol)) - { - return new RegistrationCall(RegistrationCallKind.BuilderOverload, default); - } + private const string RequestHandlerMetadataName = + "DSoftStudio.Mediator.Abstractions.IRequestHandler`2"; + private const string NotificationHandlerMetadataName = + "DSoftStudio.Mediator.Abstractions.INotificationHandler`1"; + private const string StreamHandlerMetadataName = + "DSoftStudio.Mediator.Abstractions.IStreamRequestHandler`2"; + private const string RequestMetadataName = + "DSoftStudio.Mediator.Abstractions.IRequest`1"; - if (name == "RegisterMediatorHandlers") - { - return new RegistrationCall( - RegistrationCallKind.RegisterHandlers, - invocation.GetLocation()); - } + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create( + DiagnosticDescriptors.MixedRegistrationApi, + DiagnosticDescriptors.MissingHandlerRegistration); - if (name == "PrecompilePipelines") - { - return new RegistrationCall( - RegistrationCallKind.PrecompilePipelines, - invocation.GetLocation()); - } + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); - return default; - }) - .Where(static c => c.Kind != RegistrationCallKind.None); + context.RegisterCompilationStartAction(compilationStart => + { + var compilation = compilationStart.Compilation; - var collected = registrationCalls.Collect(); + var requestHandler = compilation.GetTypeByMetadataName(RequestHandlerMetadataName); + var notificationHandler = compilation.GetTypeByMetadataName(NotificationHandlerMetadataName); + var streamHandler = compilation.GetTypeByMetadataName(StreamHandlerMetadataName); - context.RegisterSourceOutput(collected, static (spc, calls) => - { - if (calls.IsDefaultOrEmpty) + // Mediator abstractions not referenced → nothing this analyzer can flag. + if (requestHandler is null && notificationHandler is null && streamHandler is null) return; - bool hasBuilderOverload = false; + // Computed once per compilation: are there handlers that need registering? + // DSOFT008 only matters when there are. + bool hasHandlers = CompilationHasHandlers( + compilation, requestHandler, notificationHandler, streamHandler, + compilationStart.CancellationToken); - foreach (var call in calls) + compilationStart.RegisterOperationBlockStartAction(blockStart => { - if (call.Kind == RegistrationCallKind.BuilderOverload) + // Per-scope (method body) state. Each operation-block-start scope gets its own + // closure instance, so this is isolated per method. + var gate = new object(); + var parameterlessAddMediator = new List(); + var redundantCalls = new List<(Location Location, string Method, string Action)>(); + bool hasBuilderOverload = false; + bool hasRegisterHandlers = false; + bool hasManualHandlerRegistration = false; + + blockStart.RegisterOperationAction(opContext => { - hasBuilderOverload = true; - break; - } - } + var invocation = (IInvocationOperation)opContext.Operation; + var method = invocation.TargetMethod; + if (method is null) + return; - if (!hasBuilderOverload) - return; + // Manual handler registration — e.g. services.AddTransient, H>() + // or services.AddSingleton>(instance) — means handlers ARE + // registered in this scope, so DSOFT008 must not fire. + if (IsManualHandlerRegistration(method, requestHandler, notificationHandler, streamHandler)) + { + lock (gate) { hasManualHandlerRegistration = true; } + return; + } - // Report DSOFT007 on each redundant individual call. - foreach (var call in calls) - { - switch (call.Kind) + // Only the mediator's own registration methods (avoids matching an unrelated + // method that happens to share a name). + var ns = method.ContainingNamespace?.ToDisplayString(); + if (ns is null || !ns.StartsWith(MediatorNamespacePrefix, StringComparison.Ordinal)) + return; + + var location = invocation.Syntax.GetLocation(); + + switch (method.Name) + { + case "AddMediator": + if (HasMediatorBuilderParameter(method)) + lock (gate) { hasBuilderOverload = true; } + else + lock (gate) { parameterlessAddMediator.Add(location); } + break; + + case "RegisterMediatorHandlers": + lock (gate) + { + hasRegisterHandlers = true; + redundantCalls.Add((location, "RegisterMediatorHandlers()", "registers handlers")); + } + break; + + case "PrecompilePipelines": + lock (gate) + redundantCalls.Add((location, "PrecompilePipelines()", "precompiles pipelines")); + break; + } + }, OperationKind.Invocation); + + blockStart.RegisterOperationBlockEndAction(blockEnd => { - case RegistrationCallKind.RegisterHandlers: - spc.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.MixedRegistrationApi, - call.Location, - "RegisterMediatorHandlers()", - "registers handlers")); - break; - - case RegistrationCallKind.PrecompilePipelines: - spc.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.MixedRegistrationApi, - call.Location, - "PrecompilePipelines()", - "precompiles pipelines")); - break; - } - } + // ── DSOFT007: redundant individual call alongside the builder overload ── + if (hasBuilderOverload) + { + foreach (var (location, method, action) in redundantCalls) + blockEnd.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.MixedRegistrationApi, location, method, action)); + } + + // ── DSOFT008: parameterless AddMediator() leaves handlers unregistered ── + // Only when this scope never registers handlers by any means, and there are + // handlers in the compilation that would otherwise be registered. + if (!hasBuilderOverload + && !hasRegisterHandlers + && !hasManualHandlerRegistration + && hasHandlers) + { + foreach (var location in parameterlessAddMediator) + blockEnd.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.MissingHandlerRegistration, location)); + } + }); + }); }); } /// - /// Fast syntactic filter: matches invocations of AddMediator, - /// RegisterMediatorHandlers, or PrecompilePipelines. + /// Checks whether the method has an Action<MediatorBuilder> parameter, + /// identifying the builder overload of AddMediator. Works for both the reduced + /// extension-method form and the static invocation form. /// - private static bool IsRegistrationCandidate(SyntaxNode node) + private static bool HasMediatorBuilderParameter(IMethodSymbol method) { - if (node is not InvocationExpressionSyntax invocation) - return false; - - string? name = invocation.Expression switch + foreach (var param in method.Parameters) { - MemberAccessExpressionSyntax memberAccess => memberAccess.Name.Identifier.Text, - IdentifierNameSyntax identifier => identifier.Identifier.Text, - _ => null - }; + if (param.Type is INamedTypeSymbol { Name: "Action", TypeArguments.Length: 1 } actionType + && actionType.TypeArguments[0].Name == "MediatorBuilder") + { + return true; + } + } - return name is "AddMediator" or "RegisterMediatorHandlers" or "PrecompilePipelines"; + return false; } /// - /// Checks whether the method has an Action<MediatorBuilder> parameter, - /// identifying the builder overload of AddMediator. - /// Works for both reduced extension method form and static invocation form. + /// Detects a manual DI registration of a mediator handler, e.g. + /// services.AddTransient<IRequestHandler<X,Y>, H>() or + /// services.AddSingleton<INotificationHandler<N>>(instance). Such a call + /// registers handlers without RegisterMediatorHandlers(), so DSOFT008 must not fire. /// - private static bool HasMediatorBuilderParameter(IMethodSymbol method) + private static bool IsManualHandlerRegistration( + IMethodSymbol method, + INamedTypeSymbol? requestHandler, + INamedTypeSymbol? notificationHandler, + INamedTypeSymbol? streamHandler) { - foreach (var param in method.Parameters) + switch (method.Name) { - if (param.Type is INamedTypeSymbol { Name: "Action", TypeArguments.Length: 1 } actionType - && actionType.TypeArguments[0].Name == "MediatorBuilder") + case "AddSingleton": + case "AddTransient": + case "AddScoped": + case "TryAddSingleton": + case "TryAddTransient": + case "TryAddScoped": + break; + default: + return false; + } + + foreach (var typeArg in method.TypeArguments) + { + if (typeArg is INamedTypeSymbol named && IsHandlerInterface( + named.OriginalDefinition, requestHandler, notificationHandler, streamHandler)) { return true; } @@ -148,34 +227,114 @@ private static bool HasMediatorBuilderParameter(IMethodSymbol method) return false; } - private enum RegistrationCallKind : byte + private static bool IsHandlerInterface( + ITypeSymbol definition, + INamedTypeSymbol? requestHandler, + INamedTypeSymbol? notificationHandler, + INamedTypeSymbol? streamHandler) + => (requestHandler is not null && SymbolEqualityComparer.Default.Equals(definition, requestHandler)) + || (notificationHandler is not null && SymbolEqualityComparer.Default.Equals(definition, notificationHandler)) + || (streamHandler is not null && SymbolEqualityComparer.Default.Equals(definition, streamHandler)); + + // ── Handler-existence detection (compilation-wide, for DSOFT008) ────────────── + + private static bool CompilationHasHandlers( + Compilation compilation, + INamedTypeSymbol? requestHandler, + INamedTypeSymbol? notificationHandler, + INamedTypeSymbol? streamHandler, + CancellationToken ct) { - None = 0, - BuilderOverload, - RegisterHandlers, - PrecompilePipelines + // Fast path: the DI generator emits [assembly: MediatorHandlerRegistration] for every + // local handler / self-handler. Present in any real build where handlers exist. + var attribute = compilation.GetTypeByMetadataName(MediatorHandlerRegistrationAttributeFullName); + if (attribute is not null) + { + foreach (var attr in compilation.Assembly.GetAttributes()) + { + if (SymbolEqualityComparer.Default.Equals(attr.AttributeClass, attribute)) + return true; + } + } + + // Handlers contributed by referenced assemblies (clean-architecture / modular setups). + if (ReferencedAssemblyScanner.GetExternalDIHandlers(compilation).Handlers.Count > 0) + return true; + + // Fallback: scan source types directly — covers the case where the DI generator did not + // run (e.g. analyzer-only unit tests, or the generator suppressed). + var request = compilation.GetTypeByMetadataName(RequestMetadataName); + return ContainsHandler( + compilation.Assembly.GlobalNamespace, + requestHandler, notificationHandler, streamHandler, request, ct); } - private readonly struct RegistrationCall( - MixedRegistrationApiAnalyzer.RegistrationCallKind kind, - Location? location) : System.IEquatable + private static bool ContainsHandler( + INamespaceSymbol ns, + INamedTypeSymbol? requestHandler, + INamedTypeSymbol? notificationHandler, + INamedTypeSymbol? streamHandler, + INamedTypeSymbol? request, + CancellationToken ct) { - public RegistrationCallKind Kind { get; } = kind; - public Location? Location { get; } = location; + foreach (var type in ns.GetTypeMembers()) + { + if (TypeOrNestedIsHandler(type, requestHandler, notificationHandler, streamHandler, request, ct)) + return true; + } - // Equality by kind + source location to support Distinct() in the pipeline. - public bool Equals(RegistrationCall other) => - Kind == other.Kind && Equals(Location, other.Location); + foreach (var child in ns.GetNamespaceMembers()) + { + if (ContainsHandler(child, requestHandler, notificationHandler, streamHandler, request, ct)) + return true; + } - public override bool Equals(object obj) => - obj is RegistrationCall other && Equals(other); + return false; + } + + private static bool TypeOrNestedIsHandler( + INamedTypeSymbol type, + INamedTypeSymbol? requestHandler, + INamedTypeSymbol? notificationHandler, + INamedTypeSymbol? streamHandler, + INamedTypeSymbol? request, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + if (IsConcreteHandlerType(type, requestHandler, notificationHandler, streamHandler, request, ct)) + return true; - public override int GetHashCode() + foreach (var nested in type.GetTypeMembers()) { - unchecked - { - return ((int)Kind * 397) ^ (Location?.GetHashCode() ?? 0); - } + if (TypeOrNestedIsHandler(nested, requestHandler, notificationHandler, streamHandler, request, ct)) + return true; } + + return false; + } + + private static bool IsConcreteHandlerType( + INamedTypeSymbol type, + INamedTypeSymbol? requestHandler, + INamedTypeSymbol? notificationHandler, + INamedTypeSymbol? streamHandler, + INamedTypeSymbol? request, + CancellationToken ct) + { + if (type.TypeKind != TypeKind.Class || type.IsAbstract) + return false; + + foreach (var iface in type.AllInterfaces) + { + if (IsHandlerInterface(iface.OriginalDefinition, requestHandler, notificationHandler, streamHandler)) + return true; + } + + // Self-handling request type (IRequest + static Execute), registered as an adapter. + if (request is not null && HandlerDiscovery.TryGetSelfHandlingRequest(type, ct, out _)) + return true; + + return false; } } diff --git a/tests/DSoftStudio.Mediator.Tests/Analyzers/MixedRegistrationApiAnalyzerTests.cs b/tests/DSoftStudio.Mediator.Tests/Analyzers/MixedRegistrationApiAnalyzerTests.cs index 3b47df5..c106239 100644 --- a/tests/DSoftStudio.Mediator.Tests/Analyzers/MixedRegistrationApiAnalyzerTests.cs +++ b/tests/DSoftStudio.Mediator.Tests/Analyzers/MixedRegistrationApiAnalyzerTests.cs @@ -1,9 +1,11 @@ // Copyright (c) DSoftStudio. All rights reserved. // Licensed under the MIT License. See LICENSE in the project root for license information. +using System.Collections.Immutable; using DSoftStudio.Mediator.Generators; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; namespace DSoftStudio.Mediator.Tests.Analyzers; @@ -11,6 +13,7 @@ namespace DSoftStudio.Mediator.Tests.Analyzers; /// Verifies that emits the correct diagnostics: /// /// DSOFT007 — mixed registration API usage (AddMediator(configure) + RegisterMediatorHandlers/PrecompilePipelines) +/// DSOFT008 — parameterless AddMediator() with handlers present but no RegisterMediatorHandlers()/builder call /// /// public class MixedRegistrationApiAnalyzerTests @@ -46,7 +49,7 @@ public MediatorBuilder(Microsoft.Extensions.DependencyInjection.IServiceCollecti public static class ServiceCollectionExtensions { public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddMediator( - Microsoft.Extensions.DependencyInjection.IServiceCollection services) => services; + this Microsoft.Extensions.DependencyInjection.IServiceCollection services) => services; } } @@ -70,7 +73,7 @@ public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddMed } """; - private static GeneratorRunResult RunAnalyzer(string userSource) + private static ImmutableArray RunAnalyzer(string userSource) { var syntaxTrees = new[] { @@ -89,14 +92,12 @@ private static GeneratorRunResult RunAnalyzer(string userSource) references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - var analyzer = new MixedRegistrationApiAnalyzer(); - - GeneratorDriver driver = CSharpGeneratorDriver.Create( - generators: new IIncrementalGenerator[] { analyzer } - .Select(GeneratorExtensions.AsSourceGenerator)); + // Run as a DiagnosticAnalyzer (not a generator): this mirrors the real build, where + // the analyzer runs after all source generators on the final compilation. + var withAnalyzers = compilation.WithAnalyzers( + ImmutableArray.Create(new MixedRegistrationApiAnalyzer())); - driver = driver.RunGenerators(compilation); - return driver.GetRunResult().Results.Single(); + return withAnalyzers.GetAnalyzerDiagnosticsAsync().GetAwaiter().GetResult(); } // ── DSOFT007: Mixed registration API ────────────────────────── @@ -119,8 +120,8 @@ public void Configure(Microsoft.Extensions.DependencyInjection.IServiceCollectio var result = RunAnalyzer(source); - result.Diagnostics.ShouldContain(d => d.Id == "DSOFT007"); - var diag = result.Diagnostics.First(d => d.Id == "DSOFT007"); + result.ShouldContain(d => d.Id == "DSOFT007"); + var diag = result.First(d => d.Id == "DSOFT007"); diag.GetMessage().ShouldContain("RegisterMediatorHandlers()"); diag.GetMessage().ShouldContain("registers handlers"); } @@ -143,8 +144,8 @@ public void Configure(Microsoft.Extensions.DependencyInjection.IServiceCollectio var result = RunAnalyzer(source); - result.Diagnostics.ShouldContain(d => d.Id == "DSOFT007"); - var diag = result.Diagnostics.First(d => d.Id == "DSOFT007"); + result.ShouldContain(d => d.Id == "DSOFT007"); + var diag = result.First(d => d.Id == "DSOFT007"); diag.GetMessage().ShouldContain("PrecompilePipelines()"); diag.GetMessage().ShouldContain("precompiles pipelines"); } @@ -168,7 +169,7 @@ public void Configure(Microsoft.Extensions.DependencyInjection.IServiceCollectio var result = RunAnalyzer(source); - var dsoft007 = result.Diagnostics.Where(d => d.Id == "DSOFT007").ToList(); + var dsoft007 = result.Where(d => d.Id == "DSOFT007").ToList(); dsoft007.Count.ShouldBe(2); dsoft007.ShouldContain(d => d.GetMessage().Contains("RegisterMediatorHandlers()")); dsoft007.ShouldContain(d => d.GetMessage().Contains("PrecompilePipelines()")); @@ -191,7 +192,7 @@ public void Configure(Microsoft.Extensions.DependencyInjection.IServiceCollectio var result = RunAnalyzer(source); - result.Diagnostics.ShouldNotContain(d => d.Id == "DSOFT007"); + result.ShouldNotContain(d => d.Id == "DSOFT007"); } [Fact] @@ -213,7 +214,7 @@ public void Configure(Microsoft.Extensions.DependencyInjection.IServiceCollectio var result = RunAnalyzer(source); - result.Diagnostics.ShouldNotContain(d => d.Id == "DSOFT007"); + result.ShouldNotContain(d => d.Id == "DSOFT007"); } [Fact] @@ -228,6 +229,144 @@ public void DoStuff() { } var result = RunAnalyzer(source); - result.Diagnostics.ShouldNotContain(d => d.Id == "DSOFT007"); + result.ShouldNotContain(d => d.Id == "DSOFT007"); + } + + // ── DSOFT008: parameterless AddMediator() leaves handlers unregistered ── + + [Fact] + public void Emits_DSOFT008_When_Parameterless_AddMediator_And_Handler_Without_RegisterHandlers() + { + const string source = """ + using DSoftStudio.Mediator; + + public sealed record Ping : DSoftStudio.Mediator.Abstractions.IRequest { } + + public sealed class PingHandler + : DSoftStudio.Mediator.Abstractions.IRequestHandler + { + public System.Threading.Tasks.ValueTask Handle( + Ping request, System.Threading.CancellationToken ct) => default; + } + + public class Startup + { + public void Configure(Microsoft.Extensions.DependencyInjection.IServiceCollection services) + { + services.AddMediator(); + } + } + """; + + var result = RunAnalyzer(source); + + result.ShouldContain(d => d.Id == "DSOFT008"); + var diag = result.First(d => d.Id == "DSOFT008"); + diag.GetMessage().ShouldContain("RegisterMediatorHandlers()"); + } + + [Fact] + public void Emits_DSOFT008_For_SelfHandling_Request_Without_RegisterHandlers() + { + const string source = """ + using DSoftStudio.Mediator; + + public sealed record Greet(string Name) : DSoftStudio.Mediator.Abstractions.IRequest + { + public static string Execute(Greet request) => request.Name; + } + + public class Startup + { + public void Configure(Microsoft.Extensions.DependencyInjection.IServiceCollection services) + { + services.AddMediator(); + } + } + """; + + var result = RunAnalyzer(source); + + result.ShouldContain(d => d.Id == "DSOFT008"); + } + + [Fact] + public void Does_Not_Emit_DSOFT008_When_RegisterMediatorHandlers_Is_Called() + { + const string source = """ + using DSoftStudio.Mediator; + using DSoftStudio.Mediator.Generated.TestAssembly; + + public sealed record Ping : DSoftStudio.Mediator.Abstractions.IRequest { } + + public sealed class PingHandler + : DSoftStudio.Mediator.Abstractions.IRequestHandler + { + public System.Threading.Tasks.ValueTask Handle( + Ping request, System.Threading.CancellationToken ct) => default; + } + + public class Startup + { + public void Configure(Microsoft.Extensions.DependencyInjection.IServiceCollection services) + { + services.AddMediator(); + services.RegisterMediatorHandlers(); + } + } + """; + + var result = RunAnalyzer(source); + + result.ShouldNotContain(d => d.Id == "DSOFT008"); + } + + [Fact] + public void Does_Not_Emit_DSOFT008_When_Builder_Overload_Is_Used() + { + const string source = """ + using DSoftStudio.Mediator.Generated.TestAssembly; + + public sealed record Ping : DSoftStudio.Mediator.Abstractions.IRequest { } + + public sealed class PingHandler + : DSoftStudio.Mediator.Abstractions.IRequestHandler + { + public System.Threading.Tasks.ValueTask Handle( + Ping request, System.Threading.CancellationToken ct) => default; + } + + public class Startup + { + public void Configure(Microsoft.Extensions.DependencyInjection.IServiceCollection services) + { + services.AddMediator(builder => { }); + } + } + """; + + var result = RunAnalyzer(source); + + result.ShouldNotContain(d => d.Id == "DSOFT008"); + } + + [Fact] + public void Does_Not_Emit_DSOFT008_When_No_Handlers_Exist() + { + const string source = """ + using DSoftStudio.Mediator; + + public class Startup + { + public void Configure(Microsoft.Extensions.DependencyInjection.IServiceCollection services) + { + services.AddMediator(); + } + } + """; + + var result = RunAnalyzer(source); + + result.ShouldNotContain(d => d.Id == "DSOFT008"); } } diff --git a/tests/DSoftStudio.Mediator.Tests/Analyzers/MixedRegistrationApiIntegrationTests.cs b/tests/DSoftStudio.Mediator.Tests/Analyzers/MixedRegistrationApiIntegrationTests.cs new file mode 100644 index 0000000..3aeb29f --- /dev/null +++ b/tests/DSoftStudio.Mediator.Tests/Analyzers/MixedRegistrationApiIntegrationTests.cs @@ -0,0 +1,245 @@ +// Copyright (c) DSoftStudio. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Collections.Immutable; +using System.Linq; +using DSoftStudio.Mediator.Generators; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace DSoftStudio.Mediator.Tests.Analyzers; + +/// +/// High-fidelity tests for that run the real +/// first, then the analyzer on the resulting (generated) +/// compilation — exactly as the compiler does in a real build. +/// +/// This is the scenario the previous stub-only tests could not exercise: in a real build, +/// RegisterMediatorHandlers() (and the builder overload) are generated, not source. +/// A source generator cannot see another generator's output, so the analyzer used to miss those +/// calls entirely (DSOFT007 silently dead; DSOFT008 false positives). As a +/// , it runs after generators and resolves them correctly. +/// +/// +public class MixedRegistrationApiIntegrationTests +{ + private const string AbstractionsSource = """ + namespace DSoftStudio.Mediator.Abstractions + { + public interface IRequest { } + public interface ICommand { } + public interface ICommand : IRequest, ICommand { } + public interface IQuery { } + public interface IQuery : IRequest, IQuery { } + public interface IStreamRequest { } + public interface INotification { } + + public interface IRequestHandler + where TRequest : IRequest + { + System.Threading.Tasks.ValueTask Handle( + TRequest request, System.Threading.CancellationToken ct); + } + + public interface IStreamRequestHandler + where TRequest : IStreamRequest + { + System.Collections.Generic.IAsyncEnumerable Handle( + TRequest request, System.Threading.CancellationToken ct); + } + + public interface INotificationHandler + where TNotification : INotification + { + System.Threading.Tasks.Task Handle( + TNotification notification, System.Threading.CancellationToken ct); + } + + [System.AttributeUsage(System.AttributeTargets.Assembly, AllowMultiple = true)] + public sealed class MediatorHandlerRegistrationAttribute : System.Attribute + { + public MediatorHandlerRegistrationAttribute(System.Type serviceType, System.Type implementationType) { } + } + } + """; + + private const string DependencyInjectionStubSource = """ + namespace Microsoft.Extensions.DependencyInjection + { + public interface IServiceCollection : System.Collections.Generic.IList { } + public class ServiceDescriptor { } + public static class ServiceCollectionServiceExtensions + { + public static IServiceCollection AddSingleton(IServiceCollection s) where TService : class => s; + public static IServiceCollection AddTransient(IServiceCollection s) where TImpl : class, TService => s; + public static IServiceCollection AddSingleton(IServiceCollection s) where TImpl : class, TService => s; + } + } + namespace Microsoft.Extensions.DependencyInjection.Extensions + { + public static class ServiceCollectionDescriptorExtensions + { + public static void TryAddTransient(IServiceCollection s, System.Type t) { } + public static void TryAddSingleton(IServiceCollection s, System.Type t) { } + } + } + """; + + /// + /// The hand-written parts of the registration surface: the parameterless AddMediator() + /// (which lives in the runtime) and the builder overload (which the pipeline generator emits in + /// the ...Generated.TestAssembly namespace — stubbed here as source so the builder case is + /// resolvable). RegisterMediatorHandlers() is deliberately NOT here: the real DI generator + /// emits it, so the test proves the analyzer sees the generated member. + /// + private const string RegistrationApiStubSource = """ + namespace DSoftStudio.Mediator + { + public sealed class MediatorBuilder + { + public MediatorBuilder(Microsoft.Extensions.DependencyInjection.IServiceCollection services) { } + } + + public static class ServiceCollectionExtensions + { + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddMediator( + this Microsoft.Extensions.DependencyInjection.IServiceCollection services) => services; + } + } + namespace DSoftStudio.Mediator.Generated.TestAssembly + { + public static class MediatorRegistryExtensions + { + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddMediator( + this Microsoft.Extensions.DependencyInjection.IServiceCollection services, + System.Action configure) => services; + } + } + """; + + private const string HandlerSource = """ + using DSoftStudio.Mediator.Abstractions; + + public sealed class Ping : IRequest { } + + public sealed class PingHandler : IRequestHandler + { + public System.Threading.Tasks.ValueTask Handle( + Ping request, System.Threading.CancellationToken ct) => default; + } + """; + + /// + /// Builds a compilation, runs the real (so + /// RegisterMediatorHandlers() and the [assembly: MediatorHandlerRegistration] + /// attributes are emitted), then runs the analyzer on the final compilation. + /// + private static ImmutableArray Analyze(string startupSource) + { + var trees = new[] + { + CSharpSyntaxTree.ParseText(AbstractionsSource, path: "Abstractions.cs"), + CSharpSyntaxTree.ParseText(DependencyInjectionStubSource, path: "DI.cs"), + CSharpSyntaxTree.ParseText(RegistrationApiStubSource, path: "RegistrationApi.cs"), + CSharpSyntaxTree.ParseText(HandlerSource, path: "Handlers.cs"), + CSharpSyntaxTree.ParseText(startupSource, path: "Startup.cs"), + }; + + var references = new MetadataReference[] + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + }; + + var compilation = CSharpCompilation.Create( + "TestAssembly", + trees, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + // 1. Run the REAL DI generator → emits RegisterMediatorHandlers() + assembly attributes. + GeneratorDriver driver = CSharpGeneratorDriver.Create( + new IIncrementalGenerator[] { new DependencyInjectionGenerator() } + .Select(GeneratorExtensions.AsSourceGenerator)); + + driver = driver.RunGeneratorsAndUpdateCompilation( + compilation, out var generatedCompilation, out _); + + // 2. Run the analyzer on the generated (final) compilation, like the real compiler. + var withAnalyzers = generatedCompilation.WithAnalyzers( + ImmutableArray.Create(new MixedRegistrationApiAnalyzer())); + + return withAnalyzers.GetAnalyzerDiagnosticsAsync().GetAwaiter().GetResult(); + } + + [Fact] + public void Does_Not_Emit_DSOFT008_When_Generated_RegisterMediatorHandlers_Is_Called() + { + // The regression: RegisterMediatorHandlers() is GENERATED. The old generator-based analyzer + // could not see it and produced a false DSOFT008 here. The analyzer must now see it. + const string startup = """ + using DSoftStudio.Mediator; + using Microsoft.Extensions.DependencyInjection; + + public class Startup + { + public void Configure(IServiceCollection services) + { + services.AddMediator(); + services.RegisterMediatorHandlers(); + } + } + """; + + var diagnostics = Analyze(startup); + + diagnostics.ShouldNotContain(d => d.Id == "DSOFT008"); + diagnostics.ShouldNotContain(d => d.Id == "DSOFT007"); + } + + [Fact] + public void Emits_DSOFT008_When_Parameterless_AddMediator_Without_Registration() + { + const string startup = """ + using DSoftStudio.Mediator; + using Microsoft.Extensions.DependencyInjection; + + public class Startup + { + public void Configure(IServiceCollection services) + { + services.AddMediator(); + } + } + """; + + var diagnostics = Analyze(startup); + + diagnostics.ShouldContain(d => d.Id == "DSOFT008"); + } + + [Fact] + public void Emits_DSOFT007_When_Builder_Overload_Mixed_With_Generated_RegisterMediatorHandlers() + { + // The builder overload + the GENERATED RegisterMediatorHandlers() in the same scope. + // This is the DSOFT007 case that was silently dead with the old generator-based analyzer. + const string startup = """ + using Microsoft.Extensions.DependencyInjection; + + public class Startup + { + public void Configure(IServiceCollection services) + { + services.AddMediator(builder => { }); + services.RegisterMediatorHandlers(); + } + } + """; + + var diagnostics = Analyze(startup); + + diagnostics.ShouldContain(d => d.Id == "DSOFT007"); + var diag = diagnostics.First(d => d.Id == "DSOFT007"); + diag.GetMessage().ShouldContain("RegisterMediatorHandlers()"); + } +} diff --git a/tests/DSoftStudio.Mediator.Tests/Coverage/MissingPathsCoverageTests.cs b/tests/DSoftStudio.Mediator.Tests/Coverage/MissingPathsCoverageTests.cs index 24b2476..edac0b7 100644 --- a/tests/DSoftStudio.Mediator.Tests/Coverage/MissingPathsCoverageTests.cs +++ b/tests/DSoftStudio.Mediator.Tests/Coverage/MissingPathsCoverageTests.cs @@ -234,8 +234,10 @@ public class AddMediatorNullTests public void AddMediator_NullServices_Throws() { IServiceCollection? services = null; +#pragma warning disable DSOFT008 // deliberate: tests the null-services guard, not handler registration Should.Throw( () => services!.AddMediator()); +#pragma warning restore DSOFT008 } } diff --git a/tests/DSoftStudio.Mediator.Tests/EdgeCases/MissingPipelineTests.cs b/tests/DSoftStudio.Mediator.Tests/EdgeCases/MissingPipelineTests.cs index 729f7cd..2258fbd 100644 --- a/tests/DSoftStudio.Mediator.Tests/EdgeCases/MissingPipelineTests.cs +++ b/tests/DSoftStudio.Mediator.Tests/EdgeCases/MissingPipelineTests.cs @@ -18,8 +18,11 @@ public class MissingPipelineTests : IDisposable public MissingPipelineTests() { var services = new ServiceCollection(); + // Intentional: no handlers registered so Send/CreateStream must fail (tested below). +#pragma warning disable DSOFT008 // deliberate: no handler registration in this test services.AddMediator(); - // No handlers or chains registered Send must fail. +#pragma warning restore DSOFT008 + // No handlers or chains registered � Send must fail. // NeverCompiledStream has no generated handler, so StreamDispatch is null by default. _provider = services.BuildServiceProvider(); _mediator = _provider.GetRequiredService(); diff --git a/tests/DSoftStudio.Mediator.Tests/Security/HandlerSpoofingTests.cs b/tests/DSoftStudio.Mediator.Tests/Security/HandlerSpoofingTests.cs index 9134206..996bc1c 100644 --- a/tests/DSoftStudio.Mediator.Tests/Security/HandlerSpoofingTests.cs +++ b/tests/DSoftStudio.Mediator.Tests/Security/HandlerSpoofingTests.cs @@ -14,7 +14,10 @@ public class HandlerSpoofingTests : IDisposable public HandlerSpoofingTests() { var services = new ServiceCollection(); + // Intentional: verifies Send throws when no handler is registered. +#pragma warning disable DSOFT008 // deliberate: no handler registration in this test services.AddMediator(); +#pragma warning restore DSOFT008 // No handler or chain registered for FakePing. diff --git a/tests/DSoftStudio.Mediator.Tests/Validation/HandlerValidationTests.cs b/tests/DSoftStudio.Mediator.Tests/Validation/HandlerValidationTests.cs index 47b5ec7..0dbe298 100644 --- a/tests/DSoftStudio.Mediator.Tests/Validation/HandlerValidationTests.cs +++ b/tests/DSoftStudio.Mediator.Tests/Validation/HandlerValidationTests.cs @@ -51,7 +51,9 @@ public void ValidateMediatorHandlers_MissingHandlers_ThrowsAggregateException() { // Arrange — mediator registered but NO handlers var services = new ServiceCollection(); +#pragma warning disable DSOFT008 // deliberate: tests that the runtime validator catches missing handlers services.AddMediator(); +#pragma warning restore DSOFT008 // Deliberately NOT calling RegisterMediatorHandlers() using var provider = services.BuildServiceProvider(); @@ -66,7 +68,9 @@ public void ValidateMediatorHandlers_AggregateContainsAllFailures() { // Arrange — mediator registered but NO handlers var services = new ServiceCollection(); +#pragma warning disable DSOFT008 // deliberate: tests that the runtime validator catches missing handlers services.AddMediator(); +#pragma warning restore DSOFT008 using var provider = services.BuildServiceProvider(); From 98cc809ce6bfbbc44969784a0c381da6d7d16a3b Mon Sep 17 00:00:00 2001 From: Yander Santiesteban Rojas Date: Sun, 21 Jun 2026 17:14:45 -0300 Subject: [PATCH 03/11] =?UTF-8?q?release:=20v1.3.0-rc.1=20=E2=80=94=20hand?= =?UTF-8?q?ler-type=20seam,=20DSOFT008=20compilation-wide,=20dep=20bumps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-release (rc.1) soaking before promotion to the stable 1.3.0. Added - IPipelineHandlerTypeAccessor (Abstractions): exposes the concrete request/ stream handler type at the tail of the pipeline chain to an outermost behavior, without resolving or instantiating it. Implemented by the internal BehaviorHandlerAdapter / StreamBehaviorHandlerAdapter (walk next -> handler). - OpenTelemetry: tag mediator.handler.type on request and stream spans (it already did for notification-handler spans). Read through the new accessor; the handler is never resolved or instantiated. Changed - DSOFT008 detection is now compilation-wide (reported from a CompilationEndAction), so splitting AddMediator() and the handler registration across different methods is no longer a false positive. - Dependency bumps: Microsoft.Extensions.DependencyInjection.Abstractions 10.0.9, Microsoft.Bcl.AsyncInterfaces 10.0.9, OpenTelemetry 1.16.0, Microsoft.Extensions.Caching.Hybrid 10.7.0. Microsoft.CodeAnalysis.CSharp is intentionally kept at 4.12.0 — that version is the minimum compiler host a consumer needs, so raising it would break consumers on older SDK/VS. - Align stale test/sample/benchmark package pins (NU1605 downgrade fixes). Security - Scriban 7.0.3 -> 7.2.4 (benchmarks, dev-only, not shipped) — GHSA-24c8-4792-22hx. Versions: Mediator/Abstractions 1.3.0-rc.1, OpenTelemetry 1.1.0-rc.1, HybridCache/FluentValidation 1.0.9-rc.1. --- CHANGELOG.md | 21 +++++ .../DSoftStudio.Mediator.Benchmarks.csproj | 4 +- .../DSoft.Sample.HybridCache.Api.csproj | 2 +- ...Soft.Sample.HybridCache.Application.csproj | 2 +- .../DSoftStudio.Mediator.Abstractions.csproj | 4 +- .../IPipelineHandlerTypeAccessor.cs | 30 ++++++++ ...oftStudio.Mediator.FluentValidation.csproj | 2 +- .../DSoftStudio.Mediator.Generators.csproj | 2 +- .../DiagnosticDescriptors.cs | 6 +- .../MixedRegistrationApiAnalyzer.cs | 77 +++++++++++-------- .../DSoftStudio.Mediator.HybridCache.csproj | 4 +- .../DSoftStudio.Mediator.OpenTelemetry.csproj | 4 +- .../InstrumentedNotificationPublisher.cs | 13 ++++ .../MediatorStreamTracingBehavior.cs | 10 +++ .../MediatorTracingBehavior.cs | 13 ++++ .../BehaviorHandlerAdapter.cs | 12 ++- .../DSoftStudio.Mediator.csproj | 4 +- .../StreamPipelineChainHandler.cs | 10 ++- ...ftStudio.Mediator.HybridCache.Tests.csproj | 2 +- ...Studio.Mediator.OpenTelemetry.Tests.csproj | 2 +- .../StreamTracingBehaviorTests.cs | 17 ++++ .../TracingBehaviorTests.cs | 21 +++++ .../MixedRegistrationApiIntegrationTests.cs | 30 ++++++++ .../Pipeline/HandlerTypeAccessorTests.cs | 52 +++++++++++++ 24 files changed, 294 insertions(+), 50 deletions(-) create mode 100644 src/DSoftStudio.Mediator.Abstractions/IPipelineHandlerTypeAccessor.cs create mode 100644 tests/DSoftStudio.Mediator.Tests/Pipeline/HandlerTypeAccessorTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 559da77..a2bd00d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.0] — Unreleased (pre-release `1.3.0-rc.1`) + +> Companions: `OpenTelemetry` 1.1.0-rc.1 · `HybridCache` 1.0.9-rc.1 · `FluentValidation` 1.0.9-rc.1. +> Soaking as a release candidate before promotion to the stable `1.3.0`. + +### Added + +- **`IPipelineHandlerTypeAccessor` (Abstractions)** — exposes the concrete request/stream handler type at the tail of the pipeline chain to an outermost pipeline behavior, without resolving or instantiating it. A behavior is open-generic and may serve many handlers; the correct one for a given request is only knowable by walking the chain it was handed as `next`. The internal chain adapters (`BehaviorHandlerAdapter`, `StreamBehaviorHandlerAdapter`) implement the interface; a behavior reads the terminal handler via `next is IPipelineHandlerTypeAccessor`. Enables tracing/diagnostics to tag the concrete handler. +- **DSOFT008 — missing handler registration** — new compile-time diagnostic (Warning) that flags a parameterless `AddMediator()` when handlers exist in the compilation but nothing anywhere registers them (no builder overload, no `RegisterMediatorHandlers()`, no manual `AddTransient>`). Detection is **compilation-wide** (reported from a `CompilationEndAction`), so splitting `AddMediator()` and the handler registration across different methods is not a false positive. +- **OpenTelemetry: `mediator.handler.type` on request and stream spans** — the bridge now tags the concrete handler type on request-send and stream spans (it already did so for notification-handler spans), so an imported OTLP/Jaeger trace maps each span to its handler source and renders HTTP/DB child spans as dependencies under it. The handler type is read through the new `IPipelineHandlerTypeAccessor` — it is never resolved or instantiated. + +### Changed + +- **DSOFT007 converted to a `DiagnosticAnalyzer`** — runs after source generators, so it correctly sees the generated `RegisterMediatorHandlers()` / builder-overload registrations that the prior generator-based diagnostic could not (DSOFT007 was silently inert; DSOFT008 false-positived). +- **DSOFT006 location widened** to the full type header so the IDE offers the ConvertToCqrs lightbulb when hovering the offending `IRequest` base type, not just the type name (Info severity unchanged). +- **Dependency bumps** — `Microsoft.Extensions.DependencyInjection.Abstractions` → 10.0.9 and `Microsoft.Bcl.AsyncInterfaces` → 10.0.9 (core/abstractions, .NET 10 servicing band); companion `OpenTelemetry` → 1.16.0; companion `Microsoft.Extensions.Caching.Hybrid` → 10.7.0. `Microsoft.CodeAnalysis.CSharp` is intentionally kept at 4.12.0 — the generator's referenced Roslyn version is the minimum compiler-host a consumer needs, so raising it would break consumers on older SDK/VS. + +### Security + +- **Scriban 7.0.3 → 7.2.4** in the benchmarks project (dev-only, not shipped) — resolves [GHSA-24c8-4792-22hx](https://github.com/advisories/GHSA-24c8-4792-22hx) (high severity). + ## [1.2.0] — 2026-04-12 ### Added diff --git a/benchmarks/DSoftStudio.Mediator.Benchmarks/DSoftStudio.Mediator.Benchmarks.csproj b/benchmarks/DSoftStudio.Mediator.Benchmarks/DSoftStudio.Mediator.Benchmarks.csproj index d5bec40..29197c3 100644 --- a/benchmarks/DSoftStudio.Mediator.Benchmarks/DSoftStudio.Mediator.Benchmarks.csproj +++ b/benchmarks/DSoftStudio.Mediator.Benchmarks/DSoftStudio.Mediator.Benchmarks.csproj @@ -19,12 +19,12 @@ all - + - + diff --git a/samples/caching/DSoft.Sample.HybridCache.Api/DSoft.Sample.HybridCache.Api.csproj b/samples/caching/DSoft.Sample.HybridCache.Api/DSoft.Sample.HybridCache.Api.csproj index 0536c7f..e0f464c 100644 --- a/samples/caching/DSoft.Sample.HybridCache.Api/DSoft.Sample.HybridCache.Api.csproj +++ b/samples/caching/DSoft.Sample.HybridCache.Api/DSoft.Sample.HybridCache.Api.csproj @@ -12,7 +12,7 @@ - + diff --git a/samples/caching/DSoft.Sample.HybridCache.Application/DSoft.Sample.HybridCache.Application.csproj b/samples/caching/DSoft.Sample.HybridCache.Application/DSoft.Sample.HybridCache.Application.csproj index 6b45650..32ca308 100644 --- a/samples/caching/DSoft.Sample.HybridCache.Application/DSoft.Sample.HybridCache.Application.csproj +++ b/samples/caching/DSoft.Sample.HybridCache.Application/DSoft.Sample.HybridCache.Application.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/DSoftStudio.Mediator.Abstractions/DSoftStudio.Mediator.Abstractions.csproj b/src/DSoftStudio.Mediator.Abstractions/DSoftStudio.Mediator.Abstractions.csproj index dd1403d..94ec7ca 100644 --- a/src/DSoftStudio.Mediator.Abstractions/DSoftStudio.Mediator.Abstractions.csproj +++ b/src/DSoftStudio.Mediator.Abstractions/DSoftStudio.Mediator.Abstractions.csproj @@ -8,7 +8,7 @@ DSoftStudio.Mediator.Abstractions - 1.2.0 + 1.3.0-rc.1 DSoftStudio DSoftStudio @@ -53,7 +53,7 @@ - + diff --git a/src/DSoftStudio.Mediator.Abstractions/IPipelineHandlerTypeAccessor.cs b/src/DSoftStudio.Mediator.Abstractions/IPipelineHandlerTypeAccessor.cs new file mode 100644 index 0000000..7d62fa8 --- /dev/null +++ b/src/DSoftStudio.Mediator.Abstractions/IPipelineHandlerTypeAccessor.cs @@ -0,0 +1,30 @@ +// Copyright (c) DSoftStudio. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; + +namespace DSoftStudio.Mediator.Abstractions +{ + /// + /// Exposes the CONCRETE handler type at the tail of the pipeline chain. + /// + /// A pipeline behavior is open-generic and may be shared by many handlers, so a behavior cannot know + /// — from its own type — which handler THIS request resolves to. The correct handler is only knowable by + /// walking the chain the behavior was handed as next down to the terminal handler. The internal + /// chain adapters implement this so an outermost behavior (tracing, diagnostics) can read the concrete + /// handler type without resolving or instantiating anything — the chain is already built. + /// + /// + /// Implemented by the request and stream behavior-chain adapters; the terminal handler does not implement + /// it, so a consumer resolves the type as next is IPipelineHandlerTypeAccessor a ? a.HandlerType : next.GetType(). + /// + /// + public interface IPipelineHandlerTypeAccessor + { + /// + /// The concrete IRequestHandler / IStreamRequestHandler implementation type at the end + /// of this chain (resolved by walking next to the terminal handler). + /// + Type HandlerType { get; } + } +} diff --git a/src/DSoftStudio.Mediator.FluentValidation/DSoftStudio.Mediator.FluentValidation.csproj b/src/DSoftStudio.Mediator.FluentValidation/DSoftStudio.Mediator.FluentValidation.csproj index 1f09cba..4b55a9b 100644 --- a/src/DSoftStudio.Mediator.FluentValidation/DSoftStudio.Mediator.FluentValidation.csproj +++ b/src/DSoftStudio.Mediator.FluentValidation/DSoftStudio.Mediator.FluentValidation.csproj @@ -9,7 +9,7 @@ DSoftStudio.Mediator.FluentValidation - 1.0.8 + 1.0.9-rc.1 DSoftStudio DSoftStudio diff --git a/src/DSoftStudio.Mediator.Generators/DSoftStudio.Mediator.Generators.csproj b/src/DSoftStudio.Mediator.Generators/DSoftStudio.Mediator.Generators.csproj index 9e1a149..ae86a0b 100644 --- a/src/DSoftStudio.Mediator.Generators/DSoftStudio.Mediator.Generators.csproj +++ b/src/DSoftStudio.Mediator.Generators/DSoftStudio.Mediator.Generators.csproj @@ -18,7 +18,7 @@ .NET 10 preview SDKs. All APIs we use (IIncrementalGenerator, OptimizationLevel, AnalyzerConfigOptions) are available since 4.3.0. --> - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/DSoftStudio.Mediator.Generators/DiagnosticDescriptors.cs b/src/DSoftStudio.Mediator.Generators/DiagnosticDescriptors.cs index bdba11d..a164c97 100644 --- a/src/DSoftStudio.Mediator.Generators/DiagnosticDescriptors.cs +++ b/src/DSoftStudio.Mediator.Generators/DiagnosticDescriptors.cs @@ -108,6 +108,10 @@ internal static class DiagnosticDescriptors + "assemblies) but neither AddMediator(Action) nor " + "RegisterMediatorHandlers() is called, those handlers are never added to DI and the " + "first dispatch fails at runtime. Use the builder overload (single entry point) or " - + "call RegisterMediatorHandlers() explicitly."); + + "call RegisterMediatorHandlers() explicitly.", + // Registration can live in a different method than AddMediator(), so the analyzer can only decide + // this once the WHOLE compilation has been seen — it reports from a CompilationEndAction. The tag + // tells the host to schedule it as a full-compilation diagnostic (not a live per-keystroke one). + customTags: WellKnownDiagnosticTags.CompilationEnd); } } diff --git a/src/DSoftStudio.Mediator.Generators/MixedRegistrationApiAnalyzer.cs b/src/DSoftStudio.Mediator.Generators/MixedRegistrationApiAnalyzer.cs index 425be28..d8e1599 100644 --- a/src/DSoftStudio.Mediator.Generators/MixedRegistrationApiAnalyzer.cs +++ b/src/DSoftStudio.Mediator.Generators/MixedRegistrationApiAnalyzer.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Immutable; using System.Threading; @@ -12,7 +13,9 @@ namespace DSoftStudio.Mediator.Generators; /// -/// Validates how the mediator registration APIs are used, per registration scope (method body). +/// Validates how the mediator registration APIs are used. DSOFT007 (mixing APIs) is checked per +/// registration scope (method body); DSOFT008 (handlers never registered) is checked across the whole +/// compilation, since the registration that backs AddMediator() often lives in another method. /// /// AddMediator(Action<MediatorBuilder>) is a single entry point that registers core /// services, handlers, and precompiled pipelines in one call. Calling @@ -26,10 +29,10 @@ namespace DSoftStudio.Mediator.Generators; /// /// DSOFT007 — the builder overload is used together with the individual registration /// methods in the same scope (redundant / double registration). -/// DSOFT008 — the parameterless AddMediator() is used in a scope that never -/// registers handlers (no builder overload, no RegisterMediatorHandlers(), and no manual -/// AddTransient<IRequestHandler<,>,…>()), while handlers exist in the -/// compilation — the handlers are left unregistered. +/// DSOFT008 — the parameterless AddMediator() is used while NOTHING in the whole +/// compilation registers handlers (no builder overload, no RegisterMediatorHandlers(), and no +/// manual AddTransient<IRequestHandler<,>,…>()), yet handlers exist — they are left +/// unregistered. Compilation-wide so a split across methods is not a false positive. /// /// /// @@ -85,16 +88,25 @@ public override void Initialize(AnalysisContext context) compilation, requestHandler, notificationHandler, streamHandler, compilationStart.CancellationToken); + // ── DSOFT008 is a COMPILATION-WIDE property ─────────────────────────────────────────────── + // "Are the handlers registered anywhere in startup?" can only be answered for the whole + // compilation: AddMediator() and the registration that backs it (RegisterMediatorHandlers(), + // the builder overload, or manual AddTransient>) routinely live in different + // methods/files. A per-scope check false-positives that split — and DSOFT008 is a Warning, so a + // false positive breaks builds under TreatWarningsAsErrors. We therefore accumulate the parameterless + // AddMediator() sites and a single "registers handlers somewhere" flag across the whole compilation + // (block actions run concurrently → thread-safe state), then decide in RegisterCompilationEndAction + // once every method has been seen. DSOFT007 (mixing APIs) stays per-scope: mixing is by definition + // within one registration block. + var unregisteredAddMediatorSites = new ConcurrentBag(); + int registersHandlersSomewhere = 0; // set-once via Interlocked from concurrent block actions + compilationStart.RegisterOperationBlockStartAction(blockStart => { - // Per-scope (method body) state. Each operation-block-start scope gets its own - // closure instance, so this is isolated per method. + // Per-scope (method body) state — DSOFT007 only. Each block gets its own closure instance. var gate = new object(); - var parameterlessAddMediator = new List(); var redundantCalls = new List<(Location Location, string Method, string Action)>(); bool hasBuilderOverload = false; - bool hasRegisterHandlers = false; - bool hasManualHandlerRegistration = false; blockStart.RegisterOperationAction(opContext => { @@ -105,10 +117,10 @@ public override void Initialize(AnalysisContext context) // Manual handler registration — e.g. services.AddTransient, H>() // or services.AddSingleton>(instance) — means handlers ARE - // registered in this scope, so DSOFT008 must not fire. + // registered (somewhere in the compilation), so DSOFT008 must not fire. if (IsManualHandlerRegistration(method, requestHandler, notificationHandler, streamHandler)) { - lock (gate) { hasManualHandlerRegistration = true; } + Interlocked.Exchange(ref registersHandlersSomewhere, 1); return; } @@ -124,17 +136,20 @@ public override void Initialize(AnalysisContext context) { case "AddMediator": if (HasMediatorBuilderParameter(method)) - lock (gate) { hasBuilderOverload = true; } + { + lock (gate) { hasBuilderOverload = true; } // DSOFT007 (per-scope) + Interlocked.Exchange(ref registersHandlersSomewhere, 1); // registers handlers + } else - lock (gate) { parameterlessAddMediator.Add(location); } + { + unregisteredAddMediatorSites.Add(location); // DSOFT008 (compilation-wide) + } break; case "RegisterMediatorHandlers": + Interlocked.Exchange(ref registersHandlersSomewhere, 1); // registers handlers lock (gate) - { - hasRegisterHandlers = true; redundantCalls.Add((location, "RegisterMediatorHandlers()", "registers handlers")); - } break; case "PrecompilePipelines": @@ -144,30 +159,30 @@ public override void Initialize(AnalysisContext context) } }, OperationKind.Invocation); + // ── DSOFT007: redundant individual call alongside the builder overload (same scope) ── blockStart.RegisterOperationBlockEndAction(blockEnd => { - // ── DSOFT007: redundant individual call alongside the builder overload ── if (hasBuilderOverload) { foreach (var (location, method, action) in redundantCalls) blockEnd.ReportDiagnostic(Diagnostic.Create( DiagnosticDescriptors.MixedRegistrationApi, location, method, action)); } - - // ── DSOFT008: parameterless AddMediator() leaves handlers unregistered ── - // Only when this scope never registers handlers by any means, and there are - // handlers in the compilation that would otherwise be registered. - if (!hasBuilderOverload - && !hasRegisterHandlers - && !hasManualHandlerRegistration - && hasHandlers) - { - foreach (var location in parameterlessAddMediator) - blockEnd.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.MissingHandlerRegistration, location)); - } }); }); + + // ── DSOFT008: decided once the whole compilation has been analyzed ── + // Fire only when handlers exist AND nothing anywhere registers them — every parameterless + // AddMediator() site is then genuinely leaving handlers unregistered. + compilationStart.RegisterCompilationEndAction(compilationEnd => + { + if (!hasHandlers || Volatile.Read(ref registersHandlersSomewhere) != 0) + return; + + foreach (var location in unregisteredAddMediatorSites) + compilationEnd.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.MissingHandlerRegistration, location)); + }); }); } diff --git a/src/DSoftStudio.Mediator.HybridCache/DSoftStudio.Mediator.HybridCache.csproj b/src/DSoftStudio.Mediator.HybridCache/DSoftStudio.Mediator.HybridCache.csproj index a0756fb..357348c 100644 --- a/src/DSoftStudio.Mediator.HybridCache/DSoftStudio.Mediator.HybridCache.csproj +++ b/src/DSoftStudio.Mediator.HybridCache/DSoftStudio.Mediator.HybridCache.csproj @@ -9,7 +9,7 @@ DSoftStudio.Mediator.HybridCache - 1.0.8 + 1.0.9-rc.1 DSoftStudio DSoftStudio @@ -65,7 +65,7 @@ - + diff --git a/src/DSoftStudio.Mediator.OpenTelemetry/DSoftStudio.Mediator.OpenTelemetry.csproj b/src/DSoftStudio.Mediator.OpenTelemetry/DSoftStudio.Mediator.OpenTelemetry.csproj index 96f67ed..9d1f008 100644 --- a/src/DSoftStudio.Mediator.OpenTelemetry/DSoftStudio.Mediator.OpenTelemetry.csproj +++ b/src/DSoftStudio.Mediator.OpenTelemetry/DSoftStudio.Mediator.OpenTelemetry.csproj @@ -9,7 +9,7 @@ DSoftStudio.Mediator.OpenTelemetry - 1.0.9 + 1.1.0-rc.1 DSoftStudio DSoftStudio @@ -65,7 +65,7 @@ - + diff --git a/src/DSoftStudio.Mediator.OpenTelemetry/InstrumentedNotificationPublisher.cs b/src/DSoftStudio.Mediator.OpenTelemetry/InstrumentedNotificationPublisher.cs index 3c2c3f4..06aa844 100644 --- a/src/DSoftStudio.Mediator.OpenTelemetry/InstrumentedNotificationPublisher.cs +++ b/src/DSoftStudio.Mediator.OpenTelemetry/InstrumentedNotificationPublisher.cs @@ -129,6 +129,19 @@ public async Task Handle(TNotification notification, CancellationToken cancellat using var activity = Source.StartActivity(spanName, ActivityKind.Internal); + // ADR-0047 P3 — tag the per-handler span so an imported trace can recognize it as a mediator + // NOTIFICATION HANDLER (not an unattributed Internal span) and map it to its handler source: + // • request.kind/type identify the published notification (so the importer pairs this child span + // with its NotificationPublished parent — the flame's nested-handler breakdown); + // • handler.type is the concrete subscriber, so the row jumps to the handler's own declaration + // and any HTTP/DB child span renders as a dependency UNDER this handler (ADR-0049). + if (activity is { IsAllDataRequested: true }) + { + activity.SetTag("mediator.request.kind", MediatorNotificationMetadata.RequestKind); + activity.SetTag("mediator.request.type", MediatorNotificationMetadata.RequestType); + activity.SetTag("mediator.handler.type", inner.GetType().FullName); + } + try { await inner.Handle(notification, cancellationToken); diff --git a/src/DSoftStudio.Mediator.OpenTelemetry/MediatorStreamTracingBehavior.cs b/src/DSoftStudio.Mediator.OpenTelemetry/MediatorStreamTracingBehavior.cs index a456bd2..fa35d7c 100644 --- a/src/DSoftStudio.Mediator.OpenTelemetry/MediatorStreamTracingBehavior.cs +++ b/src/DSoftStudio.Mediator.OpenTelemetry/MediatorStreamTracingBehavior.cs @@ -44,6 +44,9 @@ private async IAsyncEnumerable Instrumented( activity.SetTag("mediator.request.type", MediatorStreamMetadata.RequestType); activity.SetTag("mediator.response.type", MediatorStreamMetadata.ResponseType); activity.SetTag("mediator.request.kind", MediatorStreamMetadata.RequestKind); + // ADR-0049 — the concrete stream handler behind this request (resolved through the chain, never + // instantiated), so an imported trace maps the stream span to its handler source. See MediatorTracingBehavior. + activity.SetTag("mediator.handler.type", ResolveHandlerType(next).FullName); options.EnrichActivity?.Invoke(activity, request); } @@ -62,4 +65,11 @@ private async IAsyncEnumerable Instrumented( activity?.SetStatus(success ? ActivityStatusCode.Ok : ActivityStatusCode.Error); } } + + /// + /// The concrete stream handler type at the end of the chain — via + /// when is a chain adapter, or its runtime type when this behavior is the innermost link. + /// + private static Type ResolveHandlerType(IStreamRequestHandler next) + => next is IPipelineHandlerTypeAccessor accessor ? accessor.HandlerType : next.GetType(); } diff --git a/src/DSoftStudio.Mediator.OpenTelemetry/MediatorTracingBehavior.cs b/src/DSoftStudio.Mediator.OpenTelemetry/MediatorTracingBehavior.cs index 605ca21..8a82f89 100644 --- a/src/DSoftStudio.Mediator.OpenTelemetry/MediatorTracingBehavior.cs +++ b/src/DSoftStudio.Mediator.OpenTelemetry/MediatorTracingBehavior.cs @@ -35,6 +35,11 @@ public async ValueTask Handle( activity.SetTag("mediator.request.type", MediatorTelemetryMetadata.RequestType); activity.SetTag("mediator.response.type", MediatorTelemetryMetadata.ResponseType); activity.SetTag("mediator.request.kind", MediatorTelemetryMetadata.RequestKind); + // ADR-0049 — the concrete handler behind this request, so an imported trace maps the request span to + // its handler source and renders HTTP/DB child spans as dependencies UNDER it. The handler is open to + // the behavior only through the chain it was handed as `next` (it is open-generic / shared); the + // pipeline already resolved the right one, exposed via IPipelineHandlerTypeAccessor — we never resolve it. + activity.SetTag("mediator.handler.type", ResolveHandlerType(next).FullName); options.EnrichActivity?.Invoke(activity, request); } @@ -56,4 +61,12 @@ public async ValueTask Handle( throw; } } + + /// + /// The concrete handler type at the end of the pipeline chain. As the outermost behavior, + /// is a chain adapter that exposes the terminal handler via ; when this + /// behavior is the innermost link, IS the concrete handler, so its runtime type is used. + /// + private static Type ResolveHandlerType(IRequestHandler next) + => next is IPipelineHandlerTypeAccessor accessor ? accessor.HandlerType : next.GetType(); } diff --git a/src/DSoftStudio.Mediator/BehaviorHandlerAdapter.cs b/src/DSoftStudio.Mediator/BehaviorHandlerAdapter.cs index 7355494..46053c2 100644 --- a/src/DSoftStudio.Mediator/BehaviorHandlerAdapter.cs +++ b/src/DSoftStudio.Mediator/BehaviorHandlerAdapter.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. See LICENSE in the project root for license information. using DSoftStudio.Mediator.Abstractions; +using System; using System.Runtime.CompilerServices; namespace DSoftStudio.Mediator @@ -13,11 +14,20 @@ namespace DSoftStudio.Mediator /// internal sealed class BehaviorHandlerAdapter( IPipelineBehavior behavior, - IRequestHandler next) : IRequestHandler + IRequestHandler next) + : IRequestHandler, IPipelineHandlerTypeAccessor where TRequest : IRequest { [MethodImpl(MethodImplOptions.AggressiveInlining)] public ValueTask Handle(TRequest request, CancellationToken cancellationToken) => behavior.Handle(request, next, cancellationToken); + + /// + /// Walks the chain to the terminal handler: an inner adapter forwards its own resolution; the tail + /// (the concrete handler, which does not implement the accessor) reports its runtime type. Lets an + /// outermost behavior tag the concrete handler without resolving it (). + /// + public Type HandlerType + => next is IPipelineHandlerTypeAccessor inner ? inner.HandlerType : next.GetType(); } } diff --git a/src/DSoftStudio.Mediator/DSoftStudio.Mediator.csproj b/src/DSoftStudio.Mediator/DSoftStudio.Mediator.csproj index fb7b917..a717e92 100644 --- a/src/DSoftStudio.Mediator/DSoftStudio.Mediator.csproj +++ b/src/DSoftStudio.Mediator/DSoftStudio.Mediator.csproj @@ -14,7 +14,7 @@ DSoftStudio.Mediator - 1.2.0 + 1.3.0-rc.1 DSoftStudio DSoftStudio @@ -66,7 +66,7 @@ - + diff --git a/src/DSoftStudio.Mediator/StreamPipelineChainHandler.cs b/src/DSoftStudio.Mediator/StreamPipelineChainHandler.cs index 27136ee..8c6a1a4 100644 --- a/src/DSoftStudio.Mediator/StreamPipelineChainHandler.cs +++ b/src/DSoftStudio.Mediator/StreamPipelineChainHandler.cs @@ -50,10 +50,18 @@ IAsyncEnumerable IStreamRequestHandler.Handle( internal sealed class StreamBehaviorHandlerAdapter( IStreamPipelineBehavior behavior, - IStreamRequestHandler next) : IStreamRequestHandler + IStreamRequestHandler next) + : IStreamRequestHandler, IPipelineHandlerTypeAccessor where TRequest : IStreamRequest { public IAsyncEnumerable Handle(TRequest request, CancellationToken cancellationToken) => behavior.Handle(request, next, cancellationToken); + + /// + /// Walks the chain to the terminal stream handler so an outermost stream behavior can tag the concrete + /// handler type without resolving it (). + /// + public System.Type HandlerType + => next is IPipelineHandlerTypeAccessor inner ? inner.HandlerType : next.GetType(); } } diff --git a/tests/DSoftStudio.Mediator.HybridCache.Tests/DSoftStudio.Mediator.HybridCache.Tests.csproj b/tests/DSoftStudio.Mediator.HybridCache.Tests/DSoftStudio.Mediator.HybridCache.Tests.csproj index 008718e..8c81f3f 100644 --- a/tests/DSoftStudio.Mediator.HybridCache.Tests/DSoftStudio.Mediator.HybridCache.Tests.csproj +++ b/tests/DSoftStudio.Mediator.HybridCache.Tests/DSoftStudio.Mediator.HybridCache.Tests.csproj @@ -11,7 +11,7 @@ - + diff --git a/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/DSoftStudio.Mediator.OpenTelemetry.Tests.csproj b/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/DSoftStudio.Mediator.OpenTelemetry.Tests.csproj index b56f04f..45542d9 100644 --- a/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/DSoftStudio.Mediator.OpenTelemetry.Tests.csproj +++ b/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/DSoftStudio.Mediator.OpenTelemetry.Tests.csproj @@ -13,7 +13,7 @@ - + diff --git a/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/StreamTracingBehaviorTests.cs b/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/StreamTracingBehaviorTests.cs index c975f1a..d97a83e 100644 --- a/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/StreamTracingBehaviorTests.cs +++ b/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/StreamTracingBehaviorTests.cs @@ -48,6 +48,23 @@ public async Task Stream_span_has_correct_tags() activity.GetTagItem("mediator.request.kind")!.ShouldBe("stream"); } + [Fact] + public async Task Stream_span_tags_concrete_handler_type() + { + // ADR-0049 — the stream span must carry the concrete handler type (parity with the request span), so an + // imported stream trace maps to its handler source instead of leaving the Handler column empty. + using var collector = new ActivityCollector(); + var options = new MediatorInstrumentationOptions(); + var behavior = new MediatorStreamTracingBehavior(options); + var handler = new TestStreamHandler(); + + await foreach (var _ in behavior.Handle(new TestStreamRequest(1), handler, TestContext.Current.CancellationToken)) + { } + + var activity = collector.Activities.ShouldHaveSingleItem(); + activity.GetTagItem("mediator.handler.type")!.ShouldBe(typeof(TestStreamHandler).FullName); + } + [Fact] public async Task Stream_span_covers_full_enumeration() { diff --git a/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/TracingBehaviorTests.cs b/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/TracingBehaviorTests.cs index 8cacee3..7c18489 100644 --- a/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/TracingBehaviorTests.cs +++ b/tests/DSoftStudio.Mediator.OpenTelemetry.Tests/TracingBehaviorTests.cs @@ -75,6 +75,27 @@ public async Task Span_has_correct_tags() activity.GetTagItem("mediator.request.kind")!.ShouldBe("command"); } + [Fact] + public async Task Span_tags_concrete_handler_type() + { + // ADR-0049 — the request span must carry the concrete handler type so an imported trace can map it to + // its handler source (and anchor HTTP/DB child spans as dependencies under it). Here the behavior is the + // innermost link (next IS the handler), so the runtime type is used. + using var collector = new ActivityCollector(); + var options = new MediatorInstrumentationOptions(); + var behavior = new MediatorTracingBehavior(options); + var handler = new TestCommandHandler(); + + await behavior.Handle(new TestCommand("test"), handler, TestContext.Current.CancellationToken); + + var activity = collector.Activities.ShouldHaveSingleItem(); + activity.GetTagItem("mediator.handler.type")!.ShouldBe(typeof(TestCommandHandler).FullName); + // NOTE: the chain case (next is a multi-link adapter that resolves the terminal handler via + // IPipelineHandlerTypeAccessor) is proven in the core suite — HandlerTypeAccessorTests — because the real + // BehaviorHandlerAdapter is internal to DSoftStudio.Mediator and any IRequestHandler stub here would be + // swept up by the mediator's handler source-generator as a duplicate registration. + } + [Fact] public async Task Exception_sets_error_status_and_records_exception_event() { diff --git a/tests/DSoftStudio.Mediator.Tests/Analyzers/MixedRegistrationApiIntegrationTests.cs b/tests/DSoftStudio.Mediator.Tests/Analyzers/MixedRegistrationApiIntegrationTests.cs index 3aeb29f..471c633 100644 --- a/tests/DSoftStudio.Mediator.Tests/Analyzers/MixedRegistrationApiIntegrationTests.cs +++ b/tests/DSoftStudio.Mediator.Tests/Analyzers/MixedRegistrationApiIntegrationTests.cs @@ -197,6 +197,36 @@ public void Configure(IServiceCollection services) diagnostics.ShouldNotContain(d => d.Id == "DSOFT007"); } + [Fact] + public void Does_Not_Emit_DSOFT008_When_Registration_Is_Split_Across_Methods() + { + // The cross-method false positive the per-scope check produced: AddMediator() in one method and the + // handler registration in ANOTHER. DSOFT008 is now compilation-wide, so it must NOT fire here — the + // handlers ARE registered, just not in the same method as AddMediator(). DSOFT008 is a Warning, so a + // false positive would break the client's build under TreatWarningsAsErrors. + const string startup = """ + using DSoftStudio.Mediator; + using Microsoft.Extensions.DependencyInjection; + + public class Startup + { + public void AddCore(IServiceCollection services) + { + services.AddMediator(); + } + + public void AddHandlers(IServiceCollection services) + { + services.RegisterMediatorHandlers(); + } + } + """; + + var diagnostics = Analyze(startup); + + diagnostics.ShouldNotContain(d => d.Id == "DSOFT008"); + } + [Fact] public void Emits_DSOFT008_When_Parameterless_AddMediator_Without_Registration() { diff --git a/tests/DSoftStudio.Mediator.Tests/Pipeline/HandlerTypeAccessorTests.cs b/tests/DSoftStudio.Mediator.Tests/Pipeline/HandlerTypeAccessorTests.cs new file mode 100644 index 0000000..fa6fa69 --- /dev/null +++ b/tests/DSoftStudio.Mediator.Tests/Pipeline/HandlerTypeAccessorTests.cs @@ -0,0 +1,52 @@ +// Copyright (c) DSoftStudio. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using DSoftStudio.Mediator.Abstractions; + +namespace DSoftStudio.Mediator.Tests.Pipeline; + +// ADR-0049 — the pipeline chain exposes the CONCRETE terminal handler type to an outermost behavior via +// IPipelineHandlerTypeAccessor, so tracing/diagnostics can tag mediator.handler.type without resolving the +// handler. A behavior is open-generic / shared, so the right handler is only knowable by walking the chain. + +public sealed class HandlerTypeAccessorTests +{ + public record AccReq : IRequest; + + public sealed class AccReqHandler : IRequestHandler + { + public ValueTask Handle(AccReq request, CancellationToken ct) => new(7); + } + + // A passthrough behavior — stands for any number of cross-cutting links between the outermost behavior and + // the handler. It is open to many handlers; only the chain knows which handler this request resolves to. + public sealed class PassThroughBehavior : IPipelineBehavior + { + public ValueTask Handle(AccReq request, IRequestHandler next, CancellationToken ct) + => next.Handle(request, ct); + } + + [Fact] + public void Adapter_resolves_concrete_handler_type_through_a_single_link() + { + var handler = new AccReqHandler(); + IRequestHandler chain = new BehaviorHandlerAdapter(new PassThroughBehavior(), handler); + + var accessor = chain.ShouldBeAssignableTo(); + accessor.HandlerType.ShouldBe(typeof(AccReqHandler)); + } + + [Fact] + public void Adapter_walks_the_full_chain_to_the_terminal_handler() + { + // adapter0 → adapter1 → adapter2 → handler. The outermost adapter must report the HANDLER, not the + // inner adapters (which is the whole point — "look at the complete chain"). + var handler = new AccReqHandler(); + IRequestHandler chain = handler; + for (int i = 0; i < 3; i++) + chain = new BehaviorHandlerAdapter(new PassThroughBehavior(), chain); + + var accessor = chain.ShouldBeAssignableTo(); + accessor.HandlerType.ShouldBe(typeof(AccReqHandler)); + } +} From 103e958990882c176c1c60eb5f9fcca3c336304c Mon Sep 17 00:00:00 2001 From: Yander Santiesteban Rojas Date: Sun, 21 Jun 2026 17:35:27 -0300 Subject: [PATCH 04/11] ci: run full-solution tests + merge coverage report - Test the whole solution (dotnet test DSoftStudio.Mediator.slnx) instead of a hand-listed set of projects. The old steps silently skipped ModularMonolith.Tests (and the cross-project-mocking sample tests); the solution target discovers every test project, so new ones are covered with no workflow edit. 453 tests run (was a subset). - Merge per-project coverage with ReportGenerator before posting. Each project emits its own coverage.cobertura.xml covering the same assemblies only in the slice it exercises; the previous summary listed each assembly N times with different numbers (the misleading 34%). The merged report shows each assembly once with its real combined coverage (e.g. core ~92%). --- .github/workflows/ci.yml | 61 +++++++++++++--------------------------- 1 file changed, 19 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9501c3..9f0e54e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,45 +43,19 @@ jobs: - name: Build run: dotnet build DSoftStudio.Mediator.slnx -c Release --no-restore + # Run EVERY test project in the solution in one shot. `dotnet test` on the + # solution discovers all test projects (and skips non-test ones), so a newly + # added test project is covered automatically — no need to edit this workflow + # (the old hand-listed steps silently skipped ModularMonolith.Tests). Coverage + # is collected per project and MERGED below into a single accurate report. - name: Test run: > - dotnet test tests/DSoftStudio.Mediator.Tests/DSoftStudio.Mediator.Tests.csproj + dotnet test DSoftStudio.Mediator.slnx -c Release --no-build - --logger "trx;LogFileName=test-results.trx" + --logger "trx" --collect:"XPlat Code Coverage" - - name: Test OpenTelemetry - run: > - dotnet test tests/DSoftStudio.Mediator.OpenTelemetry.Tests/DSoftStudio.Mediator.OpenTelemetry.Tests.csproj - -c Release - --no-build - --logger "trx;LogFileName=test-results-otel.trx" - --collect:"XPlat Code Coverage" - - - name: Test FluentValidation - run: > - dotnet test tests/DSoftStudio.Mediator.FluentValidation.Tests/DSoftStudio.Mediator.FluentValidation.Tests.csproj - -c Release - --no-build - --logger "trx;LogFileName=test-results-fv.trx" - --collect:"XPlat Code Coverage" - - - name: Test HybridCache - run: > - dotnet test tests/DSoftStudio.Mediator.HybridCache.Tests/DSoftStudio.Mediator.HybridCache.Tests.csproj - -c Release - --no-build - --logger "trx;LogFileName=test-results-hc.trx" - --collect:"XPlat Code Coverage" - - - name: Test InternalsVisibleTo - run: > - dotnet test tests/DSoftStudio.Mediator.InternalsVisibleTo.Tests/DSoftStudio.Mediator.InternalsVisibleTo.Tests.csproj - -c Release - --no-build - --logger "trx;LogFileName=test-results-ivt.trx" - # ── Artifacts (always uploaded, even on failure) ── - name: Upload test results if: always() @@ -97,22 +71,25 @@ jobs: name: coverage path: tests/**/TestResults/**/coverage.cobertura.xml - # Post coverage summary as a PR comment - - name: Coverage report + # MERGE all per-project coverage files into ONE report before summarizing. + # Each test project emits its own coverage.cobertura.xml covering the SAME + # assemblies only in the slice it exercises; feeding the raw glob to a summary + # lists each assembly N times with different numbers (the misleading "34%"). + # ReportGenerator unions them, so every assembly shows its real combined + # coverage exactly once. + - name: Merge coverage reports if: github.event_name == 'pull_request' - uses: irongut/CodeCoverageSummary@51cc3a756ddcd398d447c044c02cb6aa83fdae95 # v1.3.0 + uses: danielpalme/ReportGenerator-GitHub-Action@049f7ec958c672fd31d5cc1cb01622dc8d2e23ab # v5.5.10 with: - filename: tests/**/TestResults/**/coverage.cobertura.xml - badge: true - format: markdown - output: both - thresholds: '70 85' + reports: 'tests/**/TestResults/**/coverage.cobertura.xml' + targetdir: 'coverage' + reporttypes: 'Cobertura;MarkdownSummaryGithub' - name: Add coverage to PR if: github.event_name == 'pull_request' uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2 with: - path: code-coverage-results.md + path: coverage/SummaryGithub.md # ── Pack (only on main/tag, and only if all tests passed) ── - name: Pack NuGet packages From 6fef33887e3f042cfd8a2a70f294923c9a2c73cc Mon Sep 17 00:00:00 2001 From: Yander Santiesteban Rojas Date: Sun, 21 Jun 2026 17:55:46 -0300 Subject: [PATCH 05/11] test(generators): cover the 5 untested source generators (0% -> ~73-97%) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stream/notification/publish/pipeline source generators had ZERO test coverage. Add a shared in-memory harness (GeneratorTestHarness) that drives the real IIncrementalGenerators against a small user compilation and inspects the generated source, then cover each: - StreamGenerator 0% -> 96.7% (registry, AOT behavior closure, empty) - StreamInterceptorGenerator 0% -> 79.0% (CreateStream<,> interception, no-call-site) - NotificationGenerator 0% -> 90.6% (dispatch table, Publish(object) switch, empty) - PublishInterceptorGenerator 0% -> 88.1% (Publish interception, Publish(object) excluded) - MediatorPipelineGenerator 0% -> 73.1% (MediatorRegistry entry points, empty skeleton) Harness note: the netstandard2.0 Abstractions exposes IAsyncEnumerable from Microsoft.Bcl.AsyncInterfaces, so stream call sites only bind once that assembly is referenced — the harness adds it. 11 tests, all green (project: 358). --- .../Generators/GeneratorTestHarness.cs | 96 +++++++++++++++++ .../MediatorPipelineGeneratorTests.cs | 62 +++++++++++ .../Generators/NotificationGeneratorTests.cs | 65 +++++++++++ .../PublishInterceptorGeneratorTests.cs | 70 ++++++++++++ .../Generators/StreamGeneratorTests.cs | 101 ++++++++++++++++++ .../StreamInterceptorGeneratorTests.cs | 68 ++++++++++++ 6 files changed, 462 insertions(+) create mode 100644 tests/DSoftStudio.Mediator.Tests/Generators/GeneratorTestHarness.cs create mode 100644 tests/DSoftStudio.Mediator.Tests/Generators/MediatorPipelineGeneratorTests.cs create mode 100644 tests/DSoftStudio.Mediator.Tests/Generators/NotificationGeneratorTests.cs create mode 100644 tests/DSoftStudio.Mediator.Tests/Generators/PublishInterceptorGeneratorTests.cs create mode 100644 tests/DSoftStudio.Mediator.Tests/Generators/StreamGeneratorTests.cs create mode 100644 tests/DSoftStudio.Mediator.Tests/Generators/StreamInterceptorGeneratorTests.cs diff --git a/tests/DSoftStudio.Mediator.Tests/Generators/GeneratorTestHarness.cs b/tests/DSoftStudio.Mediator.Tests/Generators/GeneratorTestHarness.cs new file mode 100644 index 0000000..4ec7570 --- /dev/null +++ b/tests/DSoftStudio.Mediator.Tests/Generators/GeneratorTestHarness.cs @@ -0,0 +1,96 @@ +// Copyright (c) DSoftStudio. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using DSoftStudio.Mediator.Generators; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace DSoftStudio.Mediator.Tests.Generators; + +/// +/// Shared in-memory harness for driving the real incremental source generators against a small user +/// compilation and inspecting the generated source — exactly as the compiler runs them. Mirrors the +/// reference set proven by InterceptorNamespaceCompilationTests (BCL + Abstractions + Mediator + +/// DI + the cross-TFM facades needed to unify netstandard2.0 generator types with .NET 10 BCL types). +/// +internal static class GeneratorTestHarness +{ + private static readonly MetadataReference[] References = BuildReferences(); + + private static MetadataReference[] BuildReferences() + { + var runtimeDir = Path.GetDirectoryName(typeof(object).Assembly.Location)!; + + var refs = new List + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(Path.Combine(runtimeDir, "System.Runtime.dll")), + MetadataReference.CreateFromFile(typeof(DSoftStudio.Mediator.Abstractions.ISender).Assembly.Location), + MetadataReference.CreateFromFile(typeof(DSoftStudio.Mediator.Mediator).Assembly.Location), + MetadataReference.CreateFromFile( + typeof(Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions).Assembly.Location), + }; + + // Microsoft.Bcl.AsyncInterfaces — the netstandard2.0 Abstractions declares IAsyncEnumerable (used by + // every stream API: IStreamRequestHandler.Handle, IMediator.CreateStream) from this package. Without it, + // stream call sites/handlers fail to bind (CS0012) and the stream generators see nothing. + var bclAsync = Path.Combine(AppContext.BaseDirectory, "Microsoft.Bcl.AsyncInterfaces.dll"); + if (File.Exists(bclAsync)) + refs.Add(MetadataReference.CreateFromFile(bclAsync)); + + // Facade assemblies required for cross-TFM type unification (netstandard2.0 → .NET 10). + foreach (var facade in new[] + { + "netstandard.dll", + "System.Threading.Tasks.Extensions.dll", + "System.Collections.dll", + "System.Linq.dll", + }) + { + var path = Path.Combine(runtimeDir, facade); + if (File.Exists(path)) + refs.Add(MetadataReference.CreateFromFile(path)); + } + + return refs.ToArray(); + } + + /// + /// Runs over and returns the single + /// generator run result plus the post-generation compilation. Set to + /// true for generators that emit [InterceptsLocation] — they need the + /// InterceptorsNamespaces feature flag or the compiler rejects the generated code with CS9137. + /// + public static (GeneratorRunResult Result, Compilation Output) Run( + string source, bool interceptors = false) + where TGenerator : IIncrementalGenerator, new() + { + var features = new Dictionary(); + if (interceptors) + { + features["InterceptorsNamespaces"] = "DSoftStudio.Mediator.Generated"; + features["InterceptorsPreviewNamespaces"] = "DSoftStudio.Mediator.Generated"; + } + + var parseOptions = CSharpParseOptions.Default + .WithLanguageVersion(LanguageVersion.Preview) + .WithFeatures(features); + + var compilation = CSharpCompilation.Create( + "TestAssembly", + [CSharpSyntaxTree.ParseText(source, parseOptions, path: "Test.cs")], + References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + GeneratorDriver driver = CSharpGeneratorDriver.Create( + generators: new IIncrementalGenerator[] { new TGenerator() }.Select(GeneratorExtensions.AsSourceGenerator), + parseOptions: parseOptions); + + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var output, out _); + return (driver.GetRunResult().Results.Single(), output); + } + + /// All documents this generator emitted, concatenated — for substring assertions. + public static string AllSource(this GeneratorRunResult result) + => string.Concat(result.GeneratedSources.Select(s => s.SourceText.ToString())); +} diff --git a/tests/DSoftStudio.Mediator.Tests/Generators/MediatorPipelineGeneratorTests.cs b/tests/DSoftStudio.Mediator.Tests/Generators/MediatorPipelineGeneratorTests.cs new file mode 100644 index 0000000..ae09a75 --- /dev/null +++ b/tests/DSoftStudio.Mediator.Tests/Generators/MediatorPipelineGeneratorTests.cs @@ -0,0 +1,62 @@ +// Copyright (c) DSoftStudio. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using DSoftStudio.Mediator.Generators; + +namespace DSoftStudio.Mediator.Tests.Generators; + +/// +/// Drives the real in-memory and asserts the generated +/// MediatorRegistry.g.cs — the single registration entry point (RegisterMediatorHandlers / +/// RegisterPipelineChains / PrecompilePipelines). It had zero coverage before this. +/// +public class MediatorPipelineGeneratorTests +{ + private const string RequestHandler = """ + using System.Threading; + using System.Threading.Tasks; + using DSoftStudio.Mediator.Abstractions; + + namespace TestApp; + + public record GetUser(int Id) : IRequest; + + public sealed class GetUserHandler : IRequestHandler + { + public ValueTask Handle(GetUser request, CancellationToken ct) => new("user"); + } + """; + + [Fact] + public void Generates_MediatorRegistry_For_RequestHandler() + { + var (result, _) = GeneratorTestHarness.Run(RequestHandler); + var code = result.AllSource(); + + code.ShouldContain("MediatorRegistry"); + code.ShouldContain("RegisterMediatorHandlers"); + code.ShouldContain("RegisterPipelineChains"); + code.ShouldContain("PrecompilePipelines"); + code.ShouldContain("GetUser"); + } + + [Fact] + public void Generates_Registry_Skeleton_When_No_Handlers() + { + // No request handler at all → the registry entry points are still emitted (so consumer startup code + // that calls them compiles), just with no per-handler registration. Covers the empty path. + const string none = """ + using DSoftStudio.Mediator.Abstractions; + + namespace TestApp; + + public record GetUser(int Id) : IRequest; + """; + + var (result, _) = GeneratorTestHarness.Run(none); + var code = result.AllSource(); + + code.ShouldContain("MediatorRegistry"); + code.ShouldContain("PrecompilePipelines"); + } +} diff --git a/tests/DSoftStudio.Mediator.Tests/Generators/NotificationGeneratorTests.cs b/tests/DSoftStudio.Mediator.Tests/Generators/NotificationGeneratorTests.cs new file mode 100644 index 0000000..bb225ee --- /dev/null +++ b/tests/DSoftStudio.Mediator.Tests/Generators/NotificationGeneratorTests.cs @@ -0,0 +1,65 @@ +// Copyright (c) DSoftStudio. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using DSoftStudio.Mediator.Generators; + +namespace DSoftStudio.Mediator.Tests.Generators; + +/// +/// Drives the real in-memory and asserts the generated +/// NotificationDispatch.g.cs — the compile-time notification dispatch tables that replace +/// runtime service enumeration. It had zero coverage before this. +/// +public class NotificationGeneratorTests +{ + private const string NotificationHandler = """ + using System.Threading; + using System.Threading.Tasks; + using DSoftStudio.Mediator.Abstractions; + + namespace TestApp; + + public record OrderPlaced(int Id) : INotification; + + public sealed class EmailHandler : INotificationHandler + { + public Task Handle(OrderPlaced notification, CancellationToken ct) => Task.CompletedTask; + } + """; + + [Fact] + public void Generates_NotificationDispatch_For_Handler() + { + var (result, _) = GeneratorTestHarness.Run(NotificationHandler); + var code = result.AllSource(); + + code.ShouldContain("NotificationRegistry"); + code.ShouldContain("PrecompileNotifications"); + code.ShouldContain("TryInitialize"); // dispatch table populated for the notification + code.ShouldContain("NotificationObjectDispatch.Register"); // AOT-safe Publish(object) path + code.ShouldContain("PublishObjectSwitch"); // source-generated type-switch fast path + code.ShouldContain("OrderPlaced"); + code.ShouldContain("EmailHandler"); + } + + [Fact] + public void Generates_Empty_Dispatch_When_No_Handlers() + { + // A notification type with no handler: the registry skeleton (+ PrecompileNotifications) is still + // emitted, but with no dispatch group → no PublishObjectSwitch. Covers the empty-groups branch. + const string none = """ + using DSoftStudio.Mediator.Abstractions; + + namespace TestApp; + + public record OrderPlaced(int Id) : INotification; + """; + + var (result, _) = GeneratorTestHarness.Run(none); + var code = result.AllSource(); + + code.ShouldContain("NotificationRegistry"); + code.ShouldContain("PrecompileNotifications"); + code.ShouldNotContain("PublishObjectSwitch"); + } +} diff --git a/tests/DSoftStudio.Mediator.Tests/Generators/PublishInterceptorGeneratorTests.cs b/tests/DSoftStudio.Mediator.Tests/Generators/PublishInterceptorGeneratorTests.cs new file mode 100644 index 0000000..3131757 --- /dev/null +++ b/tests/DSoftStudio.Mediator.Tests/Generators/PublishInterceptorGeneratorTests.cs @@ -0,0 +1,70 @@ +// Copyright (c) DSoftStudio. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using DSoftStudio.Mediator.Generators; + +namespace DSoftStudio.Mediator.Tests.Generators; + +/// +/// Drives the real in-memory. It intercepts +/// IPublisher.Publish<TNotification>() call sites (where the argument implements +/// INotification) to skip interface dispatch on the publish hot path — zero coverage before this. +/// +public class PublishInterceptorGeneratorTests +{ + private const string PublishCallSite = """ + using System.Threading.Tasks; + using DSoftStudio.Mediator.Abstractions; + + namespace TestApp; + + public record OrderPlaced(int Id) : INotification; + + public static class Consumer + { + public static Task Run(IPublisher publisher) + => publisher.Publish(new OrderPlaced(1)); + } + """; + + [Fact] + public void Emits_Interceptor_For_Publish_CallSite() + { + var (result, output) = GeneratorTestHarness.Run( + PublishCallSite, interceptors: true); + + var code = result.AllSource(); + + result.GeneratedSources.ShouldNotBeEmpty( + "PublishInterceptorGenerator should intercept publisher.Publish(new OrderPlaced(1))"); + code.ShouldContain("InterceptsLocation"); + code.ShouldContain("DSoftStudio.Mediator.Generated"); + output.GetDiagnostics().Where(d => d.Id == "CS9137").ShouldBeEmpty(); + } + + [Fact] + public void Does_Not_Intercept_Publish_Object_Overload() + { + // Publish(object) — the argument does NOT implement INotification, so the generator must skip it + // (only the strongly-typed Publish hot path is intercepted). Covers the exclusion branch. + const string objectOverload = """ + using System.Threading.Tasks; + using DSoftStudio.Mediator.Abstractions; + + namespace TestApp; + + public static class Consumer + { + public static Task Run(IPublisher publisher) + => publisher.Publish((object)"not a notification"); + } + """; + + var (result, _) = GeneratorTestHarness.Run(objectOverload, interceptors: true); + + result.GeneratedSources + .SelectMany(s => s.SourceText.ToString().Split('\n')) + .Where(line => line.Contains("InterceptsLocation")) + .ShouldBeEmpty("Publish(object) must not be intercepted"); + } +} diff --git a/tests/DSoftStudio.Mediator.Tests/Generators/StreamGeneratorTests.cs b/tests/DSoftStudio.Mediator.Tests/Generators/StreamGeneratorTests.cs new file mode 100644 index 0000000..275c668 --- /dev/null +++ b/tests/DSoftStudio.Mediator.Tests/Generators/StreamGeneratorTests.cs @@ -0,0 +1,101 @@ +// Copyright (c) DSoftStudio. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using DSoftStudio.Mediator.Generators; + +namespace DSoftStudio.Mediator.Tests.Generators; + +/// +/// Drives the real in-memory and asserts the generated +/// StreamRegistry.g.cs — the stream registration path had zero coverage before this. +/// +public class StreamGeneratorTests +{ + private const string StreamHandler = """ + using System.Collections.Generic; + using System.Threading; + using DSoftStudio.Mediator.Abstractions; + + namespace TestApp; + + public record Countdown(int From) : IStreamRequest; + + public sealed class CountdownHandler : IStreamRequestHandler + { + // Body is irrelevant to the generator (it works off the symbol's interfaces); null! keeps it terse. + public IAsyncEnumerable Handle(Countdown request, CancellationToken ct) => null!; + } + """; + + [Fact] + public void Generates_StreamRegistry_With_Handler_Registration() + { + var (result, _) = GeneratorTestHarness.Run(StreamHandler); + var code = result.AllSource(); + + code.ShouldContain("StreamRegistry"); + code.ShouldContain("PrecompileStreams"); + code.ShouldContain("TryInitializeHandler"); // handler factory wired + code.ShouldContain("RegisterStreamPipeline"); // per-handler pipeline registration + code.ShouldContain("Countdown"); // the discovered stream request flows into the registry + } + + [Fact] + public void Emits_OpenGeneric_Behavior_Closure_When_StreamBehavior_Present() + { + // A local open-generic IStreamPipelineBehavior<,> alongside a handler triggers the AOT-safe + // closure-emit path (CloseAllOpenGenericStreamBehaviors / RemoveOpenGenericStreamBehaviorDescriptors). + const string withBehavior = """ + using System.Collections.Generic; + using System.Threading; + using DSoftStudio.Mediator.Abstractions; + + namespace TestApp; + + public record Countdown(int From) : IStreamRequest; + + public sealed class CountdownHandler : IStreamRequestHandler + { + public IAsyncEnumerable Handle(Countdown request, CancellationToken ct) => null!; + } + + public sealed class LoggingStreamBehavior + : IStreamPipelineBehavior + where TRequest : IStreamRequest + { + public IAsyncEnumerable Handle( + TRequest request, + IStreamRequestHandler next, + CancellationToken ct) => next.Handle(request, ct); + } + """; + + var (result, _) = GeneratorTestHarness.Run(withBehavior); + var code = result.AllSource(); + + code.ShouldContain("CloseAllOpenGenericStreamBehaviors"); + code.ShouldContain("RemoveOpenGenericStreamBehaviorDescriptors"); + code.ShouldContain("LoggingStreamBehavior"); + } + + [Fact] + public void Generates_Empty_Registry_When_No_Stream_Handlers() + { + // No stream handler anywhere: the registry skeleton (+ PrecompileStreams entry point) is still + // emitted, but with no per-handler registration line — covers the empty-registrations branch. + const string noStream = """ + using DSoftStudio.Mediator.Abstractions; + + namespace TestApp; + + public record Ping : IRequest; + """; + + var (result, _) = GeneratorTestHarness.Run(noStream); + var code = result.AllSource(); + + code.ShouldContain("StreamRegistry"); + code.ShouldContain("PrecompileStreams"); + code.ShouldNotContain("TryInitializeHandler"); + } +} diff --git a/tests/DSoftStudio.Mediator.Tests/Generators/StreamInterceptorGeneratorTests.cs b/tests/DSoftStudio.Mediator.Tests/Generators/StreamInterceptorGeneratorTests.cs new file mode 100644 index 0000000..b16651f --- /dev/null +++ b/tests/DSoftStudio.Mediator.Tests/Generators/StreamInterceptorGeneratorTests.cs @@ -0,0 +1,68 @@ +// Copyright (c) DSoftStudio. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using DSoftStudio.Mediator.Generators; + +namespace DSoftStudio.Mediator.Tests.Generators; + +/// +/// Drives the real in-memory. It intercepts +/// IMediator.CreateStream<TRequest, TResponse>() call sites to skip the interface dispatch +/// and delegate indirection on the stream hot path — it had zero coverage before this. +/// +public class StreamInterceptorGeneratorTests +{ + private const string CreateStreamCallSite = """ + using System.Collections.Generic; + using DSoftStudio.Mediator.Abstractions; + + namespace TestApp; + + public record Ticker(int N) : IStreamRequest; + + public static class Consumer + { + public static IAsyncEnumerable Run(IMediator mediator) + => mediator.CreateStream(new Ticker(3)); + } + """; + + [Fact] + public void Emits_Interceptor_For_CreateStream_CallSite() + { + // interceptors: true → InterceptorsNamespaces feature set, otherwise the generated + // [InterceptsLocation] methods would be rejected with CS9137. + var (result, output) = GeneratorTestHarness.Run( + CreateStreamCallSite, interceptors: true); + + var code = result.AllSource(); + + result.GeneratedSources.ShouldNotBeEmpty( + "StreamInterceptorGenerator should intercept the mediator.CreateStream() call site"); + code.ShouldContain("InterceptsLocation"); + code.ShouldContain("DSoftStudio.Mediator.Generated"); + + // The interceptor wiring must compile cleanly (no CS9137) with the feature flag on. + output.GetDiagnostics().Where(d => d.Id == "CS9137").ShouldBeEmpty(); + } + + [Fact] + public void Does_Not_Emit_When_No_CreateStream_CallSite() + { + // A stream request type with no CreateStream() invocation → nothing to intercept. + const string noCallSite = """ + using DSoftStudio.Mediator.Abstractions; + + namespace TestApp; + + public record Ticker(int N) : IStreamRequest; + """; + + var (result, _) = GeneratorTestHarness.Run(noCallSite, interceptors: true); + + result.GeneratedSources + .SelectMany(s => s.SourceText.ToString().Split('\n')) + .Where(line => line.Contains("InterceptsLocation")) + .ShouldBeEmpty("no CreateStream call site → no interceptor methods"); + } +} From 7358a58557c7cb9f79e5b9da90d43bcca5601151 Mon Sep 17 00:00:00 2001 From: Yander Santiesteban Rojas Date: Sun, 21 Jun 2026 18:19:53 -0300 Subject: [PATCH 06/11] test(generators): deepen generator coverage to ~92-97% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the 0%->coverage pass. Add a shared in-memory harness RunChain (runs two generators in sequence, so the second sees the first's output) plus a Release/optimization toggle, and cover the deeper branches: - MediatorPipelineGenerator -> 94.1% (AOT open-generic behavior closure, self-handling request) - StreamGenerator -> 96.7% - NotificationGenerator -> 93.4% (multi-handler grouping, abstract/generic/file-local rejection) - PublishInterceptorGenerator -> 91.2% (explicit + inferred Publish, Release, non-publisher exclusion) - StreamInterceptorGenerator -> 92.0% (inferred CreateStream via the generated typed extension) Note: the inferred CreateStream path is NOT dead code (an earlier guess) — it is reachable once MediatorExtensionsGenerator emits the typed CreateStream(this IMediator, T) extension that makes the inferred call bind; RunChain exercises exactly that two-generator scenario. The remaining uncovered lines are defensive early-exits that only fire when the mediator interface is absent (impossible in a real project), config-flag (SuppressInterceptors) branches, and external-assembly scans — covering those needs dedicated harness machinery for marginal value. --- .../Generators/GeneratorTestHarness.cs | 52 ++++++++++--- .../MediatorPipelineGeneratorTests.cs | 65 ++++++++++++++++ .../Generators/NotificationGeneratorTests.cs | 59 +++++++++++++++ .../PublishInterceptorGeneratorTests.cs | 59 +++++++++++++++ .../StreamInterceptorGeneratorTests.cs | 75 +++++++++++++++++++ 5 files changed, 300 insertions(+), 10 deletions(-) diff --git a/tests/DSoftStudio.Mediator.Tests/Generators/GeneratorTestHarness.cs b/tests/DSoftStudio.Mediator.Tests/Generators/GeneratorTestHarness.cs index 4ec7570..bd29932 100644 --- a/tests/DSoftStudio.Mediator.Tests/Generators/GeneratorTestHarness.cs +++ b/tests/DSoftStudio.Mediator.Tests/Generators/GeneratorTestHarness.cs @@ -62,8 +62,36 @@ private static MetadataReference[] BuildReferences() /// InterceptorsNamespaces feature flag or the compiler rejects the generated code with CS9137. /// public static (GeneratorRunResult Result, Compilation Output) Run( - string source, bool interceptors = false) + string source, bool interceptors = false, bool release = false) where TGenerator : IIncrementalGenerator, new() + { + var (parse, compilation) = Build(source, interceptors, release); + var driver = DriverFor(new TGenerator(), parse) + .RunGeneratorsAndUpdateCompilation(compilation, out var output, out _); + return (driver.GetRunResult().Results.Single(), output); + } + + /// + /// Runs then in sequence, with the second + /// generator seeing the first's emitted source. This mirrors a real build where one generator's output is a + /// prerequisite for another — e.g. MediatorExtensionsGenerator emits the typed + /// CreateStream(this IMediator, T) / Send(this ISender, T) extension that makes a + /// type-inferred call bind, which the interceptor generator then intercepts. + /// + public static (GeneratorRunResult Result, Compilation Output) RunChain( + string source, bool interceptors = false, bool release = false) + where TFirst : IIncrementalGenerator, new() + where TSecond : IIncrementalGenerator, new() + { + var (parse, compilation) = Build(source, interceptors, release); + DriverFor(new TFirst(), parse).RunGeneratorsAndUpdateCompilation(compilation, out var afterFirst, out _); + var driver = DriverFor(new TSecond(), parse) + .RunGeneratorsAndUpdateCompilation(afterFirst, out var output, out _); + return (driver.GetRunResult().Results.Single(), output); + } + + private static (CSharpParseOptions Parse, CSharpCompilation Compilation) Build( + string source, bool interceptors, bool release) { var features = new Dictionary(); if (interceptors) @@ -72,24 +100,28 @@ public static (GeneratorRunResult Result, Compilation Output) Run( features["InterceptorsPreviewNamespaces"] = "DSoftStudio.Mediator.Generated"; } - var parseOptions = CSharpParseOptions.Default + var parse = CSharpParseOptions.Default .WithLanguageVersion(LanguageVersion.Preview) .WithFeatures(features); var compilation = CSharpCompilation.Create( "TestAssembly", - [CSharpSyntaxTree.ParseText(source, parseOptions, path: "Test.cs")], + [CSharpSyntaxTree.ParseText(source, parse, path: "Test.cs")], References, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + new CSharpCompilationOptions( + OutputKind.DynamicallyLinkedLibrary, + // Release flips OptimizationLevel — the interceptor generators emit slightly different code + // (e.g. [MethodImpl(AggressiveInlining)]) on the Release path, exercised by passing release: true. + optimizationLevel: release ? OptimizationLevel.Release : OptimizationLevel.Debug)); - GeneratorDriver driver = CSharpGeneratorDriver.Create( - generators: new IIncrementalGenerator[] { new TGenerator() }.Select(GeneratorExtensions.AsSourceGenerator), - parseOptions: parseOptions); - - driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var output, out _); - return (driver.GetRunResult().Results.Single(), output); + return (parse, compilation); } + private static GeneratorDriver DriverFor(IIncrementalGenerator generator, CSharpParseOptions parse) + => CSharpGeneratorDriver.Create( + generators: new[] { generator.AsSourceGenerator() }, + parseOptions: parse); + /// All documents this generator emitted, concatenated — for substring assertions. public static string AllSource(this GeneratorRunResult result) => string.Concat(result.GeneratedSources.Select(s => s.SourceText.ToString())); diff --git a/tests/DSoftStudio.Mediator.Tests/Generators/MediatorPipelineGeneratorTests.cs b/tests/DSoftStudio.Mediator.Tests/Generators/MediatorPipelineGeneratorTests.cs index ae09a75..26e8d4c 100644 --- a/tests/DSoftStudio.Mediator.Tests/Generators/MediatorPipelineGeneratorTests.cs +++ b/tests/DSoftStudio.Mediator.Tests/Generators/MediatorPipelineGeneratorTests.cs @@ -40,6 +40,71 @@ public void Generates_MediatorRegistry_For_RequestHandler() code.ShouldContain("GetUser"); } + [Fact] + public void Emits_Aot_Behavior_Closure_For_OpenGeneric_Behavior_And_Processor() + { + // A handler PLUS open-generic pipeline components (behavior + pre-processor) triggers the AOT-safe + // closure emit (CloseAllOpenGenericBehaviors / RemoveOpenGenericBehaviorDescriptors) for each kind — + // the largest previously-uncovered block of the generator. + const string rich = """ + using System.Threading; + using System.Threading.Tasks; + using DSoftStudio.Mediator.Abstractions; + + namespace TestApp; + + public record GetUser(int Id) : IRequest; + + public sealed class GetUserHandler : IRequestHandler + { + public ValueTask Handle(GetUser request, CancellationToken ct) => new("u"); + } + + public sealed class LoggingBehavior : IPipelineBehavior + where TRequest : IRequest + { + public ValueTask Handle( + TRequest request, IRequestHandler next, CancellationToken ct) + => next.Handle(request, ct); + } + + public sealed class ValidationPreProcessor : IRequestPreProcessor + { + public ValueTask Process(TRequest request, CancellationToken ct) => default; + } + """; + + var (result, _) = GeneratorTestHarness.Run(rich); + var code = result.AllSource(); + + code.ShouldContain("CloseAllOpenGenericBehaviors"); + code.ShouldContain("RemoveOpenGenericBehaviorDescriptors"); + code.ShouldContain("LoggingBehavior"); + } + + [Fact] + public void Registers_Self_Handling_Request() + { + // A self-handling request — implements IRequest, has a static Execute, and NO separate + // IRequestHandler — is registered through the self-handler discovery path (previously uncovered). + const string selfHandler = """ + using DSoftStudio.Mediator.Abstractions; + + namespace TestApp; + + public record GetTime(int Tz) : IRequest + { + public static string Execute(GetTime request) => "now"; + } + """; + + var (result, _) = GeneratorTestHarness.Run(selfHandler); + var code = result.AllSource(); + + code.ShouldContain("MediatorRegistry"); + code.ShouldContain("GetTime"); + } + [Fact] public void Generates_Registry_Skeleton_When_No_Handlers() { diff --git a/tests/DSoftStudio.Mediator.Tests/Generators/NotificationGeneratorTests.cs b/tests/DSoftStudio.Mediator.Tests/Generators/NotificationGeneratorTests.cs index bb225ee..f156eb6 100644 --- a/tests/DSoftStudio.Mediator.Tests/Generators/NotificationGeneratorTests.cs +++ b/tests/DSoftStudio.Mediator.Tests/Generators/NotificationGeneratorTests.cs @@ -42,6 +42,65 @@ public void Generates_NotificationDispatch_For_Handler() code.ShouldContain("EmailHandler"); } + [Fact] + public void Groups_Multiple_Handlers_For_Same_Notification() + { + // Two handlers for one notification → both factories land in the same dispatch group (covers the + // per-handler inner loop). Abstract + open-generic handlers must be skipped (GetHandlerInfo rejection). + const string twoHandlers = """ + using System.Threading; + using System.Threading.Tasks; + using DSoftStudio.Mediator.Abstractions; + + namespace TestApp; + + public record OrderPlaced(int Id) : INotification; + + public sealed class EmailHandler : INotificationHandler + { + public Task Handle(OrderPlaced n, CancellationToken ct) => Task.CompletedTask; + } + + public sealed class AuditHandler : INotificationHandler + { + public Task Handle(OrderPlaced n, CancellationToken ct) => Task.CompletedTask; + } + + // Abstract handler — must be skipped by the generator. + public abstract class BaseHandler : INotificationHandler + { + public abstract Task Handle(OrderPlaced n, CancellationToken ct); + } + + // Open-generic handler — must be skipped (the generator only registers closed concrete handlers). + public sealed class GenericHandler : INotificationHandler + { + public Task Handle(OrderPlaced n, CancellationToken ct) => Task.CompletedTask; + } + + // file-local handler — must be skipped (cannot be referenced for registration). + file sealed class FileLocalHandler : INotificationHandler + { + public Task Handle(OrderPlaced n, CancellationToken ct) => Task.CompletedTask; + } + + // A class WITH a base list that is NOT a notification handler — exercises the + // "candidate matched syntactically but rejected semantically" branch. + public sealed class NotAHandler : System.IDisposable + { + public void Dispose() { } + } + """; + + var (result, _) = GeneratorTestHarness.Run(twoHandlers); + var code = result.AllSource(); + + code.ShouldContain("EmailHandler"); + code.ShouldContain("AuditHandler"); + code.ShouldNotContain("BaseHandler"); // abstract → skipped + code.ShouldNotContain("GenericHandler"); // open-generic → skipped + } + [Fact] public void Generates_Empty_Dispatch_When_No_Handlers() { diff --git a/tests/DSoftStudio.Mediator.Tests/Generators/PublishInterceptorGeneratorTests.cs b/tests/DSoftStudio.Mediator.Tests/Generators/PublishInterceptorGeneratorTests.cs index 3131757..b280d26 100644 --- a/tests/DSoftStudio.Mediator.Tests/Generators/PublishInterceptorGeneratorTests.cs +++ b/tests/DSoftStudio.Mediator.Tests/Generators/PublishInterceptorGeneratorTests.cs @@ -42,6 +42,65 @@ public void Emits_Interceptor_For_Publish_CallSite() output.GetDiagnostics().Where(d => d.Id == "CS9137").ShouldBeEmpty(); } + [Fact] + public void Intercepts_Explicit_Generic_Publish_On_Release_Build() + { + // Explicit Publish(...) (vs the inferred form above) on a Release build — covers the + // explicit type-argument path and the Release emit branch. + const string explicitPublish = """ + using System.Threading.Tasks; + using DSoftStudio.Mediator.Abstractions; + + namespace TestApp; + + public record OrderPlaced(int Id) : INotification; + + public static class Consumer + { + public static Task Run(IPublisher publisher) + => publisher.Publish(new OrderPlaced(1)); + } + """; + + var (result, output) = GeneratorTestHarness.Run( + explicitPublish, interceptors: true, release: true); + + result.GeneratedSources.ShouldNotBeEmpty(); + result.AllSource().ShouldContain("InterceptsLocation"); + output.GetDiagnostics().Where(d => d.Id == "CS9137").ShouldBeEmpty(); + } + + [Fact] + public void Ignores_Publish_On_Non_Publisher_Type() + { + // Publish() on a type that is NOT IPublisher must not be intercepted. Covers the receiver exclusion. + const string nonPublisher = """ + using System.Threading.Tasks; + using DSoftStudio.Mediator.Abstractions; + + namespace TestApp; + + public record OrderPlaced(int Id) : INotification; + + public sealed class Faker + { + public Task Publish(TNotification n) => Task.CompletedTask; + } + + public static class Consumer + { + public static Task Run(Faker f) => f.Publish(new OrderPlaced(1)); + } + """; + + var (result, _) = GeneratorTestHarness.Run(nonPublisher, interceptors: true); + + result.GeneratedSources + .SelectMany(s => s.SourceText.ToString().Split('\n')) + .Where(l => l.Contains("InterceptsLocation")) + .ShouldBeEmpty("Publish on a non-IPublisher type must not be intercepted"); + } + [Fact] public void Does_Not_Intercept_Publish_Object_Overload() { diff --git a/tests/DSoftStudio.Mediator.Tests/Generators/StreamInterceptorGeneratorTests.cs b/tests/DSoftStudio.Mediator.Tests/Generators/StreamInterceptorGeneratorTests.cs index b16651f..c586e7a 100644 --- a/tests/DSoftStudio.Mediator.Tests/Generators/StreamInterceptorGeneratorTests.cs +++ b/tests/DSoftStudio.Mediator.Tests/Generators/StreamInterceptorGeneratorTests.cs @@ -46,6 +46,81 @@ public void Emits_Interceptor_For_CreateStream_CallSite() output.GetDiagnostics().Where(d => d.Id == "CS9137").ShouldBeEmpty(); } + [Fact] + public void Intercepts_CreateStream_On_Release_Build() + { + // Release flips OptimizationLevel → exercises the Release emit branch of the interceptor generator. + var (result, output) = GeneratorTestHarness.Run( + CreateStreamCallSite, interceptors: true, release: true); + + result.GeneratedSources.ShouldNotBeEmpty(); + result.AllSource().ShouldContain("InterceptsLocation"); + output.GetDiagnostics().Where(d => d.Id == "CS9137").ShouldBeEmpty(); + } + + [Fact] + public void Intercepts_Type_Inferred_CreateStream_Via_Generated_Extension() + { + // `mediator.CreateStream(request)` (no ) only BINDS once MediatorExtensionsGenerator has + // emitted the typed `CreateStream(this IMediator, Ticker)` extension (TResponse can't be inferred from + // the open IMediator.CreateStream alone). Running both generators in sequence, the + // inferred call binds and is intercepted — exercising the inferred type-resolution path. This is the real + // two-generator build scenario; a single-generator run would never reach it. + const string inferred = """ + using System.Collections.Generic; + using System.Threading; + using DSoftStudio.Mediator.Abstractions; + + namespace TestApp; + + public record Ticker(int N) : IStreamRequest; + + public sealed class TickerHandler : IStreamRequestHandler + { + public IAsyncEnumerable Handle(Ticker request, CancellationToken ct) => null!; + } + + public static class Consumer + { + public static IAsyncEnumerable Run(IMediator mediator) => mediator.CreateStream(new Ticker(3)); + } + """; + + var (result, _) = GeneratorTestHarness.RunChain( + inferred, interceptors: true); + + result.AllSource().ShouldContain("InterceptsLocation"); + } + + [Fact] + public void Ignores_CreateStream_On_Non_Mediator_Type() + { + // A CreateStream<,> method on a type that is NOT IMediator must not be intercepted (the generator + // verifies the receiver implements IMediator). Covers the receiver-type exclusion branch. + const string nonMediator = """ + using System.Collections.Generic; + + namespace TestApp; + + public sealed class Faker + { + public IAsyncEnumerable CreateStream(TRequest r) => null!; + } + + public static class Consumer + { + public static IAsyncEnumerable Run(Faker f) => f.CreateStream(0); + } + """; + + var (result, _) = GeneratorTestHarness.Run(nonMediator, interceptors: true); + + result.GeneratedSources + .SelectMany(s => s.SourceText.ToString().Split('\n')) + .Where(l => l.Contains("InterceptsLocation")) + .ShouldBeEmpty("CreateStream on a non-IMediator type must not be intercepted"); + } + [Fact] public void Does_Not_Emit_When_No_CreateStream_CallSite() { From a6065c1696d5b5af0535364810c49d22e0506cf0 Mon Sep 17 00:00:00 2001 From: Yander Santiesteban Rojas Date: Sun, 21 Jun 2026 18:35:03 -0300 Subject: [PATCH 07/11] test(generators): cover EquatableArray, SendInterceptor, self-handler discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the remaining low-coverage generator helpers (the merged CI report showed these well below the rest): - EquatableArray 35% -> 100% (direct unit tests: Equals/GetHashCode/enumeration/default) - SelfHandlerDetail 24% -> 100% (direct struct equality + properties) - SelfHandlerParam 29% -> 100% - HandlerDiscovery 75% -> 95% (self-handler discovery across every Execute return shape — sync/Task/ValueTask/void/Task — and param kind — request/service/cancellation) - SendInterceptorGenerator 79% -> 93% (inferred Send via the generated typed extension using RunChain, Release path, non-sender + expression-tree exclusions) Adds [InternalsVisibleTo("DSoftStudio.Mediator.Tests")] on the generators project (matching the strong-name key) so the internal helper structs can be tested directly. --- .../DSoftStudio.Mediator.Generators.csproj | 6 + .../Generators/EquatableArrayTests.cs | 66 +++++++++ .../Generators/SelfHandlerTests.cs | 104 ++++++++++++++ .../SendInterceptorGeneratorTests.cs | 134 ++++++++++++++++++ 4 files changed, 310 insertions(+) create mode 100644 tests/DSoftStudio.Mediator.Tests/Generators/EquatableArrayTests.cs create mode 100644 tests/DSoftStudio.Mediator.Tests/Generators/SelfHandlerTests.cs create mode 100644 tests/DSoftStudio.Mediator.Tests/Generators/SendInterceptorGeneratorTests.cs diff --git a/src/DSoftStudio.Mediator.Generators/DSoftStudio.Mediator.Generators.csproj b/src/DSoftStudio.Mediator.Generators/DSoftStudio.Mediator.Generators.csproj index ae86a0b..c8a99b7 100644 --- a/src/DSoftStudio.Mediator.Generators/DSoftStudio.Mediator.Generators.csproj +++ b/src/DSoftStudio.Mediator.Generators/DSoftStudio.Mediator.Generators.csproj @@ -33,4 +33,10 @@ + + + + + diff --git a/tests/DSoftStudio.Mediator.Tests/Generators/EquatableArrayTests.cs b/tests/DSoftStudio.Mediator.Tests/Generators/EquatableArrayTests.cs new file mode 100644 index 0000000..9f4403f --- /dev/null +++ b/tests/DSoftStudio.Mediator.Tests/Generators/EquatableArrayTests.cs @@ -0,0 +1,66 @@ +// Copyright (c) DSoftStudio. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using DSoftStudio.Mediator.Generators; + +namespace DSoftStudio.Mediator.Tests.Generators; + +/// +/// Direct unit tests for — the value-equality wrapper every generator uses so +/// the incremental pipeline can compare collected results structurally. It was the least-covered generator type. +/// +public class EquatableArrayTests +{ + [Fact] + public void Empty_And_Null_Constructor_Are_Length_Zero() + { + EquatableArray.Empty.Length.ShouldBe(0); + new EquatableArray(null!).Length.ShouldBe(0); // null is normalized to Array.Empty + } + + [Fact] + public void Indexer_And_Length_Reflect_The_Backing_Array() + { + var a = new EquatableArray(new[] { 10, 20, 30 }); + + a.Length.ShouldBe(3); + a[0].ShouldBe(10); + a[2].ShouldBe(30); + } + + [Fact] + public void Equals_Is_Structural_ElementWise() + { + var a = new EquatableArray(new[] { 1, 2, 3 }); + var same = new EquatableArray(new[] { 1, 2, 3 }); + var diffElement = new EquatableArray(new[] { 1, 2, 9 }); + var diffLength = new EquatableArray(new[] { 1, 2 }); + + a.Equals(same).ShouldBeTrue(); + a.Equals(diffElement).ShouldBeFalse(); // same length, different element + a.Equals(diffLength).ShouldBeFalse(); // different length (early out) + a.Equals((object)same).ShouldBeTrue(); // object overload, matching type + a.Equals((object)"not an array").ShouldBeFalse(); // object overload, wrong type + } + + [Fact] + public void Equal_Arrays_Share_HashCode() + { + var a = new EquatableArray(new[] { "x", "y" }); + var same = new EquatableArray(new[] { "x", "y" }); + + a.GetHashCode().ShouldBe(same.GetHashCode()); + } + + [Fact] + public void Enumerates_All_Elements_Generic_And_NonGeneric() + { + var a = new EquatableArray(new[] { 5, 6, 7 }); + + a.ToList().ShouldBe(new[] { 5, 6, 7 }); // IEnumerable.GetEnumerator + + var e = ((System.Collections.IEnumerable)a).GetEnumerator(); // explicit non-generic GetEnumerator + e.MoveNext().ShouldBeTrue(); + e.Current.ShouldBe(5); + } +} diff --git a/tests/DSoftStudio.Mediator.Tests/Generators/SelfHandlerTests.cs b/tests/DSoftStudio.Mediator.Tests/Generators/SelfHandlerTests.cs new file mode 100644 index 0000000..0a4b7c2 --- /dev/null +++ b/tests/DSoftStudio.Mediator.Tests/Generators/SelfHandlerTests.cs @@ -0,0 +1,104 @@ +// Copyright (c) DSoftStudio. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using DSoftStudio.Mediator.Generators; + +namespace DSoftStudio.Mediator.Tests.Generators; + +/// +/// Covers the self-handling-request discovery (HandlerDiscovery.TryGetSelfHandlingRequest) across every +/// Execute return shape and parameter kind, plus the / +/// value structs directly. +/// +public class SelfHandlerTests +{ + // ── Value structs (equality + properties used by the incremental pipeline) ──────────────── + + [Fact] + public void SelfHandlerParam_Equality_And_Properties() + { + var a = new SelfHandlerParam(SelfHandlerParam.KindRequest, "R"); + var same = new SelfHandlerParam(SelfHandlerParam.KindRequest, "R"); + + a.Kind.ShouldBe(SelfHandlerParam.KindRequest); + a.TypeName.ShouldBe("R"); + a.Equals(same).ShouldBeTrue(); + a.GetHashCode().ShouldBe(same.GetHashCode()); + a.Equals(new SelfHandlerParam(SelfHandlerParam.KindService, "R")).ShouldBeFalse(); // different kind + a.Equals(new SelfHandlerParam(SelfHandlerParam.KindRequest, "Other")).ShouldBeFalse(); // different type + a.Equals((object)same).ShouldBeTrue(); + a.Equals((object)"x").ShouldBeFalse(); + } + + [Fact] + public void SelfHandlerDetail_Equality_And_Properties() + { + var ps = new EquatableArray(new[] { new SelfHandlerParam(SelfHandlerParam.KindRequest, "R") }); + var a = new SelfHandlerDetail("R", "string", SelfHandlerDetail.ReturnSync, ps); + var same = new SelfHandlerDetail("R", "string", SelfHandlerDetail.ReturnSync, ps); + + a.RequestType.ShouldBe("R"); + a.ResponseType.ShouldBe("string"); + a.ReturnKind.ShouldBe(SelfHandlerDetail.ReturnSync); + a.Parameters.Length.ShouldBe(1); + a.Equals(same).ShouldBeTrue(); + a.GetHashCode().ShouldBe(same.GetHashCode()); + a.Equals(new SelfHandlerDetail("R", "string", SelfHandlerDetail.ReturnTaskOfT, ps)).ShouldBeFalse(); + a.Equals((object)same).ShouldBeTrue(); + a.Equals((object)"x").ShouldBeFalse(); + } + + // ── Discovery across every Execute return shape + parameter kind ────────────────────────── + + [Fact] + public void Discovers_Self_Handlers_With_All_Return_Shapes_And_Param_Kinds() + { + // One self-handling request per return shape (sync T / Task / ValueTask / void→Unit / Task→Unit) + // and the ValueTask one also exercises all three SelfHandlerParam kinds (request, service, cancellation). + const string src = """ + using System.Threading; + using System.Threading.Tasks; + using DSoftStudio.Mediator.Abstractions; + + namespace TestApp; + + public interface IClock { } + + public record SyncReq(int X) : IRequest + { + public static string Execute(SyncReq r) => "s"; // ReturnSync, KindRequest + } + + public record VtReq(int X) : IRequest + { + public static ValueTask Execute(VtReq r, IClock clock, CancellationToken ct) => new("v"); // ReturnValueTaskOfT + all param kinds + } + + public record TaskReq(int X) : IRequest + { + public static Task Execute(TaskReq r) => Task.FromResult(1); // ReturnTaskOfT + } + + public record VoidReq(int X) : IRequest + { + public static void Execute(VoidReq r) { } // ReturnVoid → Unit + } + + public record TaskUnitReq(int X) : IRequest + { + public static Task Execute(TaskUnitReq r) => Task.CompletedTask; // ReturnTask → Unit + } + """; + + // DependencyInjectionGenerator consumes the full SelfHandlerDetail (return kind + params) to emit the + // self-handler adapter, so this exercises both the discovery branches and their use. + var (result, _) = GeneratorTestHarness.Run(src); + var code = result.AllSource(); + + code.ShouldContain("SyncReq"); + code.ShouldContain("VtReq"); + code.ShouldContain("TaskReq"); + code.ShouldContain("VoidReq"); + code.ShouldContain("TaskUnitReq"); + } +} diff --git a/tests/DSoftStudio.Mediator.Tests/Generators/SendInterceptorGeneratorTests.cs b/tests/DSoftStudio.Mediator.Tests/Generators/SendInterceptorGeneratorTests.cs new file mode 100644 index 0000000..4d78afb --- /dev/null +++ b/tests/DSoftStudio.Mediator.Tests/Generators/SendInterceptorGeneratorTests.cs @@ -0,0 +1,134 @@ +// Copyright (c) DSoftStudio. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using DSoftStudio.Mediator.Generators; + +namespace DSoftStudio.Mediator.Tests.Generators; + +/// +/// Drives the real in-memory. It intercepts +/// ISender.Send<TRequest, TResponse>() call sites to skip interface dispatch on the request hot +/// path. The existing InterceptorNamespaceCompilationTests only exercise the explicit-generic Debug path; these +/// cover the inferred call, the Release path, and the exclusion branches. +/// +public class SendInterceptorGeneratorTests +{ + private const string SendCallSite = """ + using System.Threading; + using System.Threading.Tasks; + using DSoftStudio.Mediator.Abstractions; + + namespace TestApp; + + public record Ping(int N) : IRequest; + + public sealed class PingHandler : IRequestHandler + { + public ValueTask Handle(Ping request, CancellationToken ct) => new("pong"); + } + + public static class Consumer + { + public static async Task Run(ISender sender) => await sender.Send(new Ping(1)); + } + """; + + [Fact] + public void Intercepts_Explicit_Send_On_Release_Build() + { + var (result, output) = GeneratorTestHarness.Run( + SendCallSite, interceptors: true, release: true); + + result.GeneratedSources.ShouldNotBeEmpty(); + result.AllSource().ShouldContain("InterceptsLocation"); + output.GetDiagnostics().Where(d => d.Id == "CS9137").ShouldBeEmpty(); + } + + [Fact] + public void Intercepts_Type_Inferred_Send_Via_Generated_Extension() + { + // sender.Send(request) (no ) only binds once MediatorExtensionsGenerator emits the typed + // Send(this ISender, Ping) extension; running both generators in sequence, the inferred call is intercepted. + const string inferred = """ + using System.Threading; + using System.Threading.Tasks; + using DSoftStudio.Mediator.Abstractions; + + namespace TestApp; + + public record Ping(int N) : IRequest; + + public sealed class PingHandler : IRequestHandler + { + public ValueTask Handle(Ping request, CancellationToken ct) => new("pong"); + } + + public static class Consumer + { + public static async Task Run(ISender sender) => await sender.Send(new Ping(1)); + } + """; + + var (result, _) = GeneratorTestHarness.RunChain( + inferred, interceptors: true); + + result.AllSource().ShouldContain("InterceptsLocation"); + } + + [Fact] + public void Ignores_Send_On_Non_Sender_Type() + { + const string nonSender = """ + using System.Threading.Tasks; + + namespace TestApp; + + public sealed class Faker + { + public Task Send(TRequest r) => Task.FromResult(default(TResponse)!); + } + + public static class Consumer + { + public static Task Run(Faker f) => f.Send(0); + } + """; + + var (result, _) = GeneratorTestHarness.Run(nonSender, interceptors: true); + + result.GeneratedSources + .SelectMany(s => s.SourceText.ToString().Split('\n')) + .Where(l => l.Contains("InterceptsLocation")) + .ShouldBeEmpty("Send on a non-ISender type must not be intercepted"); + } + + [Fact] + public void Ignores_Send_Inside_Expression_Tree_Lambda() + { + // A Send call captured inside an Expression<...> (e.g. a Moq Setup) must NOT be intercepted — an + // interceptor cannot attach to an expression-tree node. Covers InterceptorHelpers.IsInsideExpressionTreeLambda. + const string exprTree = """ + using System; + using System.Linq.Expressions; + using System.Threading.Tasks; + using DSoftStudio.Mediator.Abstractions; + + namespace TestApp; + + public record Ping(int N) : IRequest; + + public static class Consumer + { + public static Expression>> Setup() + => sender => sender.Send(new Ping(1)); + } + """; + + var (result, _) = GeneratorTestHarness.Run(exprTree, interceptors: true); + + result.GeneratedSources + .SelectMany(s => s.SourceText.ToString().Split('\n')) + .Where(l => l.Contains("InterceptsLocation")) + .ShouldBeEmpty("Send inside an expression-tree lambda must not be intercepted"); + } +} From 032642cef74a0a6c166ebff0f767a95d5c220215 Mon Sep 17 00:00:00 2001 From: Yander Santiesteban Rojas Date: Sun, 21 Jun 2026 18:46:23 -0300 Subject: [PATCH 08/11] ci: re-trigger coverage run The pull_request:synchronize event for a6065c1 (EquatableArray / SendInterceptor / self-handler coverage) was skipped by GitHub Actions, so the CI workflow never ran and the merged coverage comment is stale. Empty commit to fire a fresh run. From 2db4c0544a54f3031999e189edad9f4cc05bf601 Mon Sep 17 00:00:00 2001 From: Yander Santiesteban Rojas Date: Sun, 21 Jun 2026 18:54:04 -0300 Subject: [PATCH 09/11] test: cover BehaviorTypeInfo + the StreamBehaviorHandlerAdapter accessor - BehaviorTypeInfo 73%/20%br -> 100%/100%br (direct struct tests: properties, structural equality across all fields, null-safe GetHashCode) - StreamBehaviorHandlerAdapter 0% branch -> 100% branch (the stream half of the ADR-0049 IPipelineHandlerTypeAccessor seam; the request adapter was already tested, the stream chain-walk was not) --- .../Generators/BehaviorTypeInfoTests.cs | 61 +++++++++++++++++++ .../Pipeline/HandlerTypeAccessorTests.cs | 28 +++++++++ 2 files changed, 89 insertions(+) create mode 100644 tests/DSoftStudio.Mediator.Tests/Generators/BehaviorTypeInfoTests.cs diff --git a/tests/DSoftStudio.Mediator.Tests/Generators/BehaviorTypeInfoTests.cs b/tests/DSoftStudio.Mediator.Tests/Generators/BehaviorTypeInfoTests.cs new file mode 100644 index 0000000..617c2e8 --- /dev/null +++ b/tests/DSoftStudio.Mediator.Tests/Generators/BehaviorTypeInfoTests.cs @@ -0,0 +1,61 @@ +// Copyright (c) DSoftStudio. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using DSoftStudio.Mediator.Generators; + +namespace DSoftStudio.Mediator.Tests.Generators; + +/// +/// Direct unit tests for — the value record the generators use to carry a +/// discovered open-generic pipeline component (behavior / processor / exception handler / stream behavior) +/// through the incremental pipeline. It was at 20% branch coverage. +/// +public class BehaviorTypeInfoTests +{ + [Fact] + public void Properties_Reflect_Constructor() + { + var b = new BehaviorTypeInfo(PipelineInterfaceKind.Behavior, "global::App.Logging<,>", "global::App.Logging"); + + b.Kind.ShouldBe(PipelineInterfaceKind.Behavior); + b.OpenTypeName.ShouldBe("global::App.Logging<,>"); + b.BaseTypeName.ShouldBe("global::App.Logging"); + } + + [Fact] + public void Equality_Is_Structural_Across_All_Fields() + { + var a = new BehaviorTypeInfo(PipelineInterfaceKind.StreamBehavior, "Open", "Base"); + var same = new BehaviorTypeInfo(PipelineInterfaceKind.StreamBehavior, "Open", "Base"); + var diffKind = new BehaviorTypeInfo(PipelineInterfaceKind.Behavior, "Open", "Base"); + var diffOpen = new BehaviorTypeInfo(PipelineInterfaceKind.StreamBehavior, "Other", "Base"); + var diffBase = new BehaviorTypeInfo(PipelineInterfaceKind.StreamBehavior, "Open", "Other"); + + a.Equals(same).ShouldBeTrue(); + a.Equals(diffKind).ShouldBeFalse(); // Kind differs + a.Equals(diffOpen).ShouldBeFalse(); // OpenTypeName differs + a.Equals(diffBase).ShouldBeFalse(); // BaseTypeName differs + a.Equals((object)same).ShouldBeTrue(); + a.Equals((object)"not a behavior").ShouldBeFalse(); + } + + [Fact] + public void Equal_Values_Share_HashCode() + { + var a = new BehaviorTypeInfo(PipelineInterfaceKind.ExceptionHandler, "O", "B"); + var same = new BehaviorTypeInfo(PipelineInterfaceKind.ExceptionHandler, "O", "B"); + + a.GetHashCode().ShouldBe(same.GetHashCode()); + } + + [Fact] + public void HashCode_Is_Null_Safe() + { + // GetHashCode uses `OpenTypeName?.GetHashCode() ?? 0` — exercise the null branch so it never throws. + var b = new BehaviorTypeInfo(PipelineInterfaceKind.PostProcessor, null!, null!); + + _ = b.GetHashCode(); + b.OpenTypeName.ShouldBeNull(); + b.BaseTypeName.ShouldBeNull(); + } +} diff --git a/tests/DSoftStudio.Mediator.Tests/Pipeline/HandlerTypeAccessorTests.cs b/tests/DSoftStudio.Mediator.Tests/Pipeline/HandlerTypeAccessorTests.cs index fa6fa69..ff8aad6 100644 --- a/tests/DSoftStudio.Mediator.Tests/Pipeline/HandlerTypeAccessorTests.cs +++ b/tests/DSoftStudio.Mediator.Tests/Pipeline/HandlerTypeAccessorTests.cs @@ -49,4 +49,32 @@ public void Adapter_walks_the_full_chain_to_the_terminal_handler() var accessor = chain.ShouldBeAssignableTo(); accessor.HandlerType.ShouldBe(typeof(AccReqHandler)); } + + // ── Stream side: StreamBehaviorHandlerAdapter exposes the same accessor (was 0% branch) ─────── + + public record AccStreamReq : IStreamRequest; + + public sealed class AccStreamHandler : IStreamRequestHandler + { + public System.Collections.Generic.IAsyncEnumerable Handle(AccStreamReq request, CancellationToken ct) => null!; + } + + public sealed class PassThroughStreamBehavior : IStreamPipelineBehavior + { + public System.Collections.Generic.IAsyncEnumerable Handle( + AccStreamReq request, IStreamRequestHandler next, CancellationToken ct) + => next.Handle(request, ct); + } + + [Fact] + public void Stream_adapter_walks_the_chain_to_the_terminal_handler() + { + var handler = new AccStreamHandler(); + IStreamRequestHandler chain = handler; + for (int i = 0; i < 3; i++) + chain = new StreamBehaviorHandlerAdapter(new PassThroughStreamBehavior(), chain); + + var accessor = chain.ShouldBeAssignableTo(); + accessor.HandlerType.ShouldBe(typeof(AccStreamHandler)); + } } From b14005a40a0d391eea0d7344e02795ffc1ef82dc Mon Sep 17 00:00:00 2001 From: Yander Santiesteban Rojas Date: Sun, 21 Jun 2026 19:26:38 -0300 Subject: [PATCH 10/11] fix(generators): skip open-generic Send/Publish/CreateStream call sites An interceptor cannot represent an open-generic call site: a single [InterceptsLocation] is instantiated for every TRequest/TResponse the enclosing generic method is called with, so no one concrete interceptor can stand in for it. The three interceptor generators emitted a method referencing the unbound type parameters anyway -> CS0246, breaking the build of any consumer with a generic dispatch wrapper, e.g.: ValueTask Dispatch(TReq req) where TReq : IRequest => _sender.Send(req); Guard with InterceptorHelpers.ContainsTypeParameter: such call sites are skipped and dispatch through the real Mediator.Send/Publish/CreateStream at runtime (which is exactly what those methods exist for). This also covers the previously-untested non-intercepted Mediator dispatch path (line 30% -> 100%, branch 17% -> 92%): an open-generic helper reaches the real Mediator.Send/Publish/CreateStream, exercising every branch (pipeline vs handler-cache, custom-publisher vs sequential, precompiled stream vs invoker fallback). Generator output for concrete call sites is byte-identical (the guard returns false for closed types), so allocation/perf are unchanged: Send 6.7ns/72B (ratio 1.00), Publish 4.4ns/0B, Stream 46ns/232B (ratio 1.00). +12 tests (9 runtime dispatch, 3 generator regression), 395 green. --- .../InterceptorHelpers.cs | 23 ++ .../PublishInterceptorGenerator.cs | 9 + .../SendInterceptorGenerator.cs | 17 +- .../StreamInterceptorGenerator.cs | 17 +- .../MediatorGenericDispatchCoverageTests.cs | 228 ++++++++++++++++++ .../PublishInterceptorGeneratorTests.cs | 28 +++ .../SendInterceptorGeneratorTests.cs | 30 +++ .../StreamInterceptorGeneratorTests.cs | 28 +++ 8 files changed, 374 insertions(+), 6 deletions(-) create mode 100644 tests/DSoftStudio.Mediator.Tests/Coverage/MediatorGenericDispatchCoverageTests.cs diff --git a/src/DSoftStudio.Mediator.Generators/InterceptorHelpers.cs b/src/DSoftStudio.Mediator.Generators/InterceptorHelpers.cs index 320b7cf..b54bf57 100644 --- a/src/DSoftStudio.Mediator.Generators/InterceptorHelpers.cs +++ b/src/DSoftStudio.Mediator.Generators/InterceptorHelpers.cs @@ -158,6 +158,29 @@ public static void AppendStreamDispatchBody( .AppendLine(">.Resolve(sp).Handle(request, cancellationToken);"); } + /// + /// Returns when is — or transitively contains — a + /// type parameter (an open / not-fully-constructed type). + /// + /// An interceptor must reference fully-constructed, concrete types: the + /// [InterceptsLocation] mechanism rewrites a single syntactic call site, but an open-generic + /// call site (e.g. mediator.Send<TRequest, TResponse>(request) inside a generic forwarding + /// method) is instantiated for every set of type arguments the enclosing method is called with — + /// no single concrete interceptor can represent all of them. Emitting one anyway produces a method + /// that references the bare type-parameter names out of scope (CS0246). Such call sites must be + /// skipped so they dispatch through the real Mediator.Send/Publish/CreateStream at runtime. + /// + /// + public static bool ContainsTypeParameter(ITypeSymbol? type) => type switch + { + null => false, + ITypeParameterSymbol => true, + IArrayTypeSymbol array => ContainsTypeParameter(array.ElementType), + IPointerTypeSymbol pointer => ContainsTypeParameter(pointer.PointedAtType), + INamedTypeSymbol named => named.TypeArguments.Any(ContainsTypeParameter), + _ => false, + }; + /// /// Returns when is or implements /// the interface identified by . diff --git a/src/DSoftStudio.Mediator.Generators/PublishInterceptorGenerator.cs b/src/DSoftStudio.Mediator.Generators/PublishInterceptorGenerator.cs index f47fb89..676952d 100644 --- a/src/DSoftStudio.Mediator.Generators/PublishInterceptorGenerator.cs +++ b/src/DSoftStudio.Mediator.Generators/PublishInterceptorGenerator.cs @@ -105,6 +105,11 @@ private static bool IsPublishCandidate(SyntaxNode node) if (method.TypeArguments.Length == 1) { // Explicit generic: publisher.Publish(notification) + // Skip open-generic call sites: no concrete interceptor can represent them — they dispatch + // through Mediator.Publish at runtime. + if (InterceptorHelpers.ContainsTypeParameter(method.TypeArguments[0])) + return null; + notificationType = method.TypeArguments[0] .ToDisplayString(HandlerDiscovery.NullableFullyQualifiedFormat); } @@ -153,6 +158,10 @@ private static bool TryResolveInferredNotificationType( if (paramType is not INamedTypeSymbol namedParamType) return false; + // Skip open-generic call sites: an interceptor cannot reference unbound type parameters. + if (InterceptorHelpers.ContainsTypeParameter(namedParamType)) + return false; + if (!InterceptorHelpers.ImplementsInterface(namedParamType, compilation, "DSoftStudio.Mediator.Abstractions.INotification")) return false; diff --git a/src/DSoftStudio.Mediator.Generators/SendInterceptorGenerator.cs b/src/DSoftStudio.Mediator.Generators/SendInterceptorGenerator.cs index e0caa47..d89293a 100644 --- a/src/DSoftStudio.Mediator.Generators/SendInterceptorGenerator.cs +++ b/src/DSoftStudio.Mediator.Generators/SendInterceptorGenerator.cs @@ -105,6 +105,12 @@ private static bool IsSendCandidate(SyntaxNode node) if (method.TypeArguments.Length == 2) { // Explicit generic: sender.Send(request) + // Skip open-generic call sites (e.g. inside a generic forwarding method): no concrete + // interceptor can represent them — they dispatch through Mediator.Send at runtime. + if (InterceptorHelpers.ContainsTypeParameter(method.TypeArguments[0]) + || InterceptorHelpers.ContainsTypeParameter(method.TypeArguments[1])) + return null; + requestType = method.TypeArguments[0] .ToDisplayString(HandlerDiscovery.NullableFullyQualifiedFormat); responseType = method.TypeArguments[1] @@ -155,13 +161,18 @@ private static bool TryResolveInferredTypes( if (requestParam is null) return false; - requestType = requestParam.Type - .ToDisplayString(HandlerDiscovery.NullableFullyQualifiedFormat); - // Return type is ValueTask — extract TResponse if (method.ReturnType is not INamedTypeSymbol { TypeArguments.Length: 1 } returnType) return false; + // Skip open-generic call sites: an interceptor cannot reference unbound type parameters. + if (InterceptorHelpers.ContainsTypeParameter(requestParam.Type) + || InterceptorHelpers.ContainsTypeParameter(returnType.TypeArguments[0])) + return false; + + requestType = requestParam.Type + .ToDisplayString(HandlerDiscovery.NullableFullyQualifiedFormat); + responseType = returnType.TypeArguments[0] .ToDisplayString(HandlerDiscovery.NullableFullyQualifiedFormat); diff --git a/src/DSoftStudio.Mediator.Generators/StreamInterceptorGenerator.cs b/src/DSoftStudio.Mediator.Generators/StreamInterceptorGenerator.cs index 86491ae..cc2742e 100644 --- a/src/DSoftStudio.Mediator.Generators/StreamInterceptorGenerator.cs +++ b/src/DSoftStudio.Mediator.Generators/StreamInterceptorGenerator.cs @@ -104,6 +104,12 @@ private static bool IsCreateStreamCandidate(SyntaxNode node) if (method.TypeArguments.Length == 2) { // Explicit generic: mediator.CreateStream(request) + // Skip open-generic call sites: no concrete interceptor can represent them — they dispatch + // through Mediator.CreateStream at runtime. + if (InterceptorHelpers.ContainsTypeParameter(method.TypeArguments[0]) + || InterceptorHelpers.ContainsTypeParameter(method.TypeArguments[1])) + return null; + requestType = method.TypeArguments[0] .ToDisplayString(HandlerDiscovery.NullableFullyQualifiedFormat); responseType = method.TypeArguments[1] @@ -149,13 +155,18 @@ private static bool TryResolveInferredTypes( if (requestParam is null) return false; - requestType = requestParam.Type - .ToDisplayString(HandlerDiscovery.NullableFullyQualifiedFormat); - // Return type is IAsyncEnumerable — extract TResponse if (method.ReturnType is not INamedTypeSymbol { TypeArguments.Length: 1 } returnType) return false; + // Skip open-generic call sites: an interceptor cannot reference unbound type parameters. + if (InterceptorHelpers.ContainsTypeParameter(requestParam.Type) + || InterceptorHelpers.ContainsTypeParameter(returnType.TypeArguments[0])) + return false; + + requestType = requestParam.Type + .ToDisplayString(HandlerDiscovery.NullableFullyQualifiedFormat); + responseType = returnType.TypeArguments[0] .ToDisplayString(HandlerDiscovery.NullableFullyQualifiedFormat); diff --git a/tests/DSoftStudio.Mediator.Tests/Coverage/MediatorGenericDispatchCoverageTests.cs b/tests/DSoftStudio.Mediator.Tests/Coverage/MediatorGenericDispatchCoverageTests.cs new file mode 100644 index 0000000..65d790a --- /dev/null +++ b/tests/DSoftStudio.Mediator.Tests/Coverage/MediatorGenericDispatchCoverageTests.cs @@ -0,0 +1,228 @@ +// Copyright (c) DSoftStudio. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System.Runtime.CompilerServices; +using DSoftStudio.Mediator.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace DSoftStudio.Mediator.Tests.Coverage; + +// ══════════════════════════════════════════════════════════════════════════════════════════════ +// Mediator.Send<,> / Publish<> / CreateStream<,> — the NON-INTERCEPTED dispatch path. +// +// Every concrete call site in this assembly (e.g. mediator.Send(…)) is REPLACED by the +// source-generated interceptor, whose inlined fast path never enters these methods — so they sat at +// 0% even though the whole suite exercises Send/Publish/CreateStream constantly. +// +// These methods are still the real dispatch used whenever a call site is NOT intercepted: +// • an open-generic caller — TRequest/TResponse are type parameters, so the generator cannot emit a +// concrete interceptor and the call binds to the interface method (this is what the helpers below do); +// • reflection / cached-delegate callers that resolve IMediator dynamically; +// • a consumer built with DSoftMediatorSuppressInterceptors=true. +// +// The OpenGeneric helper reproduces that path with zero artificial machinery: it forwards through a +// generic method, so the call uses open type parameters the interceptor cannot bind, reaching the real +// Mediator.Send/Publish/CreateStream. Each branch (pipeline vs handler-cache, custom publisher vs +// sequential, precompiled stream vs invoker fallback) gets its own unique message type. +// ══════════════════════════════════════════════════════════════════════════════════════════════ + +/// +/// Forwards through open type parameters so the source-generated interceptors cannot bind to the call +/// site — the calls reach the real Mediator.Send/Publish/CreateStream instead of the inlined +/// fast path. This is the dispatch a reflection / open-generic / interceptor-suppressed caller hits. +/// +file static class OpenGeneric +{ + public static ValueTask Send(IMediator m, TRequest request, CancellationToken ct = default) + where TRequest : IRequest + => m.Send(request, ct); + + public static Task Publish(IMediator m, TNotification notification, CancellationToken ct = default) + where TNotification : INotification + => m.Publish(notification, ct); + + public static IAsyncEnumerable CreateStream(IMediator m, TRequest request, CancellationToken ct = default) + where TRequest : IStreamRequest + => m.CreateStream(request, ct); +} + +// ── Message types (unique per branch to avoid static-dispatch collisions with other tests) ────────── + +public record MgdNoPipe : IRequest; // Send → HasPipelineChain == false (handler-cache path) +public record MgdWithPipe : IRequest; // Send → HasPipelineChain == true (chain path) +public record MgdNotif : INotification; // Publish → no custom publisher (sequential dispatch) +public record MgdNotifPub : INotification; // Publish → custom INotificationPublisher registered +public record MgdStreamPre : IStreamRequest; // CreateStream → precompiled (static pipeline) +public record MgdStreamOrphan : IStreamRequest; // CreateStream → no handler ever discovered → invoker fallback + +public sealed class MgdNoPipeHandler : IRequestHandler +{ + public ValueTask Handle(MgdNoPipe request, CancellationToken ct) => new(11); +} + +public sealed class MgdWithPipeHandler : IRequestHandler +{ + public ValueTask Handle(MgdWithPipe request, CancellationToken ct) => new(22); +} + +public sealed class MgdNotifHandler : INotificationHandler +{ + public static int Count; + public Task Handle(MgdNotif notification, CancellationToken ct) { Count++; return Task.CompletedTask; } +} + +public sealed class MgdNotifPubHandler : INotificationHandler +{ + public static int Count; + public Task Handle(MgdNotifPub notification, CancellationToken ct) { Count++; return Task.CompletedTask; } +} + +public sealed class MgdStreamPreHandler : IStreamRequestHandler +{ + public async IAsyncEnumerable Handle(MgdStreamPre request, [EnumeratorCancellation] CancellationToken ct) + { + yield return 1; + yield return 2; + await Task.CompletedTask; + } +} + +// NOTE: MgdStreamOrphan deliberately has NO handler — so it is never discovered or precompiled and its +// StreamDispatch<,>.Pipeline stays null, forcing Mediator.CreateStream down the invoker fallback. + +/// +/// Drives the three generic dispatch methods on Mediator through their real (non-intercepted) body, +/// covering every branch. +/// +public class MediatorGenericDispatchCoverageTests +{ + private static IMediator BuildMediator(Action register) + { + var services = new ServiceCollection(); + register(services); + services.AddMediator() + .RegisterMediatorHandlers() + .PrecompilePipelines() + .PrecompileNotifications() + .PrecompileStreams(); + return services.BuildServiceProvider().GetRequiredService(); + } + + // ── Send ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task Send_NoPipeline_GoesThroughHandlerCache() + { + // No IPipelineBehavior registered → RequestDispatch.HasPipelineChain == false → HandlerCache path. + var mediator = BuildMediator(_ => { }); + + var result = await OpenGeneric.Send(mediator, new MgdNoPipe(), TestContext.Current.CancellationToken); + + result.ShouldBe(11); + } + + [Fact] + public async Task Send_WithPipeline_GoesThroughChain() + { + // A behavior is registered → RequestDispatch.HasPipelineChain == true → resolve + run the chain. + var mediator = BuildMediator(s => + s.AddTransient, PassThroughBehavior>()); + + var result = await OpenGeneric.Send(mediator, new MgdWithPipe(), TestContext.Current.CancellationToken); + + result.ShouldBe(22); + } + + [Fact] + public async Task Send_NullRequest_Throws() + { + var mediator = BuildMediator(_ => { }); + + await Should.ThrowAsync( + () => OpenGeneric.Send(mediator, null!).AsTask()); + } + + // ── Publish ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Publish_NoCustomPublisher_UsesSequentialDispatch() + { + // No INotificationPublisher registered → _notificationPublisher is null → cached sequential dispatch. + // MgdNotifHandler is auto-discovered by RegisterMediatorHandlers (registering it again would + // double-dispatch on the GetServices path). + MgdNotifHandler.Count = 0; + var mediator = BuildMediator(_ => { }); + + await OpenGeneric.Publish(mediator, new MgdNotif(), TestContext.Current.CancellationToken); + + MgdNotifHandler.Count.ShouldBe(1); + } + + [Fact] + public async Task Publish_WithCustomPublisher_UsesPublisher() + { + // A custom INotificationPublisher is registered → _notificationPublisher is not null → publisher path + // (GetServices> + publisher.Publish). The handler is auto-discovered. + MgdNotifPubHandler.Count = 0; + var mediator = BuildMediator(s => + s.AddSingleton()); + + await OpenGeneric.Publish(mediator, new MgdNotifPub(), TestContext.Current.CancellationToken); + + MgdNotifPubHandler.Count.ShouldBe(1); + } + + [Fact] + public async Task Publish_NullNotification_Throws() + { + var mediator = BuildMediator(_ => { }); + + await Should.ThrowAsync( + () => OpenGeneric.Publish(mediator, null!)); + } + + // ── CreateStream ─────────────────────────────────────────────────────── + + [Fact] + public async Task CreateStream_Precompiled_UsesStaticPipeline() + { + // PrecompileStreams() populates StreamDispatch<,>.Pipeline → the O(1) static-field path. + var mediator = BuildMediator(_ => { }); + + var values = new List(); + await foreach (var v in OpenGeneric.CreateStream(mediator, new MgdStreamPre(), TestContext.Current.CancellationToken)) + values.Add(v); + + values.ShouldBe(new[] { 1, 2 }); + } + + [Fact] + public async Task CreateStream_UnregisteredStream_FallsBackToInvoker_AndThrowsClearError() + { + // MgdStreamOrphan has no handler → never discovered/precompiled → StreamDispatch<,>.Pipeline stays + // null → CreateStream takes the StreamPipelineInvoker fallback, which surfaces a clear + // "not registered" error rather than a NullReferenceException. + var mediator = BuildMediator(_ => { }); + + var ex = await Should.ThrowAsync(async () => + { + await foreach (var _ in OpenGeneric.CreateStream(mediator, new MgdStreamOrphan(), TestContext.Current.CancellationToken)) + { + } + }); + ex.Message.ShouldContain("MgdStreamOrphan"); + } + + [Fact] + public async Task CreateStream_NullRequest_Throws() + { + var mediator = BuildMediator(_ => { }); + + await Should.ThrowAsync(async () => + { + await foreach (var _ in OpenGeneric.CreateStream(mediator, null!)) + { + } + }); + } +} diff --git a/tests/DSoftStudio.Mediator.Tests/Generators/PublishInterceptorGeneratorTests.cs b/tests/DSoftStudio.Mediator.Tests/Generators/PublishInterceptorGeneratorTests.cs index b280d26..eb13370 100644 --- a/tests/DSoftStudio.Mediator.Tests/Generators/PublishInterceptorGeneratorTests.cs +++ b/tests/DSoftStudio.Mediator.Tests/Generators/PublishInterceptorGeneratorTests.cs @@ -126,4 +126,32 @@ public static Task Run(IPublisher publisher) .Where(line => line.Contains("InterceptsLocation")) .ShouldBeEmpty("Publish(object) must not be intercepted"); } + + [Fact] + public void Ignores_Open_Generic_Publish_Call_Site() + { + // A generic forwarding method — publisher.Publish(notification) with an OPEN type + // parameter — cannot be intercepted; the call dispatches through Mediator.Publish at runtime. The + // generator must skip it. Regression: it used to emit an interceptor referencing TNotification → CS0246. + const string openGeneric = """ + using System.Threading.Tasks; + using DSoftStudio.Mediator.Abstractions; + + namespace TestApp; + + public static class Dispatcher + { + public static Task Publish(IPublisher publisher, TNotification notification) + where TNotification : INotification + => publisher.Publish(notification); + } + """; + + var (result, output) = GeneratorTestHarness.Run(openGeneric, interceptors: true); + + result.AllSource().ShouldNotContain("InterceptsLocation", + customMessage: "an open-generic Publish call site must not be intercepted"); + output.GetDiagnostics().Where(d => d.Id == "CS0246").ShouldBeEmpty( + "the generated interceptor must not reference unbound type parameters"); + } } diff --git a/tests/DSoftStudio.Mediator.Tests/Generators/SendInterceptorGeneratorTests.cs b/tests/DSoftStudio.Mediator.Tests/Generators/SendInterceptorGeneratorTests.cs index 4d78afb..325fb25 100644 --- a/tests/DSoftStudio.Mediator.Tests/Generators/SendInterceptorGeneratorTests.cs +++ b/tests/DSoftStudio.Mediator.Tests/Generators/SendInterceptorGeneratorTests.cs @@ -131,4 +131,34 @@ public static Expression>> Setup() .Where(l => l.Contains("InterceptsLocation")) .ShouldBeEmpty("Send inside an expression-tree lambda must not be intercepted"); } + + [Fact] + public void Ignores_Open_Generic_Send_Call_Site() + { + // A generic forwarding method — sender.Send(request) with OPEN type parameters — + // cannot be intercepted: the single syntactic call site is instantiated for every TRequest/TResponse, + // so no concrete interceptor can represent it. The generator must skip it; the call dispatches through + // Mediator.Send at runtime. Regression: it used to emit an interceptor referencing the unbound type + // parameters, breaking the consumer's build with CS0246. + const string openGeneric = """ + using System.Threading.Tasks; + using DSoftStudio.Mediator.Abstractions; + + namespace TestApp; + + public static class Dispatcher + { + public static ValueTask Send(ISender sender, TRequest request) + where TRequest : IRequest + => sender.Send(request); + } + """; + + var (result, output) = GeneratorTestHarness.Run(openGeneric, interceptors: true); + + result.AllSource().ShouldNotContain("InterceptsLocation", + customMessage: "an open-generic Send call site must not be intercepted"); + output.GetDiagnostics().Where(d => d.Id == "CS0246").ShouldBeEmpty( + "the generated interceptor must not reference unbound type parameters"); + } } diff --git a/tests/DSoftStudio.Mediator.Tests/Generators/StreamInterceptorGeneratorTests.cs b/tests/DSoftStudio.Mediator.Tests/Generators/StreamInterceptorGeneratorTests.cs index c586e7a..225d27b 100644 --- a/tests/DSoftStudio.Mediator.Tests/Generators/StreamInterceptorGeneratorTests.cs +++ b/tests/DSoftStudio.Mediator.Tests/Generators/StreamInterceptorGeneratorTests.cs @@ -140,4 +140,32 @@ public record Ticker(int N) : IStreamRequest; .Where(line => line.Contains("InterceptsLocation")) .ShouldBeEmpty("no CreateStream call site → no interceptor methods"); } + + [Fact] + public void Ignores_Open_Generic_CreateStream_Call_Site() + { + // A generic forwarding method — mediator.CreateStream(request) with OPEN type + // parameters — cannot be intercepted; the call dispatches through Mediator.CreateStream at runtime. The + // generator must skip it. Regression: it used to emit an interceptor referencing TRequest/TResponse → CS0246. + const string openGeneric = """ + using System.Collections.Generic; + using DSoftStudio.Mediator.Abstractions; + + namespace TestApp; + + public static class Dispatcher + { + public static IAsyncEnumerable CreateStream(IMediator mediator, TRequest request) + where TRequest : IStreamRequest + => mediator.CreateStream(request); + } + """; + + var (result, output) = GeneratorTestHarness.Run(openGeneric, interceptors: true); + + result.AllSource().ShouldNotContain("InterceptsLocation", + customMessage: "an open-generic CreateStream call site must not be intercepted"); + output.GetDiagnostics().Where(d => d.Id == "CS0246").ShouldBeEmpty( + "the generated interceptor must not reference unbound type parameters"); + } } From b444abe55512564a7ccf31194f54c97f1a01ed11 Mon Sep 17 00:00:00 2001 From: Yander Santiesteban Rojas Date: Sun, 21 Jun 2026 19:51:34 -0300 Subject: [PATCH 11/11] refactor(core): drop dead pipeline-delegate path, dedupe builder, fix stale docs Core-runtime audit cleanup -- no behavior change, hot paths byte-identical. - Remove RequestDispatch<>.Pipeline / TryInitialize and the PipelineBuilder class. The generator built and stored a per-request-type dispatch delegate that nothing ever invoked: Send / the interceptor / RequestObjectDispatch all dispatch via HasPipelineChain + the ThreadStatic PipelineChainCache/HandlerCache. (The stream side still uses StreamDispatch.Pipeline; the request side left it vestigial.) Saves one delegate allocation per request type at startup. The 3 tests that drove it are migrated to the live PipelineChainHandler path. - MediatorBuilder: collapse 4 identical interface-matching registration loops into a shared RegisterByOpenInterface helper. - Fix XML docs describing the removed mutable-index / reentrancy / PipelineBuilder design (PipelineChainHandler, BehaviorHandlerAdapter, ParallelNotificationPublisher) + broken brace indentation. Send 6.6ns/72B, Publish 4.4ns/0B, Stream 46ns/232B (ratio 1.00). All suites green. --- .../MediatorPipelineGenerator.cs | 16 --- .../BehaviorHandlerAdapter.cs | 4 +- src/DSoftStudio.Mediator/MediatorBuilder.cs | 102 ++++++------------ .../ParallelNotificationPublisher.cs | 5 +- src/DSoftStudio.Mediator/PipelineBuilder.cs | 55 ---------- .../PipelineChainHandler.cs | 40 +++---- src/DSoftStudio.Mediator/RequestDispatch.cs | 32 +----- .../Coverage/CacheCoverageTests.cs | 29 +---- .../Coverage/FinalCoverageTests.cs | 11 -- .../Pipelines/BehaviorOrderTests.cs | 11 +- .../Pipelines/PipelineCompilationTests.cs | 64 ----------- 11 files changed, 68 insertions(+), 301 deletions(-) delete mode 100644 src/DSoftStudio.Mediator/PipelineBuilder.cs delete mode 100644 tests/DSoftStudio.Mediator.Tests/Pipelines/PipelineCompilationTests.cs diff --git a/src/DSoftStudio.Mediator.Generators/MediatorPipelineGenerator.cs b/src/DSoftStudio.Mediator.Generators/MediatorPipelineGenerator.cs index cf816c7..6b2fbe2 100644 --- a/src/DSoftStudio.Mediator.Generators/MediatorPipelineGenerator.cs +++ b/src/DSoftStudio.Mediator.Generators/MediatorPipelineGenerator.cs @@ -333,22 +333,6 @@ private static string GenerateRegistryCode( sb.AppendLine(" // Transient chains must be resolved fresh each call."); sb.AppendLine(" if (!hasTransientPipelineComponent)"); sb.AppendLine(" global::DSoftStudio.Mediator.RequestDispatch.MarkPipelineChainCacheable();"); - sb.AppendLine(); - sb.AppendLine(" // Pipeline with behaviors: resolve PipelineChainHandler directly — single DI lookup."); - sb.AppendLine(" global::DSoftStudio.Mediator.RequestDispatch.TryInitialize("); - sb.AppendLine(" static (request, sp, ct) =>"); - sb.AppendLine(" global::Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions"); - sb.AppendLine(" .GetRequiredService>(sp)"); - sb.AppendLine(" .Handle(request, ct));"); - sb.AppendLine(" }"); - sb.AppendLine(" else"); - sb.AppendLine(" {"); - sb.AppendLine(" // No pipeline features — resolve handler directly. Single DI lookup, zero overhead."); - sb.AppendLine(" global::DSoftStudio.Mediator.RequestDispatch.TryInitialize("); - sb.AppendLine(" static (request, sp, ct) =>"); - sb.AppendLine(" global::Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions"); - sb.AppendLine(" .GetRequiredService>(sp)"); - sb.AppendLine(" .Handle(request, ct));"); sb.AppendLine(" }"); sb.AppendLine(); sb.AppendLine(" // AOT-safe Send(object) dispatch — register a runtime-typed delegate for this request type."); diff --git a/src/DSoftStudio.Mediator/BehaviorHandlerAdapter.cs b/src/DSoftStudio.Mediator/BehaviorHandlerAdapter.cs index 46053c2..97756b6 100644 --- a/src/DSoftStudio.Mediator/BehaviorHandlerAdapter.cs +++ b/src/DSoftStudio.Mediator/BehaviorHandlerAdapter.cs @@ -9,8 +9,8 @@ namespace DSoftStudio.Mediator { /// /// Adapts an + next handler - /// into an . - /// Used by the reentrant fallback path and by . + /// into an . Used by + /// to pre-link the behavior chain. /// internal sealed class BehaviorHandlerAdapter( IPipelineBehavior behavior, diff --git a/src/DSoftStudio.Mediator/MediatorBuilder.cs b/src/DSoftStudio.Mediator/MediatorBuilder.cs index c3b2afc..a01a8c2 100644 --- a/src/DSoftStudio.Mediator/MediatorBuilder.cs +++ b/src/DSoftStudio.Mediator/MediatorBuilder.cs @@ -82,23 +82,8 @@ public MediatorBuilder AddOpenBehavior( /// public MediatorBuilder AddStreamBehavior<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] T>(ServiceLifetime lifetime = ServiceLifetime.Transient) where T : class - { - var type = typeof(T); - var target = typeof(IStreamPipelineBehavior<,>); - - foreach (var iface in type.GetInterfaces()) - { - if (iface.IsGenericType && iface.GetGenericTypeDefinition() == target) - { - Services.Add(new ServiceDescriptor(iface, type, lifetime)); - return this; - } - } - - throw new ArgumentException( - $"Type '{type.Name}' does not implement IStreamPipelineBehavior.", - nameof(T)); - } + => RegisterByOpenInterface(typeof(T), typeof(IStreamPipelineBehavior<,>), lifetime, + nameof(T), "IStreamPipelineBehavior"); /// /// Registers a request pre-processor. @@ -112,23 +97,8 @@ public MediatorBuilder AddOpenBehavior( /// public MediatorBuilder AddRequestPreProcessor<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] T>(ServiceLifetime lifetime = ServiceLifetime.Transient) where T : class - { - var type = typeof(T); - var target = typeof(IRequestPreProcessor<>); - - foreach (var iface in type.GetInterfaces()) - { - if (iface.IsGenericType && iface.GetGenericTypeDefinition() == target) - { - Services.Add(new ServiceDescriptor(iface, type, lifetime)); - return this; - } - } - - throw new ArgumentException( - $"Type '{type.Name}' does not implement IRequestPreProcessor.", - nameof(T)); - } + => RegisterByOpenInterface(typeof(T), typeof(IRequestPreProcessor<>), lifetime, + nameof(T), "IRequestPreProcessor"); /// /// Registers a request post-processor. @@ -142,23 +112,8 @@ public MediatorBuilder AddOpenBehavior( /// public MediatorBuilder AddRequestPostProcessor<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] T>(ServiceLifetime lifetime = ServiceLifetime.Transient) where T : class - { - var type = typeof(T); - var target = typeof(IRequestPostProcessor<,>); - - foreach (var iface in type.GetInterfaces()) - { - if (iface.IsGenericType && iface.GetGenericTypeDefinition() == target) - { - Services.Add(new ServiceDescriptor(iface, type, lifetime)); - return this; - } - } - - throw new ArgumentException( - $"Type '{type.Name}' does not implement IRequestPostProcessor.", - nameof(T)); - } + => RegisterByOpenInterface(typeof(T), typeof(IRequestPostProcessor<,>), lifetime, + nameof(T), "IRequestPostProcessor"); /// /// Registers a request exception handler. @@ -172,23 +127,8 @@ public MediatorBuilder AddOpenBehavior( /// public MediatorBuilder AddRequestExceptionHandler<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] T>(ServiceLifetime lifetime = ServiceLifetime.Transient) where T : class - { - var type = typeof(T); - var target = typeof(IRequestExceptionHandler<,>); - - foreach (var iface in type.GetInterfaces()) - { - if (iface.IsGenericType && iface.GetGenericTypeDefinition() == target) - { - Services.Add(new ServiceDescriptor(iface, type, lifetime)); - return this; - } - } - - throw new ArgumentException( - $"Type '{type.Name}' does not implement IRequestExceptionHandler.", - nameof(T)); - } + => RegisterByOpenInterface(typeof(T), typeof(IRequestExceptionHandler<,>), lifetime, + nameof(T), "IRequestExceptionHandler"); /// /// Replaces the default sequential notification publisher with a parallel implementation @@ -200,4 +140,30 @@ public MediatorBuilder AddParallelNotificationPublisher() Services.AddSingleton(); return this; } + + /// + /// Registers against the closed + /// it implements, throwing when it implements none. + /// Shared by the stream-behavior / pre-processor / post-processor / exception-handler registrations. + /// + private MediatorBuilder RegisterByOpenInterface( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.Interfaces)] Type implementationType, + Type openInterface, + ServiceLifetime lifetime, + string parameterName, + string interfaceDisplayName) + { + foreach (var iface in implementationType.GetInterfaces()) + { + if (iface.IsGenericType && iface.GetGenericTypeDefinition() == openInterface) + { + Services.Add(new ServiceDescriptor(iface, implementationType, lifetime)); + return this; + } + } + + throw new ArgumentException( + $"Type '{implementationType.Name}' does not implement {interfaceDisplayName}.", + parameterName); + } } diff --git a/src/DSoftStudio.Mediator/ParallelNotificationPublisher.cs b/src/DSoftStudio.Mediator/ParallelNotificationPublisher.cs index 7a59cb4..8c4b9f6 100644 --- a/src/DSoftStudio.Mediator/ParallelNotificationPublisher.cs +++ b/src/DSoftStudio.Mediator/ParallelNotificationPublisher.cs @@ -7,8 +7,9 @@ namespace DSoftStudio.Mediator { /// /// Invokes all notification handlers in parallel using . - /// All handlers start concurrently — if any handler throws, - /// an is thrown after all have completed. + /// All handlers start concurrently; awaiting the returned task surfaces the first faulting + /// handler's exception (the remaining failures are available on the task's + /// aggregate) once every handler has completed. /// public sealed class ParallelNotificationPublisher : INotificationPublisher { diff --git a/src/DSoftStudio.Mediator/PipelineBuilder.cs b/src/DSoftStudio.Mediator/PipelineBuilder.cs deleted file mode 100644 index 7368942..0000000 --- a/src/DSoftStudio.Mediator/PipelineBuilder.cs +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) DSoftStudio. All rights reserved. -// Licensed under the MIT License. See LICENSE in the project root for license information. - -using DSoftStudio.Mediator.Abstractions; -using Microsoft.Extensions.DependencyInjection; - -namespace DSoftStudio.Mediator -{ - - /// - /// Builds a reusable pipeline delegate for a given <TRequest, TResponse> pair. - /// - /// No behaviors: The delegate resolves the handler from DI and invokes it directly. - /// Zero closures, zero intermediate allocations. - /// - /// - /// With behaviors: The delegate resolves a pre-wired - /// from DI. - /// The chain is built once per DI scope (not per request), eliminating - /// the per-request closure fold that previously created N closures + 1 array. - /// - /// Public to allow compile-time generated code (MediatorRegistry) to initialize pipelines. - /// - public static class PipelineBuilder - { - /// - /// Constructs a compiled pipeline delegate that can be cached and reused. - /// When no behaviors are registered, the delegate goes straight to the handler - /// with zero allocations on the hot path. - /// When behaviors exist, the chain is resolved as a single pre-wired service from DI. - /// - public static Func> - Build() - where TRequest : IRequest - { - return static (request, serviceProvider, cancellationToken) => - { - // Try to resolve the pre-wired pipeline chain first. - // When behaviors/processors are registered, PrecompilePipelines() adds - // PipelineChainHandler to DI — it caches the handler + behaviors array - // at scope creation (zero GetServices/ToArray per request). - var chain = serviceProvider.GetService>(); - - if (chain is not null) - return chain.Handle(request, cancellationToken); - - // No PipelineChainHandler in DI — no pipeline features registered. - // Go directly to the handler: single DI lookup, zero overhead. - return serviceProvider - .GetRequiredService>() - .Handle(request, cancellationToken); - }; - } - } -} diff --git a/src/DSoftStudio.Mediator/PipelineChainHandler.cs b/src/DSoftStudio.Mediator/PipelineChainHandler.cs index 08440a1..f6fe4e0 100644 --- a/src/DSoftStudio.Mediator/PipelineChainHandler.cs +++ b/src/DSoftStudio.Mediator/PipelineChainHandler.cs @@ -6,30 +6,23 @@ namespace DSoftStudio.Mediator { /// - /// Zero-allocation pipeline executor using interface dispatch and index-based chain traversal. + /// Zero-allocation pipeline executor. Pre-links the behavior chain once at construction + /// (per DI scope) into an immutable chain of + /// ending at the terminal handler, so the hot path carries zero mutable state. /// - /// Architecture: This class implements - /// so it can pass this as the next parameter to each behavior. - /// Behaviors call next.Handle(request, ct) which routes back to - /// via interface dispatch (virtual call) — no delegates, no closures. + /// Dispatch mode is computed once in the constructor (see ComputePipelineMode): + /// + /// PassThrough (no components): calls the handler directly. + /// BehaviorsOnly: invokes the pre-linked behavior chain. + /// Full: pre-processors, post-processors and exception handlers around the chain. + /// + /// Each behavior receives the next link as an , so + /// next.Handle(request, ct) is a virtual call (~0.5 ns) rather than a delegate invocation (~2 ns). + /// Because the chain is immutable and stateless, nested / reentrant Send() calls are inherently safe. /// /// - /// How it works: Handle() stores the per-request state (request, - /// cancellationToken) in fields and resets _behaviorIndex to 0. - /// InvokeNext() advances the index and calls the next behavior or handler. - /// Each behavior receives this as an , - /// and calling next.Handle(request, ct) is a virtual call (~0.5 ns) instead of - /// a delegate invocation (~2 ns). - /// - /// - /// Reentrancy: If a behavior or handler triggers a nested Send() of the - /// same request type on the same scope, the _active flag detects it and falls - /// back to a closure-based chain (correct but allocating). - /// - /// - /// Sync fast path: When the entire chain completes synchronously - /// (common for in-memory handlers), the IsCompletedSuccessfully check - /// avoids the async state machine allocation entirely. + /// Sync fast path: when the chain completes synchronously (common for in-memory handlers), + /// the IsCompletedSuccessfully checks avoid the async state-machine allocation entirely. /// /// public sealed class PipelineChainHandler @@ -249,6 +242,5 @@ private async ValueTask HandleWithExceptionHandlers(TRequest request, ValueTask IRequestHandler.Handle( TRequest request, CancellationToken cancellationToken) => Handle(request, cancellationToken); - - } - } + } +} diff --git a/src/DSoftStudio.Mediator/RequestDispatch.cs b/src/DSoftStudio.Mediator/RequestDispatch.cs index 54cbab6..44899e6 100644 --- a/src/DSoftStudio.Mediator/RequestDispatch.cs +++ b/src/DSoftStudio.Mediator/RequestDispatch.cs @@ -8,13 +8,13 @@ namespace DSoftStudio.Mediator { /// - /// Write-once static dispatch table for a specific <TRequest, TResponse> pair. + /// Static dispatch metadata for a specific <TRequest, TResponse> pair. /// The CLR creates one specialization per closed generic type, giving O(1) lookup /// without any dictionary or concurrent collection. /// - /// Populated once at startup by source-generated code. After initialization, - /// the pipeline cannot be overwritten — uses - /// to enforce write-once semantics. + /// The flags are set once at startup by source-generated code and read on the hot path + /// (by the Send interceptor and Mediator.Send) to choose between the pipeline-chain + /// and direct-handler dispatch paths with a single static-field read. /// /// Infrastructure type — not intended for direct use by application code. /// @@ -22,20 +22,9 @@ namespace DSoftStudio.Mediator public static class RequestDispatch where TRequest : IRequest { - private static Func>? _pipeline; private static bool _hasPipelineChain; private static bool _isPipelineChainCacheable; - /// - /// The cached pipeline dispatch delegate. until initialized. - /// Hot-path read — inlined by the JIT to a single static field load. - /// - public static Func>? Pipeline - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => _pipeline; - } - /// /// when a /// is registered in DI (behaviors / processors / exception handlers exist). @@ -58,19 +47,6 @@ public static bool IsPipelineChainCacheable get => Volatile.Read(ref _isPipelineChainCacheable); } - /// - /// Atomically sets the pipeline if not yet initialized. Returns - /// if this call performed the initialization; if already set. - /// Thread-safe, lock-free, zero-cost on the read path. - /// - [EditorBrowsable(EditorBrowsableState.Never)] - public static bool TryInitialize( - Func> pipeline) - { - ArgumentNullException.ThrowIfNull(pipeline); - return Interlocked.CompareExchange(ref _pipeline, pipeline, null) == null; - } - /// /// Marks that a is registered /// in DI for this request type. Called once at startup by generated code. diff --git a/tests/DSoftStudio.Mediator.Tests/Coverage/CacheCoverageTests.cs b/tests/DSoftStudio.Mediator.Tests/Coverage/CacheCoverageTests.cs index 1b4fefb..bf02a22 100644 --- a/tests/DSoftStudio.Mediator.Tests/Coverage/CacheCoverageTests.cs +++ b/tests/DSoftStudio.Mediator.Tests/Coverage/CacheCoverageTests.cs @@ -12,10 +12,9 @@ public record CovCachePing : IRequest; public record CovCacheStream : IStreamRequest; public record CovCacheBehaviorStream : IStreamRequest; -// ── Dedicated types for write-once TryInitialize tests ── -// These types must NEVER be used anywhere else to guarantee -// they are uninitialized when the test runs (regardless of test order). -public record WriteOncePing : IRequest; +// ── Dedicated type for the write-once TryInitializeHandler test ── +// Must NEVER be used anywhere else, so it is guaranteed uninitialized +// when the test runs (regardless of test order). public record WriteOnceStream : IStreamRequest; // ── Handlers ── @@ -137,21 +136,6 @@ public async Task StreamPipelineChainCache_WithBehavior_CachesChain() items2.ShouldBe(new[] { 5 }); } - [Fact] - public void RequestDispatch_TryInitialize_WriteOnce_FirstTrueSecondFalse() - { - // WriteOncePing is dedicated to this test — guaranteed uninitialized. - // First call performs the initialization → true. - var first = RequestDispatch.TryInitialize( - static (req, sp, ct) => new ValueTask(0)); - first.ShouldBeTrue(); - - // Second call: already set → false (write-once semantics). - var second = RequestDispatch.TryInitialize( - static (req, sp, ct) => new ValueTask(1)); - second.ShouldBeFalse(); - } - [Fact] public void StreamDispatch_TryInitializeHandler_WriteOnce_FirstTrueSecondFalse() { @@ -165,13 +149,6 @@ public void StreamDispatch_TryInitializeHandler_WriteOnce_FirstTrueSecondFalse() second.ShouldBeFalse(); } - [Fact] - public void RequestDispatch_TryInitialize_NullPipeline_Throws() - { - Should.Throw( - () => RequestDispatch.TryInitialize(null!)); - } - [Fact] public void StreamDispatch_TryInitializeHandler_Null_Throws() { diff --git a/tests/DSoftStudio.Mediator.Tests/Coverage/FinalCoverageTests.cs b/tests/DSoftStudio.Mediator.Tests/Coverage/FinalCoverageTests.cs index ac24531..a309c2a 100644 --- a/tests/DSoftStudio.Mediator.Tests/Coverage/FinalCoverageTests.cs +++ b/tests/DSoftStudio.Mediator.Tests/Coverage/FinalCoverageTests.cs @@ -81,17 +81,6 @@ public async Task PipelineChainHandler_AsyncPostProcessor_OnSyncCore() result.ShouldBe(66); } - [Fact] - public void RequestDispatch_Pipeline_Property_NotNull_AfterPrecompile() - { - var services = new ServiceCollection(); - services.AddMediator().RegisterMediatorHandlers() - .PrecompilePipelines().PrecompileNotifications().PrecompileStreams(); - - // The Pipeline delegate is set by PrecompilePipelines() generated code - RequestDispatch.Pipeline.ShouldNotBeNull(); - } - [Fact] public void RequestDispatch_HasPipelineChain_And_IsCacheable() { diff --git a/tests/DSoftStudio.Mediator.Tests/Pipelines/BehaviorOrderTests.cs b/tests/DSoftStudio.Mediator.Tests/Pipelines/BehaviorOrderTests.cs index 49dffc0..ef1b5a5 100644 --- a/tests/DSoftStudio.Mediator.Tests/Pipelines/BehaviorOrderTests.cs +++ b/tests/DSoftStudio.Mediator.Tests/Pipelines/BehaviorOrderTests.cs @@ -24,13 +24,14 @@ public async Task Behaviors_ExecuteInRegistrationOrder_OuterToInner() services.AddTransient>(sp => new TrackingBehavior(sp.GetRequiredService>(), "Third")); - // PipelineChainHandler must be registered for PipelineBuilder.Build to detect behaviors. + // Resolve the pre-wired chain directly — PipelineChainHandler pre-links the behaviors + // in registration order (outer → inner), which is exactly what the live Send path runs. services.AddTransient>(); using var sp = services.BuildServiceProvider(); - var pipeline = PipelineBuilder.Build(); - await pipeline(new Ping(), sp, TestContext.Current.CancellationToken); + var chain = sp.GetRequiredService>(); + await chain.Handle(new Ping(), TestContext.Current.CancellationToken); log.ShouldBe(new[] { "First:before", @@ -60,8 +61,8 @@ public async Task FiveBehaviors_AllExecute() using var sp = services.BuildServiceProvider(); - var pipeline = PipelineBuilder.Build(); - var result = await pipeline(new Ping(), sp, TestContext.Current.CancellationToken); + var chain = sp.GetRequiredService>(); + var result = await chain.Handle(new Ping(), TestContext.Current.CancellationToken); result.ShouldBe(42); log.Where(e => e.EndsWith(":before")).Count().ShouldBe(5); diff --git a/tests/DSoftStudio.Mediator.Tests/Pipelines/PipelineCompilationTests.cs b/tests/DSoftStudio.Mediator.Tests/Pipelines/PipelineCompilationTests.cs deleted file mode 100644 index 819ae6a..0000000 --- a/tests/DSoftStudio.Mediator.Tests/Pipelines/PipelineCompilationTests.cs +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) DSoftStudio. All rights reserved. -// Licensed under the MIT License. See LICENSE in the project root for license information. - -using DSoftStudio.Mediator.Abstractions; -using DSoftStudio.Mediator.Tests.Infrastructure; -using Microsoft.Extensions.DependencyInjection; - -namespace DSoftStudio.Mediator.Tests.Pipelines; - -public class PipelineCompilationTests -{ - [Fact] - public void Build_ReturnsNonNullDelegate() - { - var pipeline = PipelineBuilder.Build(); - - pipeline.ShouldNotBeNull(); - } - - [Fact] - public async Task Build_DelegateResolvesHandlerAndReturnsResult() - { - var services = new ServiceCollection(); - services.AddTransient, PingHandler>(); - using var sp = services.BuildServiceProvider(); - - var pipeline = PipelineBuilder.Build(); - - var result = await pipeline(new Ping(), sp, TestContext.Current.CancellationToken); - - result.ShouldBe(42); - } - - [Fact] - public async Task Build_DelegateChainsBehaviors() - { - var log = new List(); - var services = new ServiceCollection(); - services.AddTransient, PingHandler>(); - services.AddSingleton(log); - services.AddTransient>(sp => - new TrackingBehavior(sp.GetRequiredService>(), "P1")); - services.AddTransient>(); - using var sp = services.BuildServiceProvider(); - - var pipeline = PipelineBuilder.Build(); - await pipeline(new Ping(), sp, TestContext.Current.CancellationToken); - - log.ShouldBe(new[] {"P1:before", "P1:after"}); - } - - [Fact] - public async Task Build_NoBehaviors_DirectlyInvokesHandler() - { - var services = new ServiceCollection(); - services.AddTransient, PingHandler>(); - using var sp = services.BuildServiceProvider(); - - var pipeline = PipelineBuilder.Build(); - var result = await pipeline(new Ping(), sp, TestContext.Current.CancellationToken); - - result.ShouldBe(42); - } -}