Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 19 additions & 42 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<IRequestHandler<,>>`). 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<T>` 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="MediatR" Version="14.1" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.5" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.5" />
<PackageReference Include="Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers" Version="18.6.37110.2" />
<PackageReference Include="Scriban" Version="7.0.3" />
<PackageReference Include="Scriban" Version="7.2.4" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="10.4.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="10.7.0" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="10.4.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="10.7.0" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

<!-- NuGet -->
<PackageId>DSoftStudio.Mediator.Abstractions</PackageId>
<Version>1.2.0</Version>
<Version>1.3.0-rc.1</Version>

<Authors>DSoftStudio</Authors>
<Company>DSoftStudio</Company>
Expand Down Expand Up @@ -53,7 +53,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.5" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.9" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Exposes the CONCRETE handler type at the tail of the pipeline chain.
/// <para>
/// 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 <c>next</c> 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.
/// </para>
/// <para>
/// Implemented by the request and stream behavior-chain adapters; the terminal handler does not implement
/// it, so a consumer resolves the type as <c>next is IPipelineHandlerTypeAccessor a ? a.HandlerType : next.GetType()</c>.
/// </para>
/// </summary>
public interface IPipelineHandlerTypeAccessor
{
/// <summary>
/// The concrete <c>IRequestHandler</c> / <c>IStreamRequestHandler</c> implementation type at the end
/// of this chain (resolved by walking <c>next</c> to the terminal handler).
/// </summary>
Type HandlerType { get; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

<!-- NuGet -->
<PackageId>DSoftStudio.Mediator.FluentValidation</PackageId>
<Version>1.0.8</Version>
<Version>1.0.9-rc.1</Version>

<Authors>DSoftStudio</Authors>
<Company>DSoftStudio</Company>
Expand Down
Original file line number Diff line number Diff line change
@@ -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
13 changes: 12 additions & 1 deletion src/DSoftStudio.Mediator.Generators/CqrsSemanticAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` 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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
.NET 10 preview SDKs. All APIs we use (IIncrementalGenerator, OptimizationLevel,
AnalyzerConfigOptions) are available since 4.3.0. -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4">
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
Expand All @@ -33,4 +33,10 @@
<ProjectReference Include="..\DSoftStudio.Mediator.Abstractions\DSoftStudio.Mediator.Abstractions.csproj" />
</ItemGroup>

<!-- Expose internal generator helpers (EquatableArray, HandlerDiscovery, the *Info structs) to the
unit tests so they can be covered directly. Key matches the shared strong-name (Directory.Build.props). -->
<ItemGroup>
<InternalsVisibleTo Include="DSoftStudio.Mediator.Tests" Key="$(PublicKey)" />
</ItemGroup>

</Project>
21 changes: 21 additions & 0 deletions src/DSoftStudio.Mediator.Generators/DiagnosticDescriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
id: "DSOFT001",
title: "No handler found for request type",
messageFormat: "No IRequestHandler<{0}, {1}> implementation found for request type '{0}'",
category: "DSoftStudio.Mediator",

Check warning on line 14 in src/DSoftStudio.Mediator.Generators/DiagnosticDescriptors.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Define a constant instead of using this literal 'DSoftStudio.Mediator' 5 times.
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "Every request type implementing IRequest<TResponse> should have a corresponding IRequestHandler<TRequest, TResponse> implementation.");
Expand Down Expand Up @@ -92,5 +92,26 @@
+ "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<MediatorBuilder>) 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.",
// 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);
}
}
23 changes: 23 additions & 0 deletions src/DSoftStudio.Mediator.Generators/InterceptorHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,29 @@ public static void AppendStreamDispatchBody(
.AppendLine(">.Resolve(sp).Handle(request, cancellationToken);");
}

/// <summary>
/// Returns <see langword="true"/> when <paramref name="type"/> is — or transitively contains — a
/// type parameter (an open / not-fully-constructed type).
/// <para>
/// An interceptor must reference fully-constructed, concrete types: the
/// <c>[InterceptsLocation]</c> mechanism rewrites a single syntactic call site, but an open-generic
/// call site (e.g. <c>mediator.Send&lt;TRequest, TResponse&gt;(request)</c> 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 <c>Mediator.Send/Publish/CreateStream</c> at runtime.
/// </para>
/// </summary>
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,
};

/// <summary>
/// Returns <see langword="true"/> when <paramref name="containingType"/> is or implements
/// the interface identified by <paramref name="interfaceMetadataName"/>.
Expand Down
16 changes: 0 additions & 16 deletions src/DSoftStudio.Mediator.Generators/MediatorPipelineGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@
sb.AppendLine();

sb.AppendLine(" public static void RegisterPipelineChains(global::Microsoft.Extensions.DependencyInjection.IServiceCollection services)");
sb.AppendLine(" {");

Check warning on line 240 in src/DSoftStudio.Mediator.Generators/MediatorPipelineGenerator.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Define a constant instead of using this literal ' {' 6 times.
sb.AppendLine(" foreach (var d in services)");
sb.AppendLine(" if (d.ServiceType == typeof(__PipelineSentinel))");
sb.AppendLine(" return;");
Expand Down Expand Up @@ -271,7 +271,7 @@
$" RegisterPipeline<{handler.RequestType}, {handler.ResponseType}>(services);");
}

sb.AppendLine(" }");

Check warning on line 274 in src/DSoftStudio.Mediator.Generators/MediatorPipelineGenerator.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Define a constant instead of using this literal ' }' 6 times.
sb.AppendLine();

// Emit AOT-safe open-generic closure methods when behaviors are discovered
Expand Down Expand Up @@ -333,22 +333,6 @@
sb.AppendLine(" // Transient chains must be resolved fresh each call.");
sb.AppendLine(" if (!hasTransientPipelineComponent)");
sb.AppendLine(" global::DSoftStudio.Mediator.RequestDispatch<TRequest, TResponse>.MarkPipelineChainCacheable();");
sb.AppendLine();
sb.AppendLine(" // Pipeline with behaviors: resolve PipelineChainHandler directly — single DI lookup.");
sb.AppendLine(" global::DSoftStudio.Mediator.RequestDispatch<TRequest, TResponse>.TryInitialize(");
sb.AppendLine(" static (request, sp, ct) =>");
sb.AppendLine(" global::Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions");
sb.AppendLine(" .GetRequiredService<global::DSoftStudio.Mediator.PipelineChainHandler<TRequest, TResponse>>(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<TRequest, TResponse>.TryInitialize(");
sb.AppendLine(" static (request, sp, ct) =>");
sb.AppendLine(" global::Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions");
sb.AppendLine(" .GetRequiredService<global::DSoftStudio.Mediator.Abstractions.IRequestHandler<TRequest, TResponse>>(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.");
Expand Down
Loading
Loading