< Summary

Information
Class: NexusLabs.Needlr.LifetimeMismatchExtensions
Assembly: NexusLabs.Needlr
File(s): /home/runner/work/needlr/needlr/src/NexusLabs.Needlr/LifetimeMismatchExtensions.cs
Line coverage
95%
Covered lines: 39
Uncovered lines: 2
Coverable lines: 41
Total lines: 149
Line coverage: 95.1%
Branch coverage
90%
Covered branches: 20
Total branches: 22
Branch coverage: 90.9%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
DetectLifetimeMismatches(...)100%1212100%
BuildLifetimeLookup(...)100%22100%
GetConstructorDependencies()75%4487.5%
GetLifetimeRank()75%4485.71%
IsLifetimeMismatch(...)100%11100%

File(s)

/home/runner/work/needlr/needlr/src/NexusLabs.Needlr/LifetimeMismatchExtensions.cs

#LineLine coverage
 1using Microsoft.Extensions.DependencyInjection;
 2
 3using System.Diagnostics.CodeAnalysis;
 4using System.Reflection;
 5
 6namespace NexusLabs.Needlr;
 7
 8/// <summary>
 9/// Extension methods for detecting lifetime mismatches (captive dependencies) in service registrations.
 10/// </summary>
 11public static class LifetimeMismatchExtensions
 12{
 13    /// <summary>
 14    /// Detects lifetime mismatches (captive dependencies) in the service collection.
 15    /// A lifetime mismatch occurs when a longer-lived service depends on a shorter-lived service.
 16    /// </summary>
 17    /// <param name="services">The service collection to analyze.</param>
 18    /// <returns>A list of detected lifetime mismatches.</returns>
 19    /// <exception cref="ArgumentNullException">Thrown when services is null.</exception>
 20    /// <remarks>
 21    /// <para>Lifetime hierarchy (from longest to shortest):</para>
 22    /// <list type="bullet">
 23    /// <item>Singleton (lives for entire application lifetime)</item>
 24    /// <item>Scoped (lives for the scope/request lifetime)</item>
 25    /// <item>Transient (new instance every time)</item>
 26    /// </list>
 27    /// <para>
 28    /// A mismatch occurs when a service with a longer lifetime depends on a service with a shorter lifetime.
 29    /// For example, a Singleton depending on a Scoped service will "capture" the scoped instance,
 30    /// causing it to live longer than intended.
 31    /// </para>
 32    /// <para>
 33    /// Factory registrations cannot be analyzed and are skipped.
 34    /// </para>
 35    /// </remarks>
 36    public static IReadOnlyList<LifetimeMismatch> DetectLifetimeMismatches(this IServiceCollection services)
 37    {
 51238        ArgumentNullException.ThrowIfNull(services);
 39
 51140        var mismatches = new List<LifetimeMismatch>();
 41
 42        // Build a lookup of service type -> lifetime
 51143        var lifetimeLookup = BuildLifetimeLookup(services);
 44
 26820645        foreach (var descriptor in services)
 46        {
 47            // Skip factory registrations - we can't analyze their dependencies
 13359248            if (descriptor.ImplementationType is null)
 49            {
 50                continue;
 51            }
 52
 4567553            var consumerLifetime = descriptor.Lifetime;
 54
 55            // Transient services can depend on anything without causing captive dependencies
 4567556            if (consumerLifetime == ServiceLifetime.Transient)
 57            {
 58                continue;
 59            }
 60
 61            // Analyze constructor dependencies
 62            // Note: descriptor.ImplementationType from MS.DI doesn't have DynamicallyAccessedMembers annotation,
 63            // but constructor metadata is typically preserved for DI-registered types.
 64#pragma warning disable IL2072 // Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in ca
 4362465            var dependencies = GetConstructorDependencies(descriptor.ImplementationType);
 66#pragma warning restore IL2072
 67
 12335868            foreach (var dependencyType in dependencies)
 69            {
 1805570                if (!lifetimeLookup.TryGetValue(dependencyType, out var dependencyLifetime))
 71                {
 72                    // Dependency not registered - skip
 73                    continue;
 74                }
 75
 1402576                if (IsLifetimeMismatch(consumerLifetime, dependencyLifetime))
 77                {
 1878                    mismatches.Add(new LifetimeMismatch(
 1879                        ConsumerServiceType: descriptor.ServiceType,
 1880                        ConsumerImplementationType: descriptor.ImplementationType,
 1881                        ConsumerLifetime: consumerLifetime,
 1882                        DependencyServiceType: dependencyType,
 1883                        DependencyLifetime: dependencyLifetime));
 84                }
 85            }
 86        }
 87
 51188        return mismatches;
 89    }
 90
 91    private static Dictionary<Type, ServiceLifetime> BuildLifetimeLookup(IServiceCollection services)
 92    {
 51193        var lookup = new Dictionary<Type, ServiceLifetime>();
 94
 26820695        foreach (var descriptor in services)
 96        {
 97            // Use the last registration for a given service type (mimics DI container behavior)
 13359298            lookup[descriptor.ServiceType] = descriptor.Lifetime;
 99        }
 100
 511101        return lookup;
 102    }
 103
 104    private static IEnumerable<Type> GetConstructorDependencies(
 105        [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type implementationType)
 106    {
 107        // Get the constructor that the DI container would use (longest parameter list)
 43624108        var constructors = implementationType.GetConstructors(BindingFlags.Public | BindingFlags.Instance);
 109
 43624110        if (constructors.Length == 0)
 111        {
 0112            yield break;
 113        }
 114
 115        // DI container typically uses the constructor with the most parameters
 43624116        var constructor = constructors
 43624117            .OrderByDescending(c => c.GetParameters().Length)
 43624118            .First();
 119
 123358120        foreach (var parameter in constructor.GetParameters())
 121        {
 18055122            yield return parameter.ParameterType;
 123        }
 43624124    }
 125
 126    /// <summary>
 127    /// Determines if there is a lifetime mismatch between a consumer and its dependency.
 128    /// </summary>
 129    /// <param name="consumerLifetime">The lifetime of the consuming service.</param>
 130    /// <param name="dependencyLifetime">The lifetime of the dependency.</param>
 131    /// <returns>True if there is a mismatch (consumer lives longer than dependency).</returns>
 132    private static bool IsLifetimeMismatch(ServiceLifetime consumerLifetime, ServiceLifetime dependencyLifetime)
 133    {
 134        // Lifetime "rank" - higher number = longer lifetime
 28050135        static int GetLifetimeRank(ServiceLifetime lifetime) => lifetime switch
 28050136        {
 6137            ServiceLifetime.Transient => 0,
 18138            ServiceLifetime.Scoped => 1,
 28026139            ServiceLifetime.Singleton => 2,
 0140            _ => 0
 28050141        };
 142
 14025143        var consumerRank = GetLifetimeRank(consumerLifetime);
 14025144        var dependencyRank = GetLifetimeRank(dependencyLifetime);
 145
 146        // Mismatch if consumer lives longer than dependency
 14025147        return consumerRank > dependencyRank;
 148    }
 149}