< Summary

Information
Class: NexusLabs.Needlr.Analyzers.DisposableCaptiveDependencyAnalyzer
Assembly: NexusLabs.Needlr.Analyzers
File(s): /home/runner/work/needlr/needlr/src/NexusLabs.Needlr.Analyzers/DisposableCaptiveDependencyAnalyzer.cs
Line coverage
94%
Covered lines: 90
Uncovered lines: 5
Coverable lines: 95
Total lines: 278
Line coverage: 94.7%
Branch coverage
90%
Covered branches: 72
Total branches: 80
Branch coverage: 90%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_SupportedDiagnostics()100%11100%
Initialize(...)100%11100%
AnalyzeClassDeclaration(...)91.66%121296.42%
AnalyzeParameters(...)100%1616100%
GetExplicitLifetimeFromAttributes(...)95.45%2222100%
IsFactoryPattern(...)75%181681.81%
GetConcreteTypeForAnalysis(...)75%4480%
GetDisposableInterface(...)100%66100%
GetLifetimeName(...)75%4485.71%

File(s)

/home/runner/work/needlr/needlr/src/NexusLabs.Needlr.Analyzers/DisposableCaptiveDependencyAnalyzer.cs

#LineLine coverage
 1using System.Collections.Immutable;
 2
 3using Microsoft.CodeAnalysis;
 4using Microsoft.CodeAnalysis.CSharp;
 5using Microsoft.CodeAnalysis.CSharp.Syntax;
 6using Microsoft.CodeAnalysis.Diagnostics;
 7
 8namespace NexusLabs.Needlr.Analyzers;
 9
 10/// <summary>
 11/// Analyzer that detects when a longer-lived service holds a reference to a shorter-lived IDisposable.
 12/// This is a more severe form of captive dependency because the disposed object will still be referenced.
 13/// </summary>
 14/// <remarks>
 15/// This analyzer is conservative to avoid false positives:
 16/// - Only fires when both consumer and dependency have explicit lifetime attributes
 17/// - Only fires when the dependency type itself (not just interface) implements IDisposable/IAsyncDisposable
 18/// - Does not fire for factory patterns (Func&lt;T&gt;, Lazy&lt;T&gt;, IServiceScopeFactory)
 19/// </remarks>
 20[DiagnosticAnalyzer(LanguageNames.CSharp)]
 21public sealed class DisposableCaptiveDependencyAnalyzer : DiagnosticAnalyzer
 22{
 23    private enum LifetimeRank
 24    {
 25        Unknown = -1,
 26        Transient = 0,
 27        Scoped = 1,
 28        Singleton = 2
 29    }
 30
 31    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
 34432        ImmutableArray.Create(DiagnosticDescriptors.DisposableCaptiveDependency);
 33
 34    public override void Initialize(AnalysisContext context)
 35    {
 3836        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
 3837        context.EnableConcurrentExecution();
 38
 3839        context.RegisterSyntaxNodeAction(AnalyzeClassDeclaration, SyntaxKind.ClassDeclaration);
 3840    }
 41
 42    private static void AnalyzeClassDeclaration(SyntaxNodeAnalysisContext context)
 43    {
 15544        var classDeclaration = (ClassDeclarationSyntax)context.Node;
 45
 46        // Skip abstract classes - they can't be instantiated directly
 15547        if (classDeclaration.Modifiers.Any(SyntaxKind.AbstractKeyword))
 48        {
 149            return;
 50        }
 51
 15452        var classSymbol = context.SemanticModel.GetDeclaredSymbol(classDeclaration);
 15453        if (classSymbol == null)
 54        {
 055            return;
 56        }
 57
 58        // Only analyze types with EXPLICIT lifetime attributes to avoid false positives
 15459        var consumerLifetime = GetExplicitLifetimeFromAttributes(classSymbol);
 15460        if (consumerLifetime == LifetimeRank.Unknown)
 61        {
 11262            return; // No explicit lifetime, skip to avoid false positives
 63        }
 64
 65        // Transient types can safely depend on anything
 4266        if (consumerLifetime == LifetimeRank.Transient)
 67        {
 568            return;
 69        }
 70
 71        // Find constructors and analyze their parameters
 3772        var constructors = classDeclaration.Members
 3773            .OfType<ConstructorDeclarationSyntax>()
 1774            .Where(c => !c.Modifiers.Any(SyntaxKind.StaticKeyword))
 3775            .ToList();
 76
 77        // Check primary constructor parameters
 3778        if (classDeclaration.ParameterList != null)
 79        {
 280            AnalyzeParameters(
 281                context,
 282                classDeclaration.ParameterList.Parameters,
 283                classSymbol,
 284                consumerLifetime);
 85        }
 86
 87        // Analyze explicit constructor parameters
 10888        foreach (var constructor in constructors)
 89        {
 1790            AnalyzeParameters(
 1791                context,
 1792                constructor.ParameterList.Parameters,
 1793                classSymbol,
 1794                consumerLifetime);
 95        }
 3796    }
 97
 98    private static void AnalyzeParameters(
 99        SyntaxNodeAnalysisContext context,
 100        SeparatedSyntaxList<ParameterSyntax> parameters,
 101        INamedTypeSymbol consumerSymbol,
 102        LifetimeRank consumerLifetime)
 103    {
 80104        foreach (var parameter in parameters)
 105        {
 21106            if (parameter.Type == null)
 107            {
 108                continue;
 109            }
 110
 21111            var typeInfo = context.SemanticModel.GetTypeInfo(parameter.Type);
 21112            var parameterType = typeInfo.Type as INamedTypeSymbol;
 21113            if (parameterType == null)
 114            {
 115                continue;
 116            }
 117
 118            // Skip factory patterns - these are safe
 21119            if (IsFactoryPattern(parameterType))
 120            {
 121                continue;
 122            }
 123
 124            // For interfaces, get the concrete type if we can determine it
 19125            var concreteType = GetConcreteTypeForAnalysis(parameterType);
 19126            if (concreteType == null)
 127            {
 128                continue; // Can't determine concrete type, skip to avoid false positives
 129            }
 130
 131            // Get the EXPLICIT lifetime of the dependency
 18132            var dependencyLifetime = GetExplicitLifetimeFromAttributes(concreteType);
 18133            if (dependencyLifetime == LifetimeRank.Unknown)
 134            {
 135                continue; // No explicit lifetime, skip to avoid false positives
 136            }
 137
 138            // Check for mismatch: consumer lifetime > dependency lifetime
 17139            if ((int)consumerLifetime <= (int)dependencyLifetime)
 140            {
 141                continue; // No mismatch
 142            }
 143
 144            // Check if the dependency implements IDisposable or IAsyncDisposable
 15145            var disposableInterface = GetDisposableInterface(concreteType);
 15146            if (disposableInterface == null)
 147            {
 148                continue; // Not disposable, let NDLRCOR005 handle generic lifetime mismatch
 149            }
 150
 12151            var diagnostic = Diagnostic.Create(
 12152                DiagnosticDescriptors.DisposableCaptiveDependency,
 12153                parameter.GetLocation(),
 12154                consumerSymbol.Name,
 12155                GetLifetimeName(consumerLifetime),
 12156                concreteType.Name,
 12157                GetLifetimeName(dependencyLifetime),
 12158                disposableInterface);
 159
 12160            context.ReportDiagnostic(diagnostic);
 161        }
 19162    }
 163
 164    /// <summary>
 165    /// Gets the lifetime ONLY from explicit attributes. Returns Unknown if no explicit attribute.
 166    /// This is more conservative than LifetimeMismatchAnalyzer which defaults to Singleton.
 167    /// </summary>
 168    private static LifetimeRank GetExplicitLifetimeFromAttributes(INamedTypeSymbol typeSymbol)
 169    {
 623170        foreach (var attribute in typeSymbol.GetAttributes())
 171        {
 169172            var attributeName = attribute.AttributeClass?.Name;
 173
 169174            if (attributeName is "SingletonAttribute" or "Singleton")
 175            {
 18176                return LifetimeRank.Singleton;
 177            }
 178
 151179            if (attributeName is "ScopedAttribute" or "Scoped")
 180            {
 32181                return LifetimeRank.Scoped;
 182            }
 183
 119184            if (attributeName is "TransientAttribute" or "Transient")
 185            {
 9186                return LifetimeRank.Transient;
 187            }
 188        }
 189
 113190        return LifetimeRank.Unknown;
 191    }
 192
 193    /// <summary>
 194    /// Check if the type is a factory pattern that safely handles lifetime management.
 195    /// </summary>
 196    private static bool IsFactoryPattern(INamedTypeSymbol typeSymbol)
 197    {
 21198        var name = typeSymbol.Name;
 21199        var fullName = typeSymbol.ToDisplayString();
 200
 201        // Func<T> - factory delegates
 21202        if (name == "Func" && typeSymbol.IsGenericType)
 203        {
 1204            return true;
 205        }
 206
 207        // Lazy<T> - deferred resolution
 20208        if (name == "Lazy" && typeSymbol.IsGenericType)
 209        {
 1210            return true;
 211        }
 212
 213        // IServiceScopeFactory - scope management
 19214        if (name == "IServiceScopeFactory" || fullName.Contains("IServiceScopeFactory"))
 215        {
 0216            return true;
 217        }
 218
 219        // IServiceProvider - direct resolution
 19220        if (name == "IServiceProvider" || fullName.Contains("IServiceProvider"))
 221        {
 0222            return true;
 223        }
 224
 19225        return false;
 226    }
 227
 228    /// <summary>
 229    /// Get the concrete type to analyze. For concrete classes, returns the type itself.
 230    /// For interfaces, returns null (we can't determine the implementation).
 231    /// </summary>
 232    private static INamedTypeSymbol? GetConcreteTypeForAnalysis(INamedTypeSymbol typeSymbol)
 233    {
 234        // For interfaces, we can't determine the concrete implementation at compile time
 19235        if (typeSymbol.TypeKind == TypeKind.Interface)
 236        {
 1237            return null;
 238        }
 239
 240        // For abstract classes, we can't determine which subclass will be used
 18241        if (typeSymbol.IsAbstract)
 242        {
 0243            return null;
 244        }
 245
 18246        return typeSymbol;
 247    }
 248
 249    /// <summary>
 250    /// Check if the type implements IDisposable or IAsyncDisposable.
 251    /// </summary>
 252    private static string? GetDisposableInterface(INamedTypeSymbol typeSymbol)
 253    {
 42254        foreach (var iface in typeSymbol.AllInterfaces)
 255        {
 12256            var fullName = iface.ToDisplayString();
 12257            if (fullName == "System.IDisposable")
 258            {
 10259                return "IDisposable";
 260            }
 261
 2262            if (fullName == "System.IAsyncDisposable")
 263            {
 2264                return "IAsyncDisposable";
 265            }
 266        }
 267
 3268        return null;
 269    }
 270
 24271    private static string GetLifetimeName(LifetimeRank lifetime) => lifetime switch
 24272    {
 10273        LifetimeRank.Singleton => "Singleton",
 10274        LifetimeRank.Scoped => "Scoped",
 4275        LifetimeRank.Transient => "Transient",
 0276        _ => "Unknown"
 24277    };
 278}