From ba67124847582a73f343218410e7463f85eb3da6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 20:30:14 +0000 Subject: [PATCH 1/7] Add diagnostic analyzer for source generator configuration errors Introduce compile-time diagnostics (INJECT0001-INJECT0009) that warn about invalid RegisterServices method signatures, missing/invalid factory methods, service-implementation type mismatches, and abstract type issues. Diagnostics are captured as equatable DiagnosticInfo records during the semantic transform phase to maximize incremental generator pipeline caching. https://claude.ai/code/session_01FpKv78qmKTTnNZAGyXxGwv --- .../DiagnosticDescriptors.cs | 80 ++++ src/Injectio.Generators/DiagnosticInfo.cs | 16 + .../ServiceRegistrationContext.cs | 3 +- .../ServiceRegistrationGenerator.cs | 300 +++++++++++++- .../ServiceRegistrationDiagnosticTests.cs | 375 ++++++++++++++++++ 5 files changed, 752 insertions(+), 22 deletions(-) create mode 100644 src/Injectio.Generators/DiagnosticDescriptors.cs create mode 100644 src/Injectio.Generators/DiagnosticInfo.cs create mode 100644 tests/Injectio.Tests/ServiceRegistrationDiagnosticTests.cs diff --git a/src/Injectio.Generators/DiagnosticDescriptors.cs b/src/Injectio.Generators/DiagnosticDescriptors.cs new file mode 100644 index 0000000..08e6d4e --- /dev/null +++ b/src/Injectio.Generators/DiagnosticDescriptors.cs @@ -0,0 +1,80 @@ +using Microsoft.CodeAnalysis; + +namespace Injectio.Generators; + +public static class DiagnosticDescriptors +{ + private const string Category = "Injectio"; + + public static readonly DiagnosticDescriptor InvalidMethodSignature = new( + id: "INJECT0001", + title: "RegisterServices method has invalid signature", + messageFormat: "Method '{0}' marked with [RegisterServices] must have IServiceCollection as its first parameter", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor InvalidMethodSecondParameter = new( + id: "INJECT0002", + title: "RegisterServices method has invalid second parameter", + messageFormat: "Method '{0}' marked with [RegisterServices] has an invalid second parameter; expected a string collection (e.g., IEnumerable)", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor MethodTooManyParameters = new( + id: "INJECT0003", + title: "RegisterServices method has too many parameters", + messageFormat: "Method '{0}' marked with [RegisterServices] has {1} parameters; expected 1 or 2", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor FactoryMethodNotFound = new( + id: "INJECT0004", + title: "Factory method not found", + messageFormat: "Factory method '{0}' was not found on type '{1}'", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor FactoryMethodNotStatic = new( + id: "INJECT0005", + title: "Factory method must be static", + messageFormat: "Factory method '{0}' on type '{1}' must be static", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor FactoryMethodInvalidSignature = new( + id: "INJECT0006", + title: "Factory method has invalid signature", + messageFormat: "Factory method '{0}' on type '{1}' must accept IServiceProvider as its first parameter and optionally object? as its second parameter", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor ServiceTypeMismatch = new( + id: "INJECT0007", + title: "Implementation does not implement service type", + messageFormat: "Type '{0}' does not implement or inherit from service type '{1}'", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor AbstractImplementationType = new( + id: "INJECT0008", + title: "Implementation type is abstract", + messageFormat: "Implementation type '{0}' is abstract and cannot be instantiated without a factory method", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor RegisterServicesMethodOnAbstractClass = new( + id: "INJECT0009", + title: "RegisterServices on non-static method in abstract class", + messageFormat: "Method '{0}' marked with [RegisterServices] is a non-static method on abstract class '{1}'; the class cannot be instantiated", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); +} diff --git a/src/Injectio.Generators/DiagnosticInfo.cs b/src/Injectio.Generators/DiagnosticInfo.cs new file mode 100644 index 0000000..f47e1b8 --- /dev/null +++ b/src/Injectio.Generators/DiagnosticInfo.cs @@ -0,0 +1,16 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Injectio.Generators; + +/// +/// Serializable diagnostic information for pipeline caching. +/// Captures all data needed to report a diagnostic without holding Roslyn symbols. +/// +public record DiagnosticInfo( + string Id, + string FilePath, + TextSpan TextSpan, + LinePositionSpan LineSpan, + EquatableArray MessageArguments +); diff --git a/src/Injectio.Generators/ServiceRegistrationContext.cs b/src/Injectio.Generators/ServiceRegistrationContext.cs index 2451cce..34a8df2 100644 --- a/src/Injectio.Generators/ServiceRegistrationContext.cs +++ b/src/Injectio.Generators/ServiceRegistrationContext.cs @@ -6,5 +6,6 @@ namespace Injectio.Generators; public record ServiceRegistrationContext( EquatableArray? ServiceRegistrations = null, - EquatableArray? ModuleRegistrations = null + EquatableArray? ModuleRegistrations = null, + EquatableArray? Diagnostics = null ); diff --git a/src/Injectio.Generators/ServiceRegistrationGenerator.cs b/src/Injectio.Generators/ServiceRegistrationGenerator.cs index 0556fcf..106f738 100644 --- a/src/Injectio.Generators/ServiceRegistrationGenerator.cs +++ b/src/Injectio.Generators/ServiceRegistrationGenerator.cs @@ -29,7 +29,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) ) .Where(static context => context is not null - && (context.ServiceRegistrations?.Count > 0 || context.ModuleRegistrations?.Count > 0) + && (context.ServiceRegistrations?.Count > 0 + || context.ModuleRegistrations?.Count > 0 + || context.Diagnostics?.Count > 0) ) .Collect() .WithTrackingName("Registrations"); @@ -59,6 +61,29 @@ private void ExecuteGeneration( SourceProductionContext sourceContext, (ImmutableArray Registrations, (string? AssemblyName, MethodOptions? MethodOptions) Options) source) { + // report all collected diagnostics + foreach (var context in source.Registrations) + { + if (context?.Diagnostics is null) + continue; + + foreach (var diagnosticInfo in context.Diagnostics) + { + var descriptor = GetDescriptorById(diagnosticInfo.Id); + var location = Location.Create( + diagnosticInfo.FilePath, + diagnosticInfo.TextSpan, + diagnosticInfo.LineSpan); + + var diagnostic = Diagnostic.Create( + descriptor, + location, + diagnosticInfo.MessageArguments.AsArray()); + + sourceContext.ReportDiagnostic(diagnostic); + } + } + var serviceRegistrations = source.Registrations .SelectMany(m => m?.ServiceRegistrations ?? Array.Empty()) .Where(m => m is not null) @@ -133,9 +158,21 @@ private static bool SyntacticPredicate(SyntaxNode syntaxNode, CancellationToken if (!isKnown) return null; - var (isValid, hasTagCollection) = ValidateMethod(methodSymbol); + var diagnostics = new List(); + + // warn if non-static method on abstract class (can't instantiate to call it) + if (!methodSymbol.IsStatic && methodSymbol.ContainingType.IsAbstract) + { + diagnostics.Add(CreateDiagnosticInfo( + DiagnosticDescriptors.RegisterServicesMethodOnAbstractClass, + methodDeclaration.Identifier.GetLocation(), + methodSymbol.Name, + methodSymbol.ContainingType.ToDisplayString(_fullyQualifiedNullableFormat))); + } + + var (isValid, hasTagCollection) = ValidateMethod(methodSymbol, methodDeclaration, diagnostics); if (!isValid) - return null; + return new ServiceRegistrationContext(Diagnostics: diagnostics.ToArray()); var registration = new ModuleRegistration ( @@ -145,7 +182,9 @@ private static bool SyntacticPredicate(SyntaxNode syntaxNode, CancellationToken HasTagCollection: hasTagCollection ); - return new ServiceRegistrationContext(ModuleRegistrations: new[] { registration }); + return new ServiceRegistrationContext( + ModuleRegistrations: new[] { registration }, + Diagnostics: diagnostics.Count > 0 ? diagnostics.ToArray() : null); } private static ServiceRegistrationContext? SemanticTransformClass(GeneratorSyntaxContext context) @@ -161,50 +200,93 @@ private static bool SyntacticPredicate(SyntaxNode syntaxNode, CancellationToken // support multiple register attributes on a class var registrations = new List(); + var diagnostics = new List(); foreach (var attribute in attributes) { - var registration = CreateServiceRegistration(classSymbol, attribute); + var registration = CreateServiceRegistration(classSymbol, attribute, declaration, diagnostics); if (registration is not null) registrations.Add(registration); } - if (registrations.Count == 0) + if (registrations.Count == 0 && diagnostics.Count == 0) return null; - return new ServiceRegistrationContext(ServiceRegistrations: registrations.ToArray()); + return new ServiceRegistrationContext( + ServiceRegistrations: registrations.Count > 0 ? registrations.ToArray() : null, + Diagnostics: diagnostics.Count > 0 ? diagnostics.ToArray() : null); } - private static (bool isValid, bool hasTagCollection) ValidateMethod(IMethodSymbol methodSymbol) + private static (bool isValid, bool hasTagCollection) ValidateMethod( + IMethodSymbol methodSymbol, + MethodDeclarationSyntax methodDeclaration, + List diagnostics) { + // too many parameters + if (methodSymbol.Parameters.Length > 2) + { + diagnostics.Add(CreateDiagnosticInfo( + DiagnosticDescriptors.MethodTooManyParameters, + methodDeclaration.Identifier.GetLocation(), + methodSymbol.Name, + methodSymbol.Parameters.Length.ToString())); + + return (false, false); + } + + // no parameters at all + if (methodSymbol.Parameters.Length == 0) + { + diagnostics.Add(CreateDiagnosticInfo( + DiagnosticDescriptors.InvalidMethodSignature, + methodDeclaration.Identifier.GetLocation(), + methodSymbol.Name)); + + return (false, false); + } + var hasServiceCollection = false; var hasTagCollection = false; // validate first parameter should be service collection - if (methodSymbol.Parameters.Length is 1 or 2) + var firstParam = methodSymbol.Parameters[0]; + hasServiceCollection = IsServiceCollection(firstParam); + + if (!hasServiceCollection) { - var parameterSymbol = methodSymbol.Parameters[0]; - hasServiceCollection = IsServiceCollection(parameterSymbol); + diagnostics.Add(CreateDiagnosticInfo( + DiagnosticDescriptors.InvalidMethodSignature, + methodDeclaration.Identifier.GetLocation(), + methodSymbol.Name)); + + return (false, false); } - if (methodSymbol.Parameters.Length is 1) - return (hasServiceCollection, false); + if (methodSymbol.Parameters.Length == 1) + return (true, false); // validate second parameter should be string collection - if (methodSymbol.Parameters.Length is 2) + var secondParam = methodSymbol.Parameters[1]; + hasTagCollection = IsStringCollection(secondParam); + + if (!hasTagCollection) { - var parameterSymbol = methodSymbol.Parameters[1]; - hasTagCollection = IsStringCollection(parameterSymbol); + diagnostics.Add(CreateDiagnosticInfo( + DiagnosticDescriptors.InvalidMethodSecondParameter, + methodDeclaration.Identifier.GetLocation(), + methodSymbol.Name)); - // to be valid, parameter 0 must be service collection and parameter 1 must be string collection, - return (hasServiceCollection && hasTagCollection, hasTagCollection); + return (false, false); } - // invalid method - return (false, false); + return (true, hasTagCollection); } - private static ServiceRegistration? CreateServiceRegistration(INamedTypeSymbol classSymbol, AttributeData attribute) + private static ServiceRegistration? CreateServiceRegistration( + INamedTypeSymbol classSymbol, + AttributeData attribute, + TypeDeclarationSyntax declaration, + List diagnostics) { // check for known attribute if (!IsKnownAttribute(attribute, out var serviceLifetime)) @@ -342,6 +424,24 @@ or KnownTypes.RegistrationStrategySelfWithInterfacesShortName if (registrationStrategy is KnownTypes.RegistrationStrategySelfWithProxyFactoryShortName && isOpenGeneric) registrationStrategy = KnownTypes.RegistrationStrategySelfWithInterfacesShortName; + // validate abstract implementation type without factory + if (classSymbol.IsAbstract && implementationFactory.IsNullOrWhiteSpace() && implementationType == classSymbol.ToDisplayString(_fullyQualifiedNullableFormat)) + { + diagnostics.Add(CreateDiagnosticInfo( + DiagnosticDescriptors.AbstractImplementationType, + declaration.Identifier.GetLocation(), + implementationType!)); + } + + // validate factory method + if (implementationFactory.HasValue()) + { + ValidateFactoryMethod(classSymbol, implementationFactory!, declaration, diagnostics); + } + + // validate service type assignability + ValidateServiceTypes(classSymbol, serviceTypes, declaration, diagnostics); + return new ServiceRegistration( Lifetime: serviceLifetime, ImplementationType: implementationType!, @@ -527,4 +627,162 @@ private static string ResolveRegistrationStrategy(object? value) _ => KnownTypes.RegistrationStrategySelfWithProxyFactoryShortName }; } + + private static void ValidateFactoryMethod( + INamedTypeSymbol classSymbol, + string factoryMethodName, + TypeDeclarationSyntax declaration, + List diagnostics) + { + var className = classSymbol.ToDisplayString(_fullyQualifiedNullableFormat); + + // look for method on the implementation type + var members = classSymbol.GetMembers(factoryMethodName); + var factoryMethods = members.OfType().ToArray(); + + if (factoryMethods.Length == 0) + { + diagnostics.Add(CreateDiagnosticInfo( + DiagnosticDescriptors.FactoryMethodNotFound, + declaration.Identifier.GetLocation(), + factoryMethodName, + className)); + return; + } + + foreach (var method in factoryMethods) + { + if (!method.IsStatic) + { + diagnostics.Add(CreateDiagnosticInfo( + DiagnosticDescriptors.FactoryMethodNotStatic, + declaration.Identifier.GetLocation(), + factoryMethodName, + className)); + return; + } + + // validate signature: (IServiceProvider) or (IServiceProvider, object?) + if (method.Parameters.Length is not (1 or 2)) + { + diagnostics.Add(CreateDiagnosticInfo( + DiagnosticDescriptors.FactoryMethodInvalidSignature, + declaration.Identifier.GetLocation(), + factoryMethodName, + className)); + return; + } + + var firstParam = method.Parameters[0]; + if (!IsServiceProvider(firstParam)) + { + diagnostics.Add(CreateDiagnosticInfo( + DiagnosticDescriptors.FactoryMethodInvalidSignature, + declaration.Identifier.GetLocation(), + factoryMethodName, + className)); + } + } + } + + private static void ValidateServiceTypes( + INamedTypeSymbol classSymbol, + HashSet serviceTypes, + TypeDeclarationSyntax declaration, + List diagnostics) + { + var implTypeName = classSymbol.ToDisplayString(_fullyQualifiedNullableFormat); + + foreach (var serviceType in serviceTypes) + { + // skip self-registration + if (serviceType == implTypeName) + continue; + + // check if the class implements the service type by comparing display strings + var implementsService = false; + + foreach (var iface in classSymbol.AllInterfaces) + { + var unboundInterface = ToUnboundGenericType(iface); + var ifaceName = unboundInterface.ToDisplayString(_fullyQualifiedNullableFormat); + if (ifaceName == serviceType) + { + implementsService = true; + break; + } + } + + if (!implementsService) + { + // also check base types + var baseType = classSymbol.BaseType; + while (baseType is not null) + { + var unboundBase = ToUnboundGenericType(baseType); + var baseName = unboundBase.ToDisplayString(_fullyQualifiedNullableFormat); + if (baseName == serviceType) + { + implementsService = true; + break; + } + baseType = baseType.BaseType; + } + } + + if (!implementsService) + { + diagnostics.Add(CreateDiagnosticInfo( + DiagnosticDescriptors.ServiceTypeMismatch, + declaration.Identifier.GetLocation(), + implTypeName, + serviceType)); + } + } + } + + private static bool IsServiceProvider(IParameterSymbol parameterSymbol) + { + return parameterSymbol?.Type is + { + Name: "IServiceProvider", + ContainingNamespace: + { + Name: "System", + ContainingNamespace.IsGlobalNamespace: true + } + }; + } + + private static DiagnosticInfo CreateDiagnosticInfo( + DiagnosticDescriptor descriptor, + Location location, + params string[] messageArgs) + { + var lineSpan = location.GetLineSpan(); + + return new DiagnosticInfo( + Id: descriptor.Id, + FilePath: lineSpan.Path ?? string.Empty, + TextSpan: location.SourceSpan, + LineSpan: lineSpan.Span, + MessageArguments: messageArgs); + } + + internal static DiagnosticDescriptor GetDescriptorById(string id) + { + return id switch + { + "INJECT0001" => DiagnosticDescriptors.InvalidMethodSignature, + "INJECT0002" => DiagnosticDescriptors.InvalidMethodSecondParameter, + "INJECT0003" => DiagnosticDescriptors.MethodTooManyParameters, + "INJECT0004" => DiagnosticDescriptors.FactoryMethodNotFound, + "INJECT0005" => DiagnosticDescriptors.FactoryMethodNotStatic, + "INJECT0006" => DiagnosticDescriptors.FactoryMethodInvalidSignature, + "INJECT0007" => DiagnosticDescriptors.ServiceTypeMismatch, + "INJECT0008" => DiagnosticDescriptors.AbstractImplementationType, + "INJECT0009" => DiagnosticDescriptors.RegisterServicesMethodOnAbstractClass, + _ => throw new ArgumentException($"Unknown diagnostic ID: {id}") + }; + } } diff --git a/tests/Injectio.Tests/ServiceRegistrationDiagnosticTests.cs b/tests/Injectio.Tests/ServiceRegistrationDiagnosticTests.cs new file mode 100644 index 0000000..bb65c98 --- /dev/null +++ b/tests/Injectio.Tests/ServiceRegistrationDiagnosticTests.cs @@ -0,0 +1,375 @@ +using System; +using System.Collections.Immutable; +using System.Linq; + +using AwesomeAssertions; + +using Injectio.Attributes; +using Injectio.Generators; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.Extensions.DependencyInjection; + +using Xunit; + +namespace Injectio.Tests; + +public class ServiceRegistrationDiagnosticTests +{ + [Fact] + public void DiagnoseRegisterServicesInvalidFirstParameter() + { + var source = @" +using Injectio.Attributes; + +namespace Injectio.Sample; + +public static class RegistrationModule +{ + [RegisterServices] + public static void Register(string test) + { + } +} +"; + + var diagnostics = GetDiagnostics(source); + + diagnostics.Should().ContainSingle(d => d.Id == "INJECT0001"); + } + + [Fact] + public void DiagnoseRegisterServicesInvalidSecondParameter() + { + var source = @" +using Injectio.Attributes; +using Microsoft.Extensions.DependencyInjection; + +namespace Injectio.Sample; + +public static class RegistrationModule +{ + [RegisterServices] + public static void Register(IServiceCollection services, string test) + { + } +} +"; + + var diagnostics = GetDiagnostics(source); + + diagnostics.Should().ContainSingle(d => d.Id == "INJECT0002"); + } + + [Fact] + public void DiagnoseRegisterServicesTooManyParameters() + { + var source = @" +using Injectio.Attributes; +using Microsoft.Extensions.DependencyInjection; + +namespace Injectio.Sample; + +public static class RegistrationModule +{ + [RegisterServices] + public static void Register(IServiceCollection services, string a, string b) + { + } +} +"; + + var diagnostics = GetDiagnostics(source); + + diagnostics.Should().ContainSingle(d => d.Id == "INJECT0003"); + } + + [Fact] + public void DiagnoseRegisterServicesNoParameters() + { + var source = @" +using Injectio.Attributes; + +namespace Injectio.Sample; + +public static class RegistrationModule +{ + [RegisterServices] + public static void Register() + { + } +} +"; + + var diagnostics = GetDiagnostics(source); + + diagnostics.Should().ContainSingle(d => d.Id == "INJECT0001"); + } + + [Fact] + public void DiagnoseFactoryMethodNotFound() + { + var source = @" +using Injectio.Attributes; + +namespace Injectio.Sample; + +public interface IService { } + +[RegisterTransient(ServiceType = typeof(IService), Factory = ""NonExistentMethod"")] +public class MyService : IService +{ +} +"; + + var diagnostics = GetDiagnostics(source); + + diagnostics.Should().ContainSingle(d => d.Id == "INJECT0004"); + } + + [Fact] + public void DiagnoseFactoryMethodNotStatic() + { + var source = @" +using System; +using Injectio.Attributes; + +namespace Injectio.Sample; + +public interface IService { } + +[RegisterTransient(ServiceType = typeof(IService), Factory = nameof(ServiceFactory))] +public class MyService : IService +{ + public IService ServiceFactory(IServiceProvider serviceProvider) + { + return new MyService(); + } +} +"; + + var diagnostics = GetDiagnostics(source); + + diagnostics.Should().ContainSingle(d => d.Id == "INJECT0005"); + } + + [Fact] + public void DiagnoseFactoryMethodInvalidSignature() + { + var source = @" +using Injectio.Attributes; + +namespace Injectio.Sample; + +public interface IService { } + +[RegisterTransient(ServiceType = typeof(IService), Factory = nameof(ServiceFactory))] +public class MyService : IService +{ + public static IService ServiceFactory(string notServiceProvider) + { + return new MyService(); + } +} +"; + + var diagnostics = GetDiagnostics(source); + + diagnostics.Should().ContainSingle(d => d.Id == "INJECT0006"); + } + + [Fact] + public void DiagnoseServiceTypeMismatch() + { + var source = @" +using Injectio.Attributes; + +namespace Injectio.Sample; + +public interface IService { } +public interface IOtherService { } + +[RegisterTransient(ServiceType = typeof(IOtherService))] +public class MyService : IService +{ +} +"; + + var diagnostics = GetDiagnostics(source); + + diagnostics.Should().ContainSingle(d => d.Id == "INJECT0007"); + } + + [Fact] + public void DiagnoseRegisterServicesOnAbstractClassNonStaticMethod() + { + var source = @" +using Injectio.Attributes; +using Microsoft.Extensions.DependencyInjection; + +namespace Injectio.Sample; + +public abstract class RegistrationModule +{ + [RegisterServices] + public void Register(IServiceCollection services) + { + } +} +"; + + var diagnostics = GetDiagnostics(source); + + diagnostics.Should().ContainSingle(d => d.Id == "INJECT0009"); + } + + [Fact] + public void NoDiagnosticsForValidRegistration() + { + var source = @" +using Injectio.Attributes; + +namespace Injectio.Sample; + +public interface IService { } + +[RegisterSingleton] +public class MyService : IService +{ +} +"; + + var diagnostics = GetDiagnostics(source); + + diagnostics.Should().BeEmpty(); + } + + [Fact] + public void NoDiagnosticsForValidFactory() + { + var source = @" +using System; +using Injectio.Attributes; + +namespace Injectio.Sample; + +public interface IService { } + +[RegisterTransient(ServiceType = typeof(IService), Factory = nameof(ServiceFactory))] +public class MyService : IService +{ + public static IService ServiceFactory(IServiceProvider serviceProvider) + { + return new MyService(); + } +} +"; + + var diagnostics = GetDiagnostics(source); + + diagnostics.Should().BeEmpty(); + } + + [Fact] + public void NoDiagnosticsForValidKeyedFactory() + { + var source = @" +using System; +using Injectio.Attributes; + +namespace Injectio.Sample; + +public interface IService { } + +[RegisterTransient(ServiceType = typeof(IService), ServiceKey = ""key"", Factory = nameof(ServiceFactory))] +public class MyService : IService +{ + public static IService ServiceFactory(IServiceProvider serviceProvider, object? serviceKey) + { + return new MyService(); + } +} +"; + + var diagnostics = GetDiagnostics(source); + + diagnostics.Should().BeEmpty(); + } + + [Fact] + public void NoDiagnosticsForValidRegisterServicesMethod() + { + var source = @" +using Injectio.Attributes; +using Microsoft.Extensions.DependencyInjection; + +namespace Injectio.Sample; + +public static class RegistrationModule +{ + [RegisterServices] + public static void Register(IServiceCollection services) + { + } +} +"; + + var diagnostics = GetDiagnostics(source); + + diagnostics.Should().BeEmpty(); + } + + [Fact] + public void NoDiagnosticsForValidRegisterServicesWithTags() + { + var source = @" +using System.Collections.Generic; +using Injectio.Attributes; +using Microsoft.Extensions.DependencyInjection; + +namespace Injectio.Sample; + +public static class RegistrationModule +{ + [RegisterServices] + public static void Register(IServiceCollection services, IEnumerable tags) + { + } +} +"; + + var diagnostics = GetDiagnostics(source); + + diagnostics.Should().BeEmpty(); + } + + private static ImmutableArray GetDiagnostics(string source) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); + var references = AppDomain.CurrentDomain.GetAssemblies() + .Where(assembly => !assembly.IsDynamic && !string.IsNullOrWhiteSpace(assembly.Location)) + .Select(assembly => MetadataReference.CreateFromFile(assembly.Location)) + .Concat(new[] + { + MetadataReference.CreateFromFile(typeof(ServiceRegistrationGenerator).Assembly.Location), + MetadataReference.CreateFromFile(typeof(RegisterServicesAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(IServiceCollection).Assembly.Location), + }); + + var compilation = CSharpCompilation.Create( + "Test.Diagnostics", + new[] { syntaxTree }, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var generator = new ServiceRegistrationGenerator(); + var driver = CSharpGeneratorDriver.Create(generator); + driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out var diagnostics); + + // return only Injectio diagnostics (INJECT*) + return diagnostics + .Where(d => d.Id.StartsWith("INJECT")) + .ToImmutableArray(); + } +} From a93ff752a98534c73565bc17d3a0c9459044e0e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 20:35:44 +0000 Subject: [PATCH 2/7] Move diagnostics from source generator to separate DiagnosticAnalyzer Reporting diagnostics inside the source generator prevents pipeline caching from working optimally. Move all validation into a dedicated ServiceRegistrationAnalyzer (DiagnosticAnalyzer) that runs independently, keeping the generator focused on pure code generation. - Add ServiceRegistrationAnalyzer with RegisterSymbolAction for methods and named types - Revert ServiceRegistrationGenerator to original (no diagnostic logic) - Remove DiagnosticInfo.cs (no longer needed without generator-side serialization) - Revert ServiceRegistrationContext (remove Diagnostics property) - Update tests to use CompilationWithAnalyzers https://claude.ai/code/session_01FpKv78qmKTTnNZAGyXxGwv --- src/Injectio.Generators/DiagnosticInfo.cs | 16 - .../ServiceRegistrationAnalyzer.cs | 481 ++++++++++++++++++ .../ServiceRegistrationContext.cs | 3 +- .../ServiceRegistrationGenerator.cs | 300 +---------- .../ServiceRegistrationDiagnosticTests.cs | 68 +-- 5 files changed, 538 insertions(+), 330 deletions(-) delete mode 100644 src/Injectio.Generators/DiagnosticInfo.cs create mode 100644 src/Injectio.Generators/ServiceRegistrationAnalyzer.cs diff --git a/src/Injectio.Generators/DiagnosticInfo.cs b/src/Injectio.Generators/DiagnosticInfo.cs deleted file mode 100644 index f47e1b8..0000000 --- a/src/Injectio.Generators/DiagnosticInfo.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Text; - -namespace Injectio.Generators; - -/// -/// Serializable diagnostic information for pipeline caching. -/// Captures all data needed to report a diagnostic without holding Roslyn symbols. -/// -public record DiagnosticInfo( - string Id, - string FilePath, - TextSpan TextSpan, - LinePositionSpan LineSpan, - EquatableArray MessageArguments -); diff --git a/src/Injectio.Generators/ServiceRegistrationAnalyzer.cs b/src/Injectio.Generators/ServiceRegistrationAnalyzer.cs new file mode 100644 index 0000000..0f796b1 --- /dev/null +++ b/src/Injectio.Generators/ServiceRegistrationAnalyzer.cs @@ -0,0 +1,481 @@ +using System.Collections.Immutable; + +using Injectio.Generators.Extensions; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Injectio.Generators; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class ServiceRegistrationAnalyzer : DiagnosticAnalyzer +{ + private static readonly SymbolDisplayFormat _fullyQualifiedNullableFormat = + SymbolDisplayFormat.FullyQualifiedFormat.AddMiscellaneousOptions( + SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier + ); + + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create( + DiagnosticDescriptors.InvalidMethodSignature, + DiagnosticDescriptors.InvalidMethodSecondParameter, + DiagnosticDescriptors.MethodTooManyParameters, + DiagnosticDescriptors.FactoryMethodNotFound, + DiagnosticDescriptors.FactoryMethodNotStatic, + DiagnosticDescriptors.FactoryMethodInvalidSignature, + DiagnosticDescriptors.ServiceTypeMismatch, + DiagnosticDescriptors.AbstractImplementationType, + DiagnosticDescriptors.RegisterServicesMethodOnAbstractClass); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSymbolAction(AnalyzeMethod, SymbolKind.Method); + context.RegisterSymbolAction(AnalyzeNamedType, SymbolKind.NamedType); + } + + private static void AnalyzeMethod(SymbolAnalysisContext context) + { + if (context.Symbol is not IMethodSymbol methodSymbol) + return; + + var attributes = methodSymbol.GetAttributes(); + var isKnown = false; + + foreach (var attribute in attributes) + { + if (IsMethodAttribute(attribute)) + { + isKnown = true; + break; + } + } + + if (!isKnown) + return; + + var location = methodSymbol.Locations.Length > 0 + ? methodSymbol.Locations[0] + : Location.None; + + // warn if non-static method on abstract class + if (!methodSymbol.IsStatic && methodSymbol.ContainingType.IsAbstract) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.RegisterServicesMethodOnAbstractClass, + location, + methodSymbol.Name, + methodSymbol.ContainingType.ToDisplayString(_fullyQualifiedNullableFormat))); + } + + ValidateMethod(context, methodSymbol, location); + } + + private static void ValidateMethod(SymbolAnalysisContext context, IMethodSymbol methodSymbol, Location location) + { + if (methodSymbol.Parameters.Length > 2) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.MethodTooManyParameters, + location, + methodSymbol.Name, + methodSymbol.Parameters.Length.ToString())); + return; + } + + if (methodSymbol.Parameters.Length == 0) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.InvalidMethodSignature, + location, + methodSymbol.Name)); + return; + } + + var hasServiceCollection = IsServiceCollection(methodSymbol.Parameters[0]); + + if (!hasServiceCollection) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.InvalidMethodSignature, + location, + methodSymbol.Name)); + return; + } + + if (methodSymbol.Parameters.Length == 2) + { + var hasTagCollection = IsStringCollection(methodSymbol.Parameters[1]); + + if (!hasTagCollection) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.InvalidMethodSecondParameter, + location, + methodSymbol.Name)); + } + } + } + + private static void AnalyzeNamedType(SymbolAnalysisContext context) + { + if (context.Symbol is not INamedTypeSymbol classSymbol) + return; + + if (classSymbol.IsAbstract || classSymbol.IsStatic) + return; + + var attributes = classSymbol.GetAttributes(); + + foreach (var attribute in attributes) + { + if (!IsKnownAttribute(attribute, out _)) + continue; + + var location = classSymbol.Locations.Length > 0 + ? classSymbol.Locations[0] + : Location.None; + + AnalyzeRegistrationAttribute(context, classSymbol, attribute, location); + } + } + + private static void AnalyzeRegistrationAttribute( + SymbolAnalysisContext context, + INamedTypeSymbol classSymbol, + AttributeData attribute, + Location location) + { + var serviceTypes = new HashSet(); + string? implementationType = null; + string? implementationFactory = null; + string? registrationStrategy = null; + + var attributeClass = attribute.AttributeClass; + if (attributeClass is { IsGenericType: true } && attributeClass.TypeArguments.Length == attributeClass.TypeParameters.Length) + { + for (var index = 0; index < attributeClass.TypeParameters.Length; index++) + { + var typeParameter = attributeClass.TypeParameters[index]; + var typeArgument = attributeClass.TypeArguments[index]; + + if (typeParameter.Name == "TService" || index == 0) + { + serviceTypes.Add(typeArgument.ToDisplayString(_fullyQualifiedNullableFormat)); + } + else if (typeParameter.Name == "TImplementation" || index == 1) + { + implementationType = typeArgument.ToDisplayString(_fullyQualifiedNullableFormat); + } + } + } + + foreach (var parameter in attribute.NamedArguments) + { + var name = parameter.Key; + var value = parameter.Value.Value; + + if (string.IsNullOrEmpty(name) || value == null) + continue; + + switch (name) + { + case "ServiceType": + var serviceTypeSymbol = value as INamedTypeSymbol; + var serviceType = serviceTypeSymbol?.ToDisplayString(_fullyQualifiedNullableFormat) ?? value.ToString(); + serviceTypes.Add(serviceType); + break; + case "ImplementationType": + var implSymbol = value as INamedTypeSymbol; + implementationType = implSymbol?.ToDisplayString(_fullyQualifiedNullableFormat) ?? value.ToString(); + break; + case "Factory": + implementationFactory = value.ToString(); + break; + case "Registration": + registrationStrategy = ResolveRegistrationStrategy(value); + break; + } + } + + // resolve effective implementation type + var implTypeName = implementationType.IsNullOrWhiteSpace() + ? classSymbol.ToDisplayString(_fullyQualifiedNullableFormat) + : implementationType!; + + // determine effective registration strategy + if (registrationStrategy == null && implementationType == null && serviceTypes.Count == 0) + registrationStrategy = KnownTypes.RegistrationStrategySelfWithProxyFactoryShortName; + + // add interface-based service types for validation + bool includeInterfaces = registrationStrategy is KnownTypes.RegistrationStrategyImplementedInterfacesShortName + or KnownTypes.RegistrationStrategySelfWithInterfacesShortName + or KnownTypes.RegistrationStrategySelfWithProxyFactoryShortName; + + if (includeInterfaces) + { + foreach (var iface in classSymbol.AllInterfaces) + { + if (iface.ConstructedFrom.ToString() == "System.IEquatable") + continue; + + serviceTypes.Add(iface.ToDisplayString(_fullyQualifiedNullableFormat)); + } + } + + bool includeSelf = registrationStrategy is KnownTypes.RegistrationStrategySelfShortName + or KnownTypes.RegistrationStrategySelfWithInterfacesShortName + or KnownTypes.RegistrationStrategySelfWithProxyFactoryShortName; + + if (includeSelf || serviceTypes.Count == 0) + serviceTypes.Add(implTypeName); + + // validate abstract implementation type without factory + if (classSymbol.IsAbstract && implementationFactory.IsNullOrWhiteSpace() && implTypeName == classSymbol.ToDisplayString(_fullyQualifiedNullableFormat)) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.AbstractImplementationType, + location, + implTypeName)); + } + + // validate factory method + if (implementationFactory.HasValue()) + { + ValidateFactoryMethod(context, classSymbol, implementationFactory!, location); + } + + // validate service type assignability (only for explicitly specified service types) + ValidateServiceTypes(context, classSymbol, serviceTypes, location); + } + + private static void ValidateFactoryMethod( + SymbolAnalysisContext context, + INamedTypeSymbol classSymbol, + string factoryMethodName, + Location location) + { + var className = classSymbol.ToDisplayString(_fullyQualifiedNullableFormat); + var members = classSymbol.GetMembers(factoryMethodName); + var factoryMethods = new List(); + + foreach (var member in members) + { + if (member is IMethodSymbol method) + factoryMethods.Add(method); + } + + if (factoryMethods.Count == 0) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.FactoryMethodNotFound, + location, + factoryMethodName, + className)); + return; + } + + foreach (var method in factoryMethods) + { + if (!method.IsStatic) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.FactoryMethodNotStatic, + location, + factoryMethodName, + className)); + return; + } + + if (method.Parameters.Length is not (1 or 2)) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.FactoryMethodInvalidSignature, + location, + factoryMethodName, + className)); + return; + } + + if (!IsServiceProvider(method.Parameters[0])) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.FactoryMethodInvalidSignature, + location, + factoryMethodName, + className)); + } + } + } + + private static void ValidateServiceTypes( + SymbolAnalysisContext context, + INamedTypeSymbol classSymbol, + HashSet serviceTypes, + Location location) + { + var implTypeName = classSymbol.ToDisplayString(_fullyQualifiedNullableFormat); + + foreach (var serviceType in serviceTypes) + { + if (serviceType == implTypeName) + continue; + + var implementsService = false; + + foreach (var iface in classSymbol.AllInterfaces) + { + var ifaceName = iface.ToDisplayString(_fullyQualifiedNullableFormat); + if (ifaceName == serviceType) + { + implementsService = true; + break; + } + } + + if (!implementsService) + { + var baseType = classSymbol.BaseType; + while (baseType is not null) + { + var baseName = baseType.ToDisplayString(_fullyQualifiedNullableFormat); + if (baseName == serviceType) + { + implementsService = true; + break; + } + baseType = baseType.BaseType; + } + } + + if (!implementsService) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.ServiceTypeMismatch, + location, + implTypeName, + serviceType)); + } + } + } + + private static bool IsMethodAttribute(AttributeData attribute) + { + return attribute?.AttributeClass is + { + Name: KnownTypes.ModuleAttributeShortName or KnownTypes.ModuleAttributeTypeName, + ContainingNamespace: + { + Name: "Attributes", + ContainingNamespace.Name: "Injectio" + } + }; + } + + private static bool IsKnownAttribute(AttributeData attribute, out string serviceLifetime) + { + if (attribute?.AttributeClass is + { + Name: KnownTypes.SingletonAttributeShortName or KnownTypes.SingletonAttributeTypeName, + ContainingNamespace: { Name: "Attributes", ContainingNamespace.Name: "Injectio" } + }) + { + serviceLifetime = KnownTypes.ServiceLifetimeSingletonFullName; + return true; + } + + if (attribute?.AttributeClass is + { + Name: KnownTypes.ScopedAttributeShortName or KnownTypes.ScopedAttributeTypeName, + ContainingNamespace: { Name: "Attributes", ContainingNamespace.Name: "Injectio" } + }) + { + serviceLifetime = KnownTypes.ServiceLifetimeScopedFullName; + return true; + } + + if (attribute?.AttributeClass is + { + Name: KnownTypes.TransientAttributeShortName or KnownTypes.TransientAttributeTypeName, + ContainingNamespace: { Name: "Attributes", ContainingNamespace.Name: "Injectio" } + }) + { + serviceLifetime = KnownTypes.ServiceLifetimeTransientFullName; + return true; + } + + serviceLifetime = KnownTypes.ServiceLifetimeTransientFullName; + return false; + } + + private static bool IsServiceCollection(IParameterSymbol parameterSymbol) + { + return parameterSymbol?.Type is + { + Name: "IServiceCollection" or "ServiceCollection", + ContainingNamespace: + { + Name: "DependencyInjection", + ContainingNamespace: + { + Name: "Extensions", + ContainingNamespace.Name: "Microsoft" + } + } + }; + } + + private static bool IsStringCollection(IParameterSymbol parameterSymbol) + { + var type = parameterSymbol?.Type as INamedTypeSymbol; + + return type is + { + Name: "IEnumerable" or "IReadOnlySet" or "IReadOnlyCollection" or "ICollection" or "ISet" or "HashSet", + IsGenericType: true, + TypeArguments.Length: 1, + TypeParameters.Length: 1, + ContainingNamespace: + { + Name: "Generic", + ContainingNamespace: + { + Name: "Collections", + ContainingNamespace.Name: "System" + } + } + }; + } + + private static bool IsServiceProvider(IParameterSymbol parameterSymbol) + { + return parameterSymbol?.Type is + { + Name: "IServiceProvider", + ContainingNamespace: + { + Name: "System", + ContainingNamespace.IsGlobalNamespace: true + } + }; + } + + private static string ResolveRegistrationStrategy(object? value) + { + return value switch + { + int v => v switch + { + KnownTypes.RegistrationStrategySelfValue => KnownTypes.RegistrationStrategySelfShortName, + KnownTypes.RegistrationStrategyImplementedInterfacesValue => KnownTypes.RegistrationStrategyImplementedInterfacesShortName, + KnownTypes.RegistrationStrategySelfWithInterfacesValue => KnownTypes.RegistrationStrategySelfWithInterfacesShortName, + KnownTypes.RegistrationStrategySelfWithProxyFactoryValue => KnownTypes.RegistrationStrategySelfWithProxyFactoryShortName, + _ => KnownTypes.RegistrationStrategySelfWithProxyFactoryShortName + }, + string text => text, + _ => KnownTypes.RegistrationStrategySelfWithProxyFactoryShortName + }; + } +} diff --git a/src/Injectio.Generators/ServiceRegistrationContext.cs b/src/Injectio.Generators/ServiceRegistrationContext.cs index 34a8df2..2451cce 100644 --- a/src/Injectio.Generators/ServiceRegistrationContext.cs +++ b/src/Injectio.Generators/ServiceRegistrationContext.cs @@ -6,6 +6,5 @@ namespace Injectio.Generators; public record ServiceRegistrationContext( EquatableArray? ServiceRegistrations = null, - EquatableArray? ModuleRegistrations = null, - EquatableArray? Diagnostics = null + EquatableArray? ModuleRegistrations = null ); diff --git a/src/Injectio.Generators/ServiceRegistrationGenerator.cs b/src/Injectio.Generators/ServiceRegistrationGenerator.cs index 106f738..68b3c08 100644 --- a/src/Injectio.Generators/ServiceRegistrationGenerator.cs +++ b/src/Injectio.Generators/ServiceRegistrationGenerator.cs @@ -29,9 +29,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) ) .Where(static context => context is not null - && (context.ServiceRegistrations?.Count > 0 - || context.ModuleRegistrations?.Count > 0 - || context.Diagnostics?.Count > 0) + && (context.ServiceRegistrations?.Count > 0 || context.ModuleRegistrations?.Count > 0) ) .Collect() .WithTrackingName("Registrations"); @@ -61,29 +59,6 @@ private void ExecuteGeneration( SourceProductionContext sourceContext, (ImmutableArray Registrations, (string? AssemblyName, MethodOptions? MethodOptions) Options) source) { - // report all collected diagnostics - foreach (var context in source.Registrations) - { - if (context?.Diagnostics is null) - continue; - - foreach (var diagnosticInfo in context.Diagnostics) - { - var descriptor = GetDescriptorById(diagnosticInfo.Id); - var location = Location.Create( - diagnosticInfo.FilePath, - diagnosticInfo.TextSpan, - diagnosticInfo.LineSpan); - - var diagnostic = Diagnostic.Create( - descriptor, - location, - diagnosticInfo.MessageArguments.AsArray()); - - sourceContext.ReportDiagnostic(diagnostic); - } - } - var serviceRegistrations = source.Registrations .SelectMany(m => m?.ServiceRegistrations ?? Array.Empty()) .Where(m => m is not null) @@ -158,21 +133,9 @@ private static bool SyntacticPredicate(SyntaxNode syntaxNode, CancellationToken if (!isKnown) return null; - var diagnostics = new List(); - - // warn if non-static method on abstract class (can't instantiate to call it) - if (!methodSymbol.IsStatic && methodSymbol.ContainingType.IsAbstract) - { - diagnostics.Add(CreateDiagnosticInfo( - DiagnosticDescriptors.RegisterServicesMethodOnAbstractClass, - methodDeclaration.Identifier.GetLocation(), - methodSymbol.Name, - methodSymbol.ContainingType.ToDisplayString(_fullyQualifiedNullableFormat))); - } - - var (isValid, hasTagCollection) = ValidateMethod(methodSymbol, methodDeclaration, diagnostics); + var (isValid, hasTagCollection) = ValidateMethod(methodSymbol); if (!isValid) - return new ServiceRegistrationContext(Diagnostics: diagnostics.ToArray()); + return null; var registration = new ModuleRegistration ( @@ -182,9 +145,7 @@ private static bool SyntacticPredicate(SyntaxNode syntaxNode, CancellationToken HasTagCollection: hasTagCollection ); - return new ServiceRegistrationContext( - ModuleRegistrations: new[] { registration }, - Diagnostics: diagnostics.Count > 0 ? diagnostics.ToArray() : null); + return new ServiceRegistrationContext(ModuleRegistrations: new[] { registration }); } private static ServiceRegistrationContext? SemanticTransformClass(GeneratorSyntaxContext context) @@ -200,93 +161,50 @@ private static bool SyntacticPredicate(SyntaxNode syntaxNode, CancellationToken // support multiple register attributes on a class var registrations = new List(); - var diagnostics = new List(); foreach (var attribute in attributes) { - var registration = CreateServiceRegistration(classSymbol, attribute, declaration, diagnostics); + var registration = CreateServiceRegistration(classSymbol, attribute); if (registration is not null) registrations.Add(registration); } - if (registrations.Count == 0 && diagnostics.Count == 0) + if (registrations.Count == 0) return null; - return new ServiceRegistrationContext( - ServiceRegistrations: registrations.Count > 0 ? registrations.ToArray() : null, - Diagnostics: diagnostics.Count > 0 ? diagnostics.ToArray() : null); + return new ServiceRegistrationContext(ServiceRegistrations: registrations.ToArray()); } - private static (bool isValid, bool hasTagCollection) ValidateMethod( - IMethodSymbol methodSymbol, - MethodDeclarationSyntax methodDeclaration, - List diagnostics) + private static (bool isValid, bool hasTagCollection) ValidateMethod(IMethodSymbol methodSymbol) { - // too many parameters - if (methodSymbol.Parameters.Length > 2) - { - diagnostics.Add(CreateDiagnosticInfo( - DiagnosticDescriptors.MethodTooManyParameters, - methodDeclaration.Identifier.GetLocation(), - methodSymbol.Name, - methodSymbol.Parameters.Length.ToString())); - - return (false, false); - } - - // no parameters at all - if (methodSymbol.Parameters.Length == 0) - { - diagnostics.Add(CreateDiagnosticInfo( - DiagnosticDescriptors.InvalidMethodSignature, - methodDeclaration.Identifier.GetLocation(), - methodSymbol.Name)); - - return (false, false); - } - var hasServiceCollection = false; var hasTagCollection = false; // validate first parameter should be service collection - var firstParam = methodSymbol.Parameters[0]; - hasServiceCollection = IsServiceCollection(firstParam); - - if (!hasServiceCollection) + if (methodSymbol.Parameters.Length is 1 or 2) { - diagnostics.Add(CreateDiagnosticInfo( - DiagnosticDescriptors.InvalidMethodSignature, - methodDeclaration.Identifier.GetLocation(), - methodSymbol.Name)); - - return (false, false); + var parameterSymbol = methodSymbol.Parameters[0]; + hasServiceCollection = IsServiceCollection(parameterSymbol); } - if (methodSymbol.Parameters.Length == 1) - return (true, false); + if (methodSymbol.Parameters.Length is 1) + return (hasServiceCollection, false); // validate second parameter should be string collection - var secondParam = methodSymbol.Parameters[1]; - hasTagCollection = IsStringCollection(secondParam); - - if (!hasTagCollection) + if (methodSymbol.Parameters.Length is 2) { - diagnostics.Add(CreateDiagnosticInfo( - DiagnosticDescriptors.InvalidMethodSecondParameter, - methodDeclaration.Identifier.GetLocation(), - methodSymbol.Name)); + var parameterSymbol = methodSymbol.Parameters[1]; + hasTagCollection = IsStringCollection(parameterSymbol); - return (false, false); + // to be valid, parameter 0 must be service collection and parameter 1 must be string collection, + return (hasServiceCollection && hasTagCollection, hasTagCollection); } - return (true, hasTagCollection); + // invalid method + return (false, false); } - private static ServiceRegistration? CreateServiceRegistration( - INamedTypeSymbol classSymbol, - AttributeData attribute, - TypeDeclarationSyntax declaration, - List diagnostics) + private static ServiceRegistration? CreateServiceRegistration(INamedTypeSymbol classSymbol, AttributeData attribute) { // check for known attribute if (!IsKnownAttribute(attribute, out var serviceLifetime)) @@ -424,24 +342,6 @@ or KnownTypes.RegistrationStrategySelfWithInterfacesShortName if (registrationStrategy is KnownTypes.RegistrationStrategySelfWithProxyFactoryShortName && isOpenGeneric) registrationStrategy = KnownTypes.RegistrationStrategySelfWithInterfacesShortName; - // validate abstract implementation type without factory - if (classSymbol.IsAbstract && implementationFactory.IsNullOrWhiteSpace() && implementationType == classSymbol.ToDisplayString(_fullyQualifiedNullableFormat)) - { - diagnostics.Add(CreateDiagnosticInfo( - DiagnosticDescriptors.AbstractImplementationType, - declaration.Identifier.GetLocation(), - implementationType!)); - } - - // validate factory method - if (implementationFactory.HasValue()) - { - ValidateFactoryMethod(classSymbol, implementationFactory!, declaration, diagnostics); - } - - // validate service type assignability - ValidateServiceTypes(classSymbol, serviceTypes, declaration, diagnostics); - return new ServiceRegistration( Lifetime: serviceLifetime, ImplementationType: implementationType!, @@ -627,162 +527,4 @@ private static string ResolveRegistrationStrategy(object? value) _ => KnownTypes.RegistrationStrategySelfWithProxyFactoryShortName }; } - - private static void ValidateFactoryMethod( - INamedTypeSymbol classSymbol, - string factoryMethodName, - TypeDeclarationSyntax declaration, - List diagnostics) - { - var className = classSymbol.ToDisplayString(_fullyQualifiedNullableFormat); - - // look for method on the implementation type - var members = classSymbol.GetMembers(factoryMethodName); - var factoryMethods = members.OfType().ToArray(); - - if (factoryMethods.Length == 0) - { - diagnostics.Add(CreateDiagnosticInfo( - DiagnosticDescriptors.FactoryMethodNotFound, - declaration.Identifier.GetLocation(), - factoryMethodName, - className)); - return; - } - - foreach (var method in factoryMethods) - { - if (!method.IsStatic) - { - diagnostics.Add(CreateDiagnosticInfo( - DiagnosticDescriptors.FactoryMethodNotStatic, - declaration.Identifier.GetLocation(), - factoryMethodName, - className)); - return; - } - - // validate signature: (IServiceProvider) or (IServiceProvider, object?) - if (method.Parameters.Length is not (1 or 2)) - { - diagnostics.Add(CreateDiagnosticInfo( - DiagnosticDescriptors.FactoryMethodInvalidSignature, - declaration.Identifier.GetLocation(), - factoryMethodName, - className)); - return; - } - - var firstParam = method.Parameters[0]; - if (!IsServiceProvider(firstParam)) - { - diagnostics.Add(CreateDiagnosticInfo( - DiagnosticDescriptors.FactoryMethodInvalidSignature, - declaration.Identifier.GetLocation(), - factoryMethodName, - className)); - } - } - } - - private static void ValidateServiceTypes( - INamedTypeSymbol classSymbol, - HashSet serviceTypes, - TypeDeclarationSyntax declaration, - List diagnostics) - { - var implTypeName = classSymbol.ToDisplayString(_fullyQualifiedNullableFormat); - - foreach (var serviceType in serviceTypes) - { - // skip self-registration - if (serviceType == implTypeName) - continue; - - // check if the class implements the service type by comparing display strings - var implementsService = false; - - foreach (var iface in classSymbol.AllInterfaces) - { - var unboundInterface = ToUnboundGenericType(iface); - var ifaceName = unboundInterface.ToDisplayString(_fullyQualifiedNullableFormat); - if (ifaceName == serviceType) - { - implementsService = true; - break; - } - } - - if (!implementsService) - { - // also check base types - var baseType = classSymbol.BaseType; - while (baseType is not null) - { - var unboundBase = ToUnboundGenericType(baseType); - var baseName = unboundBase.ToDisplayString(_fullyQualifiedNullableFormat); - if (baseName == serviceType) - { - implementsService = true; - break; - } - baseType = baseType.BaseType; - } - } - - if (!implementsService) - { - diagnostics.Add(CreateDiagnosticInfo( - DiagnosticDescriptors.ServiceTypeMismatch, - declaration.Identifier.GetLocation(), - implTypeName, - serviceType)); - } - } - } - - private static bool IsServiceProvider(IParameterSymbol parameterSymbol) - { - return parameterSymbol?.Type is - { - Name: "IServiceProvider", - ContainingNamespace: - { - Name: "System", - ContainingNamespace.IsGlobalNamespace: true - } - }; - } - - private static DiagnosticInfo CreateDiagnosticInfo( - DiagnosticDescriptor descriptor, - Location location, - params string[] messageArgs) - { - var lineSpan = location.GetLineSpan(); - - return new DiagnosticInfo( - Id: descriptor.Id, - FilePath: lineSpan.Path ?? string.Empty, - TextSpan: location.SourceSpan, - LineSpan: lineSpan.Span, - MessageArguments: messageArgs); - } - - internal static DiagnosticDescriptor GetDescriptorById(string id) - { - return id switch - { - "INJECT0001" => DiagnosticDescriptors.InvalidMethodSignature, - "INJECT0002" => DiagnosticDescriptors.InvalidMethodSecondParameter, - "INJECT0003" => DiagnosticDescriptors.MethodTooManyParameters, - "INJECT0004" => DiagnosticDescriptors.FactoryMethodNotFound, - "INJECT0005" => DiagnosticDescriptors.FactoryMethodNotStatic, - "INJECT0006" => DiagnosticDescriptors.FactoryMethodInvalidSignature, - "INJECT0007" => DiagnosticDescriptors.ServiceTypeMismatch, - "INJECT0008" => DiagnosticDescriptors.AbstractImplementationType, - "INJECT0009" => DiagnosticDescriptors.RegisterServicesMethodOnAbstractClass, - _ => throw new ArgumentException($"Unknown diagnostic ID: {id}") - }; - } } diff --git a/tests/Injectio.Tests/ServiceRegistrationDiagnosticTests.cs b/tests/Injectio.Tests/ServiceRegistrationDiagnosticTests.cs index bb65c98..ee1250d 100644 --- a/tests/Injectio.Tests/ServiceRegistrationDiagnosticTests.cs +++ b/tests/Injectio.Tests/ServiceRegistrationDiagnosticTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Immutable; using System.Linq; +using System.Threading.Tasks; using AwesomeAssertions; @@ -9,6 +10,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -18,7 +20,7 @@ namespace Injectio.Tests; public class ServiceRegistrationDiagnosticTests { [Fact] - public void DiagnoseRegisterServicesInvalidFirstParameter() + public async Task DiagnoseRegisterServicesInvalidFirstParameter() { var source = @" using Injectio.Attributes; @@ -34,13 +36,13 @@ public static void Register(string test) } "; - var diagnostics = GetDiagnostics(source); + var diagnostics = await GetDiagnosticsAsync(source); diagnostics.Should().ContainSingle(d => d.Id == "INJECT0001"); } [Fact] - public void DiagnoseRegisterServicesInvalidSecondParameter() + public async Task DiagnoseRegisterServicesInvalidSecondParameter() { var source = @" using Injectio.Attributes; @@ -57,13 +59,13 @@ public static void Register(IServiceCollection services, string test) } "; - var diagnostics = GetDiagnostics(source); + var diagnostics = await GetDiagnosticsAsync(source); diagnostics.Should().ContainSingle(d => d.Id == "INJECT0002"); } [Fact] - public void DiagnoseRegisterServicesTooManyParameters() + public async Task DiagnoseRegisterServicesTooManyParameters() { var source = @" using Injectio.Attributes; @@ -80,13 +82,13 @@ public static void Register(IServiceCollection services, string a, string b) } "; - var diagnostics = GetDiagnostics(source); + var diagnostics = await GetDiagnosticsAsync(source); diagnostics.Should().ContainSingle(d => d.Id == "INJECT0003"); } [Fact] - public void DiagnoseRegisterServicesNoParameters() + public async Task DiagnoseRegisterServicesNoParameters() { var source = @" using Injectio.Attributes; @@ -102,13 +104,13 @@ public static void Register() } "; - var diagnostics = GetDiagnostics(source); + var diagnostics = await GetDiagnosticsAsync(source); diagnostics.Should().ContainSingle(d => d.Id == "INJECT0001"); } [Fact] - public void DiagnoseFactoryMethodNotFound() + public async Task DiagnoseFactoryMethodNotFound() { var source = @" using Injectio.Attributes; @@ -123,13 +125,13 @@ public class MyService : IService } "; - var diagnostics = GetDiagnostics(source); + var diagnostics = await GetDiagnosticsAsync(source); diagnostics.Should().ContainSingle(d => d.Id == "INJECT0004"); } [Fact] - public void DiagnoseFactoryMethodNotStatic() + public async Task DiagnoseFactoryMethodNotStatic() { var source = @" using System; @@ -149,13 +151,13 @@ public IService ServiceFactory(IServiceProvider serviceProvider) } "; - var diagnostics = GetDiagnostics(source); + var diagnostics = await GetDiagnosticsAsync(source); diagnostics.Should().ContainSingle(d => d.Id == "INJECT0005"); } [Fact] - public void DiagnoseFactoryMethodInvalidSignature() + public async Task DiagnoseFactoryMethodInvalidSignature() { var source = @" using Injectio.Attributes; @@ -174,13 +176,13 @@ public static IService ServiceFactory(string notServiceProvider) } "; - var diagnostics = GetDiagnostics(source); + var diagnostics = await GetDiagnosticsAsync(source); diagnostics.Should().ContainSingle(d => d.Id == "INJECT0006"); } [Fact] - public void DiagnoseServiceTypeMismatch() + public async Task DiagnoseServiceTypeMismatch() { var source = @" using Injectio.Attributes; @@ -196,13 +198,13 @@ public class MyService : IService } "; - var diagnostics = GetDiagnostics(source); + var diagnostics = await GetDiagnosticsAsync(source); diagnostics.Should().ContainSingle(d => d.Id == "INJECT0007"); } [Fact] - public void DiagnoseRegisterServicesOnAbstractClassNonStaticMethod() + public async Task DiagnoseRegisterServicesOnAbstractClassNonStaticMethod() { var source = @" using Injectio.Attributes; @@ -219,13 +221,13 @@ public void Register(IServiceCollection services) } "; - var diagnostics = GetDiagnostics(source); + var diagnostics = await GetDiagnosticsAsync(source); diagnostics.Should().ContainSingle(d => d.Id == "INJECT0009"); } [Fact] - public void NoDiagnosticsForValidRegistration() + public async Task NoDiagnosticsForValidRegistration() { var source = @" using Injectio.Attributes; @@ -240,13 +242,13 @@ public class MyService : IService } "; - var diagnostics = GetDiagnostics(source); + var diagnostics = await GetDiagnosticsAsync(source); diagnostics.Should().BeEmpty(); } [Fact] - public void NoDiagnosticsForValidFactory() + public async Task NoDiagnosticsForValidFactory() { var source = @" using System; @@ -266,13 +268,13 @@ public static IService ServiceFactory(IServiceProvider serviceProvider) } "; - var diagnostics = GetDiagnostics(source); + var diagnostics = await GetDiagnosticsAsync(source); diagnostics.Should().BeEmpty(); } [Fact] - public void NoDiagnosticsForValidKeyedFactory() + public async Task NoDiagnosticsForValidKeyedFactory() { var source = @" using System; @@ -292,13 +294,13 @@ public static IService ServiceFactory(IServiceProvider serviceProvider, object? } "; - var diagnostics = GetDiagnostics(source); + var diagnostics = await GetDiagnosticsAsync(source); diagnostics.Should().BeEmpty(); } [Fact] - public void NoDiagnosticsForValidRegisterServicesMethod() + public async Task NoDiagnosticsForValidRegisterServicesMethod() { var source = @" using Injectio.Attributes; @@ -315,13 +317,13 @@ public static void Register(IServiceCollection services) } "; - var diagnostics = GetDiagnostics(source); + var diagnostics = await GetDiagnosticsAsync(source); diagnostics.Should().BeEmpty(); } [Fact] - public void NoDiagnosticsForValidRegisterServicesWithTags() + public async Task NoDiagnosticsForValidRegisterServicesWithTags() { var source = @" using System.Collections.Generic; @@ -339,12 +341,12 @@ public static void Register(IServiceCollection services, IEnumerable tag } "; - var diagnostics = GetDiagnostics(source); + var diagnostics = await GetDiagnosticsAsync(source); diagnostics.Should().BeEmpty(); } - private static ImmutableArray GetDiagnostics(string source) + private static async Task> GetDiagnosticsAsync(string source) { var syntaxTree = CSharpSyntaxTree.ParseText(source); var references = AppDomain.CurrentDomain.GetAssemblies() @@ -363,11 +365,11 @@ private static ImmutableArray GetDiagnostics(string source) references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - var generator = new ServiceRegistrationGenerator(); - var driver = CSharpGeneratorDriver.Create(generator); - driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out var diagnostics); + var analyzer = new ServiceRegistrationAnalyzer(); + var compilationWithAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer)); + var diagnostics = await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); - // return only Injectio diagnostics (INJECT*) + // return only Injectio diagnostics return diagnostics .Where(d => d.Id.StartsWith("INJECT")) .ToImmutableArray(); From 17ac25e0a5c293e7034e4cf6108e12f04fe769a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 00:21:23 +0000 Subject: [PATCH 3/7] Extract shared symbol helpers into SymbolHelpers class Move duplicated helper methods (IsMethodAttribute, IsKnownAttribute, IsServiceCollection, IsStringCollection, IsServiceProvider, ResolveRegistrationStrategy, FullyQualifiedNullableFormat) into a shared SymbolHelpers class used by both the generator and analyzer. https://claude.ai/code/session_01FpKv78qmKTTnNZAGyXxGwv --- .../ServiceRegistrationAnalyzer.cs | 161 +++--------------- .../ServiceRegistrationGenerator.cs | 161 ++---------------- src/Injectio.Generators/SymbolHelpers.cs | 156 +++++++++++++++++ 3 files changed, 187 insertions(+), 291 deletions(-) create mode 100644 src/Injectio.Generators/SymbolHelpers.cs diff --git a/src/Injectio.Generators/ServiceRegistrationAnalyzer.cs b/src/Injectio.Generators/ServiceRegistrationAnalyzer.cs index 0f796b1..6797494 100644 --- a/src/Injectio.Generators/ServiceRegistrationAnalyzer.cs +++ b/src/Injectio.Generators/ServiceRegistrationAnalyzer.cs @@ -10,11 +10,6 @@ namespace Injectio.Generators; [DiagnosticAnalyzer(LanguageNames.CSharp)] public class ServiceRegistrationAnalyzer : DiagnosticAnalyzer { - private static readonly SymbolDisplayFormat _fullyQualifiedNullableFormat = - SymbolDisplayFormat.FullyQualifiedFormat.AddMiscellaneousOptions( - SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier - ); - public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create( DiagnosticDescriptors.InvalidMethodSignature, @@ -46,7 +41,7 @@ private static void AnalyzeMethod(SymbolAnalysisContext context) foreach (var attribute in attributes) { - if (IsMethodAttribute(attribute)) + if (SymbolHelpers.IsMethodAttribute(attribute)) { isKnown = true; break; @@ -67,7 +62,7 @@ private static void AnalyzeMethod(SymbolAnalysisContext context) DiagnosticDescriptors.RegisterServicesMethodOnAbstractClass, location, methodSymbol.Name, - methodSymbol.ContainingType.ToDisplayString(_fullyQualifiedNullableFormat))); + methodSymbol.ContainingType.ToDisplayString(SymbolHelpers.FullyQualifiedNullableFormat))); } ValidateMethod(context, methodSymbol, location); @@ -94,7 +89,7 @@ private static void ValidateMethod(SymbolAnalysisContext context, IMethodSymbol return; } - var hasServiceCollection = IsServiceCollection(methodSymbol.Parameters[0]); + var hasServiceCollection = SymbolHelpers.IsServiceCollection(methodSymbol.Parameters[0]); if (!hasServiceCollection) { @@ -107,7 +102,7 @@ private static void ValidateMethod(SymbolAnalysisContext context, IMethodSymbol if (methodSymbol.Parameters.Length == 2) { - var hasTagCollection = IsStringCollection(methodSymbol.Parameters[1]); + var hasTagCollection = SymbolHelpers.IsStringCollection(methodSymbol.Parameters[1]); if (!hasTagCollection) { @@ -131,7 +126,7 @@ private static void AnalyzeNamedType(SymbolAnalysisContext context) foreach (var attribute in attributes) { - if (!IsKnownAttribute(attribute, out _)) + if (!SymbolHelpers.IsKnownAttribute(attribute, out _)) continue; var location = classSymbol.Locations.Length > 0 @@ -163,11 +158,11 @@ private static void AnalyzeRegistrationAttribute( if (typeParameter.Name == "TService" || index == 0) { - serviceTypes.Add(typeArgument.ToDisplayString(_fullyQualifiedNullableFormat)); + serviceTypes.Add(typeArgument.ToDisplayString(SymbolHelpers.FullyQualifiedNullableFormat)); } else if (typeParameter.Name == "TImplementation" || index == 1) { - implementationType = typeArgument.ToDisplayString(_fullyQualifiedNullableFormat); + implementationType = typeArgument.ToDisplayString(SymbolHelpers.FullyQualifiedNullableFormat); } } } @@ -184,25 +179,25 @@ private static void AnalyzeRegistrationAttribute( { case "ServiceType": var serviceTypeSymbol = value as INamedTypeSymbol; - var serviceType = serviceTypeSymbol?.ToDisplayString(_fullyQualifiedNullableFormat) ?? value.ToString(); + var serviceType = serviceTypeSymbol?.ToDisplayString(SymbolHelpers.FullyQualifiedNullableFormat) ?? value.ToString(); serviceTypes.Add(serviceType); break; case "ImplementationType": var implSymbol = value as INamedTypeSymbol; - implementationType = implSymbol?.ToDisplayString(_fullyQualifiedNullableFormat) ?? value.ToString(); + implementationType = implSymbol?.ToDisplayString(SymbolHelpers.FullyQualifiedNullableFormat) ?? value.ToString(); break; case "Factory": implementationFactory = value.ToString(); break; case "Registration": - registrationStrategy = ResolveRegistrationStrategy(value); + registrationStrategy = SymbolHelpers.ResolveRegistrationStrategy(value); break; } } // resolve effective implementation type var implTypeName = implementationType.IsNullOrWhiteSpace() - ? classSymbol.ToDisplayString(_fullyQualifiedNullableFormat) + ? classSymbol.ToDisplayString(SymbolHelpers.FullyQualifiedNullableFormat) : implementationType!; // determine effective registration strategy @@ -221,7 +216,7 @@ or KnownTypes.RegistrationStrategySelfWithInterfacesShortName if (iface.ConstructedFrom.ToString() == "System.IEquatable") continue; - serviceTypes.Add(iface.ToDisplayString(_fullyQualifiedNullableFormat)); + serviceTypes.Add(iface.ToDisplayString(SymbolHelpers.FullyQualifiedNullableFormat)); } } @@ -233,7 +228,7 @@ or KnownTypes.RegistrationStrategySelfWithInterfacesShortName serviceTypes.Add(implTypeName); // validate abstract implementation type without factory - if (classSymbol.IsAbstract && implementationFactory.IsNullOrWhiteSpace() && implTypeName == classSymbol.ToDisplayString(_fullyQualifiedNullableFormat)) + if (classSymbol.IsAbstract && implementationFactory.IsNullOrWhiteSpace() && implTypeName == classSymbol.ToDisplayString(SymbolHelpers.FullyQualifiedNullableFormat)) { context.ReportDiagnostic(Diagnostic.Create( DiagnosticDescriptors.AbstractImplementationType, @@ -247,7 +242,7 @@ or KnownTypes.RegistrationStrategySelfWithInterfacesShortName ValidateFactoryMethod(context, classSymbol, implementationFactory!, location); } - // validate service type assignability (only for explicitly specified service types) + // validate service type assignability ValidateServiceTypes(context, classSymbol, serviceTypes, location); } @@ -257,7 +252,7 @@ private static void ValidateFactoryMethod( string factoryMethodName, Location location) { - var className = classSymbol.ToDisplayString(_fullyQualifiedNullableFormat); + var className = classSymbol.ToDisplayString(SymbolHelpers.FullyQualifiedNullableFormat); var members = classSymbol.GetMembers(factoryMethodName); var factoryMethods = new List(); @@ -299,7 +294,7 @@ private static void ValidateFactoryMethod( return; } - if (!IsServiceProvider(method.Parameters[0])) + if (!SymbolHelpers.IsServiceProvider(method.Parameters[0])) { context.ReportDiagnostic(Diagnostic.Create( DiagnosticDescriptors.FactoryMethodInvalidSignature, @@ -316,7 +311,7 @@ private static void ValidateServiceTypes( HashSet serviceTypes, Location location) { - var implTypeName = classSymbol.ToDisplayString(_fullyQualifiedNullableFormat); + var implTypeName = classSymbol.ToDisplayString(SymbolHelpers.FullyQualifiedNullableFormat); foreach (var serviceType in serviceTypes) { @@ -327,7 +322,7 @@ private static void ValidateServiceTypes( foreach (var iface in classSymbol.AllInterfaces) { - var ifaceName = iface.ToDisplayString(_fullyQualifiedNullableFormat); + var ifaceName = iface.ToDisplayString(SymbolHelpers.FullyQualifiedNullableFormat); if (ifaceName == serviceType) { implementsService = true; @@ -340,7 +335,7 @@ private static void ValidateServiceTypes( var baseType = classSymbol.BaseType; while (baseType is not null) { - var baseName = baseType.ToDisplayString(_fullyQualifiedNullableFormat); + var baseName = baseType.ToDisplayString(SymbolHelpers.FullyQualifiedNullableFormat); if (baseName == serviceType) { implementsService = true; @@ -360,122 +355,4 @@ private static void ValidateServiceTypes( } } } - - private static bool IsMethodAttribute(AttributeData attribute) - { - return attribute?.AttributeClass is - { - Name: KnownTypes.ModuleAttributeShortName or KnownTypes.ModuleAttributeTypeName, - ContainingNamespace: - { - Name: "Attributes", - ContainingNamespace.Name: "Injectio" - } - }; - } - - private static bool IsKnownAttribute(AttributeData attribute, out string serviceLifetime) - { - if (attribute?.AttributeClass is - { - Name: KnownTypes.SingletonAttributeShortName or KnownTypes.SingletonAttributeTypeName, - ContainingNamespace: { Name: "Attributes", ContainingNamespace.Name: "Injectio" } - }) - { - serviceLifetime = KnownTypes.ServiceLifetimeSingletonFullName; - return true; - } - - if (attribute?.AttributeClass is - { - Name: KnownTypes.ScopedAttributeShortName or KnownTypes.ScopedAttributeTypeName, - ContainingNamespace: { Name: "Attributes", ContainingNamespace.Name: "Injectio" } - }) - { - serviceLifetime = KnownTypes.ServiceLifetimeScopedFullName; - return true; - } - - if (attribute?.AttributeClass is - { - Name: KnownTypes.TransientAttributeShortName or KnownTypes.TransientAttributeTypeName, - ContainingNamespace: { Name: "Attributes", ContainingNamespace.Name: "Injectio" } - }) - { - serviceLifetime = KnownTypes.ServiceLifetimeTransientFullName; - return true; - } - - serviceLifetime = KnownTypes.ServiceLifetimeTransientFullName; - return false; - } - - private static bool IsServiceCollection(IParameterSymbol parameterSymbol) - { - return parameterSymbol?.Type is - { - Name: "IServiceCollection" or "ServiceCollection", - ContainingNamespace: - { - Name: "DependencyInjection", - ContainingNamespace: - { - Name: "Extensions", - ContainingNamespace.Name: "Microsoft" - } - } - }; - } - - private static bool IsStringCollection(IParameterSymbol parameterSymbol) - { - var type = parameterSymbol?.Type as INamedTypeSymbol; - - return type is - { - Name: "IEnumerable" or "IReadOnlySet" or "IReadOnlyCollection" or "ICollection" or "ISet" or "HashSet", - IsGenericType: true, - TypeArguments.Length: 1, - TypeParameters.Length: 1, - ContainingNamespace: - { - Name: "Generic", - ContainingNamespace: - { - Name: "Collections", - ContainingNamespace.Name: "System" - } - } - }; - } - - private static bool IsServiceProvider(IParameterSymbol parameterSymbol) - { - return parameterSymbol?.Type is - { - Name: "IServiceProvider", - ContainingNamespace: - { - Name: "System", - ContainingNamespace.IsGlobalNamespace: true - } - }; - } - - private static string ResolveRegistrationStrategy(object? value) - { - return value switch - { - int v => v switch - { - KnownTypes.RegistrationStrategySelfValue => KnownTypes.RegistrationStrategySelfShortName, - KnownTypes.RegistrationStrategyImplementedInterfacesValue => KnownTypes.RegistrationStrategyImplementedInterfacesShortName, - KnownTypes.RegistrationStrategySelfWithInterfacesValue => KnownTypes.RegistrationStrategySelfWithInterfacesShortName, - KnownTypes.RegistrationStrategySelfWithProxyFactoryValue => KnownTypes.RegistrationStrategySelfWithProxyFactoryShortName, - _ => KnownTypes.RegistrationStrategySelfWithProxyFactoryShortName - }, - string text => text, - _ => KnownTypes.RegistrationStrategySelfWithProxyFactoryShortName - }; - } } diff --git a/src/Injectio.Generators/ServiceRegistrationGenerator.cs b/src/Injectio.Generators/ServiceRegistrationGenerator.cs index 68b3c08..fc96898 100644 --- a/src/Injectio.Generators/ServiceRegistrationGenerator.cs +++ b/src/Injectio.Generators/ServiceRegistrationGenerator.cs @@ -14,11 +14,6 @@ namespace Injectio.Generators; [Generator] public class ServiceRegistrationGenerator : IIncrementalGenerator { - private static readonly SymbolDisplayFormat _fullyQualifiedNullableFormat = - SymbolDisplayFormat.FullyQualifiedFormat.AddMiscellaneousOptions( - SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier - ); - public void Initialize(IncrementalGeneratorInitializationContext context) { // find all classes and methods with attributes @@ -129,7 +124,7 @@ private static bool SyntacticPredicate(SyntaxNode syntaxNode, CancellationToken // make sure attribute is for registration var attributes = methodSymbol.GetAttributes(); - var isKnown = attributes.Any(IsMethodAttribute); + var isKnown = attributes.Any(SymbolHelpers.IsMethodAttribute); if (!isKnown) return null; @@ -139,7 +134,7 @@ private static bool SyntacticPredicate(SyntaxNode syntaxNode, CancellationToken var registration = new ModuleRegistration ( - ClassName: methodSymbol.ContainingType.ToDisplayString(_fullyQualifiedNullableFormat), + ClassName: methodSymbol.ContainingType.ToDisplayString(SymbolHelpers.FullyQualifiedNullableFormat), MethodName: methodSymbol.Name, IsStatic: methodSymbol.IsStatic, HasTagCollection: hasTagCollection @@ -184,7 +179,7 @@ private static (bool isValid, bool hasTagCollection) ValidateMethod(IMethodSymbo if (methodSymbol.Parameters.Length is 1 or 2) { var parameterSymbol = methodSymbol.Parameters[0]; - hasServiceCollection = IsServiceCollection(parameterSymbol); + hasServiceCollection = SymbolHelpers.IsServiceCollection(parameterSymbol); } if (methodSymbol.Parameters.Length is 1) @@ -194,7 +189,7 @@ private static (bool isValid, bool hasTagCollection) ValidateMethod(IMethodSymbo if (methodSymbol.Parameters.Length is 2) { var parameterSymbol = methodSymbol.Parameters[1]; - hasTagCollection = IsStringCollection(parameterSymbol); + hasTagCollection = SymbolHelpers.IsStringCollection(parameterSymbol); // to be valid, parameter 0 must be service collection and parameter 1 must be string collection, return (hasServiceCollection && hasTagCollection, hasTagCollection); @@ -207,7 +202,7 @@ private static (bool isValid, bool hasTagCollection) ValidateMethod(IMethodSymbo private static ServiceRegistration? CreateServiceRegistration(INamedTypeSymbol classSymbol, AttributeData attribute) { // check for known attribute - if (!IsKnownAttribute(attribute, out var serviceLifetime)) + if (!SymbolHelpers.IsKnownAttribute(attribute, out var serviceLifetime)) return null; // defaults @@ -231,12 +226,12 @@ private static (bool isValid, bool hasTagCollection) ValidateMethod(IMethodSymbo if (typeParameter.Name == "TService" || index == 0) { - var service = typeArgument.ToDisplayString(_fullyQualifiedNullableFormat); + var service = typeArgument.ToDisplayString(SymbolHelpers.FullyQualifiedNullableFormat); serviceTypes.Add(service); } else if (typeParameter.Name == "TImplementation" || index == 1) { - implementationType = typeArgument.ToDisplayString(_fullyQualifiedNullableFormat); + implementationType = typeArgument.ToDisplayString(SymbolHelpers.FullyQualifiedNullableFormat); } } } @@ -256,7 +251,7 @@ private static (bool isValid, bool hasTagCollection) ValidateMethod(IMethodSymbo var serviceTypeSymbol = value as INamedTypeSymbol; isOpenGeneric = isOpenGeneric || IsOpenGeneric(serviceTypeSymbol); - var serviceType = serviceTypeSymbol?.ToDisplayString(_fullyQualifiedNullableFormat) ?? value.ToString(); + var serviceType = serviceTypeSymbol?.ToDisplayString(SymbolHelpers.FullyQualifiedNullableFormat) ?? value.ToString(); serviceTypes.Add(serviceType); break; case "ServiceKey": @@ -266,7 +261,7 @@ private static (bool isValid, bool hasTagCollection) ValidateMethod(IMethodSymbo var implementationTypeSymbol = value as INamedTypeSymbol; isOpenGeneric = isOpenGeneric || IsOpenGeneric(implementationTypeSymbol); - implementationType = implementationTypeSymbol?.ToDisplayString(_fullyQualifiedNullableFormat) ?? value.ToString(); + implementationType = implementationTypeSymbol?.ToDisplayString(SymbolHelpers.FullyQualifiedNullableFormat) ?? value.ToString(); break; case "Factory": implementationFactory = value.ToString(); @@ -275,7 +270,7 @@ private static (bool isValid, bool hasTagCollection) ValidateMethod(IMethodSymbo duplicateStrategy = ResolveDuplicateStrategy(value); break; case "Registration": - registrationStrategy = ResolveRegistrationStrategy(value); + registrationStrategy = SymbolHelpers.ResolveRegistrationStrategy(value); break; case "Tags": var tagsItems = value @@ -306,7 +301,7 @@ private static (bool isValid, bool hasTagCollection) ValidateMethod(IMethodSymbo { var unboundType = ToUnboundGenericType(classSymbol); isOpenGeneric = isOpenGeneric || IsOpenGeneric(unboundType); - implementationType = unboundType.ToDisplayString(_fullyQualifiedNullableFormat); + implementationType = unboundType.ToDisplayString(SymbolHelpers.FullyQualifiedNullableFormat); } // add implemented interfaces @@ -324,7 +319,7 @@ or KnownTypes.RegistrationStrategySelfWithInterfacesShortName var unboundInterface = ToUnboundGenericType(implementedInterface); isOpenGeneric = isOpenGeneric || IsOpenGeneric(unboundInterface); - var interfaceName = unboundInterface.ToDisplayString(_fullyQualifiedNullableFormat); + var interfaceName = unboundInterface.ToDisplayString(SymbolHelpers.FullyQualifiedNullableFormat); serviceTypes.Add(interfaceName); } } @@ -369,121 +364,6 @@ private static INamedTypeSymbol ToUnboundGenericType(INamedTypeSymbol typeSymbol return typeSymbol.ConstructUnboundGenericType(); } - private static bool IsKnownAttribute(AttributeData attribute, out string serviceLifetime) - { - if (IsSingletonAttribute(attribute)) - { - serviceLifetime = KnownTypes.ServiceLifetimeSingletonFullName; - return true; - } - - if (IsScopedAttribute(attribute)) - { - serviceLifetime = KnownTypes.ServiceLifetimeScopedFullName; - return true; - } - - if (IsTransientAttribute(attribute)) - { - serviceLifetime = KnownTypes.ServiceLifetimeTransientFullName; - return true; - } - - serviceLifetime = KnownTypes.ServiceLifetimeTransientFullName; - return false; - } - - private static bool IsTransientAttribute(AttributeData attribute) - { - return attribute?.AttributeClass is - { - Name: KnownTypes.TransientAttributeShortName or KnownTypes.TransientAttributeTypeName, - ContainingNamespace: - { - Name: "Attributes", - ContainingNamespace.Name: "Injectio" - } - }; - } - - private static bool IsSingletonAttribute(AttributeData attribute) - { - return attribute?.AttributeClass is - { - Name: KnownTypes.SingletonAttributeShortName or KnownTypes.SingletonAttributeTypeName, - ContainingNamespace: - { - Name: "Attributes", - ContainingNamespace.Name: "Injectio" - } - }; - } - - private static bool IsScopedAttribute(AttributeData attribute) - { - return attribute?.AttributeClass is - { - Name: KnownTypes.ScopedAttributeShortName or KnownTypes.ScopedAttributeTypeName, - ContainingNamespace: - { - Name: "Attributes", - ContainingNamespace.Name: "Injectio" - } - }; - } - - private static bool IsMethodAttribute(AttributeData attribute) - { - return attribute?.AttributeClass is - { - Name: KnownTypes.ModuleAttributeShortName or KnownTypes.ModuleAttributeTypeName, - ContainingNamespace: - { - Name: "Attributes", - ContainingNamespace.Name: "Injectio" - } - }; - } - - private static bool IsServiceCollection(IParameterSymbol parameterSymbol) - { - return parameterSymbol?.Type is - { - Name: "IServiceCollection" or "ServiceCollection", - ContainingNamespace: - { - Name: "DependencyInjection", - ContainingNamespace: - { - Name: "Extensions", - ContainingNamespace.Name: "Microsoft" - } - } - }; - } - - private static bool IsStringCollection(IParameterSymbol parameterSymbol) - { - var type = parameterSymbol?.Type as INamedTypeSymbol; - - return type is - { - Name: "IEnumerable" or "IReadOnlySet" or "IReadOnlyCollection" or "ICollection" or "ISet" or "HashSet", - IsGenericType: true, - TypeArguments.Length: 1, - TypeParameters.Length: 1, - ContainingNamespace: - { - Name: "Generic", - ContainingNamespace: - { - Name: "Collections", - ContainingNamespace.Name: "System" - } - } - }; - } - private static bool IsOpenGeneric(INamedTypeSymbol? typeSymbol) { if (typeSymbol is null) @@ -510,21 +390,4 @@ private static string ResolveDuplicateStrategy(object? value) _ => KnownTypes.DuplicateStrategySkipShortName }; } - - private static string ResolveRegistrationStrategy(object? value) - { - return value switch - { - int v => v switch - { - KnownTypes.RegistrationStrategySelfValue => KnownTypes.RegistrationStrategySelfShortName, - KnownTypes.RegistrationStrategyImplementedInterfacesValue => KnownTypes.RegistrationStrategyImplementedInterfacesShortName, - KnownTypes.RegistrationStrategySelfWithInterfacesValue => KnownTypes.RegistrationStrategySelfWithInterfacesShortName, - KnownTypes.RegistrationStrategySelfWithProxyFactoryValue => KnownTypes.RegistrationStrategySelfWithProxyFactoryShortName, - _ => KnownTypes.RegistrationStrategySelfWithProxyFactoryShortName - }, - string text => text, - _ => KnownTypes.RegistrationStrategySelfWithProxyFactoryShortName - }; - } } diff --git a/src/Injectio.Generators/SymbolHelpers.cs b/src/Injectio.Generators/SymbolHelpers.cs new file mode 100644 index 0000000..6fb441f --- /dev/null +++ b/src/Injectio.Generators/SymbolHelpers.cs @@ -0,0 +1,156 @@ +using Microsoft.CodeAnalysis; + +namespace Injectio.Generators; + +internal static class SymbolHelpers +{ + public static readonly SymbolDisplayFormat FullyQualifiedNullableFormat = + SymbolDisplayFormat.FullyQualifiedFormat.AddMiscellaneousOptions( + SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier + ); + + public static bool IsMethodAttribute(AttributeData attribute) + { + return attribute?.AttributeClass is + { + Name: KnownTypes.ModuleAttributeShortName or KnownTypes.ModuleAttributeTypeName, + ContainingNamespace: + { + Name: "Attributes", + ContainingNamespace.Name: "Injectio" + } + }; + } + + public static bool IsTransientAttribute(AttributeData attribute) + { + return attribute?.AttributeClass is + { + Name: KnownTypes.TransientAttributeShortName or KnownTypes.TransientAttributeTypeName, + ContainingNamespace: + { + Name: "Attributes", + ContainingNamespace.Name: "Injectio" + } + }; + } + + public static bool IsSingletonAttribute(AttributeData attribute) + { + return attribute?.AttributeClass is + { + Name: KnownTypes.SingletonAttributeShortName or KnownTypes.SingletonAttributeTypeName, + ContainingNamespace: + { + Name: "Attributes", + ContainingNamespace.Name: "Injectio" + } + }; + } + + public static bool IsScopedAttribute(AttributeData attribute) + { + return attribute?.AttributeClass is + { + Name: KnownTypes.ScopedAttributeShortName or KnownTypes.ScopedAttributeTypeName, + ContainingNamespace: + { + Name: "Attributes", + ContainingNamespace.Name: "Injectio" + } + }; + } + + public static bool IsKnownAttribute(AttributeData attribute, out string serviceLifetime) + { + if (IsSingletonAttribute(attribute)) + { + serviceLifetime = KnownTypes.ServiceLifetimeSingletonFullName; + return true; + } + + if (IsScopedAttribute(attribute)) + { + serviceLifetime = KnownTypes.ServiceLifetimeScopedFullName; + return true; + } + + if (IsTransientAttribute(attribute)) + { + serviceLifetime = KnownTypes.ServiceLifetimeTransientFullName; + return true; + } + + serviceLifetime = KnownTypes.ServiceLifetimeTransientFullName; + return false; + } + + public static bool IsServiceCollection(IParameterSymbol parameterSymbol) + { + return parameterSymbol?.Type is + { + Name: "IServiceCollection" or "ServiceCollection", + ContainingNamespace: + { + Name: "DependencyInjection", + ContainingNamespace: + { + Name: "Extensions", + ContainingNamespace.Name: "Microsoft" + } + } + }; + } + + public static bool IsStringCollection(IParameterSymbol parameterSymbol) + { + var type = parameterSymbol?.Type as INamedTypeSymbol; + + return type is + { + Name: "IEnumerable" or "IReadOnlySet" or "IReadOnlyCollection" or "ICollection" or "ISet" or "HashSet", + IsGenericType: true, + TypeArguments.Length: 1, + TypeParameters.Length: 1, + ContainingNamespace: + { + Name: "Generic", + ContainingNamespace: + { + Name: "Collections", + ContainingNamespace.Name: "System" + } + } + }; + } + + public static bool IsServiceProvider(IParameterSymbol parameterSymbol) + { + return parameterSymbol?.Type is + { + Name: "IServiceProvider", + ContainingNamespace: + { + Name: "System", + ContainingNamespace.IsGlobalNamespace: true + } + }; + } + + public static string ResolveRegistrationStrategy(object? value) + { + return value switch + { + int v => v switch + { + KnownTypes.RegistrationStrategySelfValue => KnownTypes.RegistrationStrategySelfShortName, + KnownTypes.RegistrationStrategyImplementedInterfacesValue => KnownTypes.RegistrationStrategyImplementedInterfacesShortName, + KnownTypes.RegistrationStrategySelfWithInterfacesValue => KnownTypes.RegistrationStrategySelfWithInterfacesShortName, + KnownTypes.RegistrationStrategySelfWithProxyFactoryValue => KnownTypes.RegistrationStrategySelfWithProxyFactoryShortName, + _ => KnownTypes.RegistrationStrategySelfWithProxyFactoryShortName + }, + string text => text, + _ => KnownTypes.RegistrationStrategySelfWithProxyFactoryShortName + }; + } +} From 79f382e2453aa2d5abc1dbd038f651971531b37a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 00:37:59 +0000 Subject: [PATCH 4/7] Migrate from xunit v2 to xunit.v3 Update package reference from xunit 2.9.3 to xunit.v3 1.1.0 to match the v3 runner already in use. Replace Xunit.Abstractions usings with Xunit (ITestOutputHelper moved in v3). https://claude.ai/code/session_01FpKv78qmKTTnNZAGyXxGwv --- Directory.Packages.props | 2 +- tests/Injectio.Acceptance.Tests/DependencyInjectionBase.cs | 2 +- .../Injectio.Acceptance.Tests/Injectio.Acceptance.Tests.csproj | 2 +- tests/Injectio.Acceptance.Tests/LibraryServiceTests.cs | 2 +- tests/Injectio.Acceptance.Tests/LocalServiceTests.cs | 2 +- tests/Injectio.Tests/Injectio.Tests.csproj | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index b58c9e3..83793e2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,7 +13,7 @@ - + diff --git a/tests/Injectio.Acceptance.Tests/DependencyInjectionBase.cs b/tests/Injectio.Acceptance.Tests/DependencyInjectionBase.cs index 882030c..46633c7 100644 --- a/tests/Injectio.Acceptance.Tests/DependencyInjectionBase.cs +++ b/tests/Injectio.Acceptance.Tests/DependencyInjectionBase.cs @@ -1,4 +1,4 @@ -using Xunit.Abstractions; +using Xunit; using XUnit.Hosting; diff --git a/tests/Injectio.Acceptance.Tests/Injectio.Acceptance.Tests.csproj b/tests/Injectio.Acceptance.Tests/Injectio.Acceptance.Tests.csproj index 8eb37db..4f9d7a1 100644 --- a/tests/Injectio.Acceptance.Tests/Injectio.Acceptance.Tests.csproj +++ b/tests/Injectio.Acceptance.Tests/Injectio.Acceptance.Tests.csproj @@ -18,7 +18,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + all diff --git a/tests/Injectio.Acceptance.Tests/LibraryServiceTests.cs b/tests/Injectio.Acceptance.Tests/LibraryServiceTests.cs index 1efb62e..65a552d 100644 --- a/tests/Injectio.Acceptance.Tests/LibraryServiceTests.cs +++ b/tests/Injectio.Acceptance.Tests/LibraryServiceTests.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.DependencyInjection; -using Xunit.Abstractions; +using Xunit; namespace Injectio.Acceptance.Tests; diff --git a/tests/Injectio.Acceptance.Tests/LocalServiceTests.cs b/tests/Injectio.Acceptance.Tests/LocalServiceTests.cs index 76dfe99..00d6c9f 100644 --- a/tests/Injectio.Acceptance.Tests/LocalServiceTests.cs +++ b/tests/Injectio.Acceptance.Tests/LocalServiceTests.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.DependencyInjection; -using Xunit.Abstractions; +using Xunit; namespace Injectio.Acceptance.Tests; diff --git a/tests/Injectio.Tests/Injectio.Tests.csproj b/tests/Injectio.Tests/Injectio.Tests.csproj index 6bea383..5664316 100644 --- a/tests/Injectio.Tests/Injectio.Tests.csproj +++ b/tests/Injectio.Tests/Injectio.Tests.csproj @@ -15,7 +15,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all From 0e07fdb8f51960c3e54d908e2f4eb28d3b1026f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 00:43:17 +0000 Subject: [PATCH 5/7] Fix CI: resolve xunit v2/v3 conflict, add release tracking, fix open generic validation - Replace Verify.Xunit (pulls xunit v2 transitively) with Verify.XunitV3 - Remove xunit.runner.visualstudio (bundled in xunit v3) - Add AnalyzerReleases.Shipped.md and AnalyzerReleases.Unshipped.md for RS2008 release tracking compliance - Move ToUnboundGenericType to shared SymbolHelpers - Fix false positive INJECT0007 for open generic registrations by comparing unbound generic forms of interfaces and base types https://claude.ai/code/session_01FpKv78qmKTTnNZAGyXxGwv --- Directory.Packages.props | 3 +-- .../AnalyzerReleases.Shipped.md | 2 ++ .../AnalyzerReleases.Unshipped.md | 16 +++++++++++++ .../Injectio.Generators.csproj | 5 ++++ .../ServiceRegistrationAnalyzer.cs | 24 +++++++++++++++++++ .../ServiceRegistrationGenerator.cs | 19 ++------------- src/Injectio.Generators/SymbolHelpers.cs | 15 ++++++++++++ .../Injectio.Acceptance.Tests.csproj | 4 ---- tests/Injectio.Tests/Injectio.Tests.csproj | 6 +---- 9 files changed, 66 insertions(+), 28 deletions(-) create mode 100644 src/Injectio.Generators/AnalyzerReleases.Shipped.md create mode 100644 src/Injectio.Generators/AnalyzerReleases.Unshipped.md diff --git a/Directory.Packages.props b/Directory.Packages.props index 83793e2..7b20a07 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,9 +12,8 @@ - + - diff --git a/src/Injectio.Generators/AnalyzerReleases.Shipped.md b/src/Injectio.Generators/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..f50bb1f --- /dev/null +++ b/src/Injectio.Generators/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/src/Injectio.Generators/AnalyzerReleases.Unshipped.md b/src/Injectio.Generators/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..7ad52dc --- /dev/null +++ b/src/Injectio.Generators/AnalyzerReleases.Unshipped.md @@ -0,0 +1,16 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +INJECT0001 | Injectio | Warning | RegisterServices method has invalid signature +INJECT0002 | Injectio | Warning | RegisterServices method has invalid second parameter +INJECT0003 | Injectio | Warning | RegisterServices method has too many parameters +INJECT0004 | Injectio | Warning | Factory method not found +INJECT0005 | Injectio | Warning | Factory method must be static +INJECT0006 | Injectio | Warning | Factory method has invalid signature +INJECT0007 | Injectio | Warning | Implementation does not implement service type +INJECT0008 | Injectio | Warning | Implementation type is abstract +INJECT0009 | Injectio | Warning | RegisterServices on non-static method in abstract class diff --git a/src/Injectio.Generators/Injectio.Generators.csproj b/src/Injectio.Generators/Injectio.Generators.csproj index 9ebcf3c..0479dc4 100644 --- a/src/Injectio.Generators/Injectio.Generators.csproj +++ b/src/Injectio.Generators/Injectio.Generators.csproj @@ -27,4 +27,9 @@ + + + + + diff --git a/src/Injectio.Generators/ServiceRegistrationAnalyzer.cs b/src/Injectio.Generators/ServiceRegistrationAnalyzer.cs index 6797494..f12d073 100644 --- a/src/Injectio.Generators/ServiceRegistrationAnalyzer.cs +++ b/src/Injectio.Generators/ServiceRegistrationAnalyzer.cs @@ -328,6 +328,18 @@ private static void ValidateServiceTypes( implementsService = true; break; } + + // also check unbound generic form (e.g. IOpenGeneric<> vs IOpenGeneric) + var unboundIface = SymbolHelpers.ToUnboundGenericType(iface); + if (unboundIface != iface) + { + var unboundName = unboundIface.ToDisplayString(SymbolHelpers.FullyQualifiedNullableFormat); + if (unboundName == serviceType) + { + implementsService = true; + break; + } + } } if (!implementsService) @@ -341,6 +353,18 @@ private static void ValidateServiceTypes( implementsService = true; break; } + + var unboundBase = SymbolHelpers.ToUnboundGenericType(baseType); + if (unboundBase != baseType) + { + var unboundBaseName = unboundBase.ToDisplayString(SymbolHelpers.FullyQualifiedNullableFormat); + if (unboundBaseName == serviceType) + { + implementsService = true; + break; + } + } + baseType = baseType.BaseType; } } diff --git a/src/Injectio.Generators/ServiceRegistrationGenerator.cs b/src/Injectio.Generators/ServiceRegistrationGenerator.cs index fc96898..d4be12c 100644 --- a/src/Injectio.Generators/ServiceRegistrationGenerator.cs +++ b/src/Injectio.Generators/ServiceRegistrationGenerator.cs @@ -299,7 +299,7 @@ private static (bool isValid, bool hasTagCollection) ValidateMethod(IMethodSymbo // no implementation type set, use class attribute is on if (implementationType.IsNullOrWhiteSpace()) { - var unboundType = ToUnboundGenericType(classSymbol); + var unboundType = SymbolHelpers.ToUnboundGenericType(classSymbol); isOpenGeneric = isOpenGeneric || IsOpenGeneric(unboundType); implementationType = unboundType.ToDisplayString(SymbolHelpers.FullyQualifiedNullableFormat); } @@ -316,7 +316,7 @@ or KnownTypes.RegistrationStrategySelfWithInterfacesShortName if (implementedInterface.ConstructedFrom.ToString() == "System.IEquatable") continue; - var unboundInterface = ToUnboundGenericType(implementedInterface); + var unboundInterface = SymbolHelpers.ToUnboundGenericType(implementedInterface); isOpenGeneric = isOpenGeneric || IsOpenGeneric(unboundInterface); var interfaceName = unboundInterface.ToDisplayString(SymbolHelpers.FullyQualifiedNullableFormat); @@ -349,21 +349,6 @@ or KnownTypes.RegistrationStrategySelfWithInterfacesShortName IsOpenGeneric: isOpenGeneric); } - private static INamedTypeSymbol ToUnboundGenericType(INamedTypeSymbol typeSymbol) - { - if (!typeSymbol.IsGenericType || typeSymbol.IsUnboundGenericType) - return typeSymbol; - - foreach (var typeArgument in typeSymbol.TypeArguments) - { - // If TypeKind is TypeParameter, it's actually the name of a locally declared type-parameter -> placeholder - if (typeArgument.TypeKind != TypeKind.TypeParameter) - return typeSymbol; - } - - return typeSymbol.ConstructUnboundGenericType(); - } - private static bool IsOpenGeneric(INamedTypeSymbol? typeSymbol) { if (typeSymbol is null) diff --git a/src/Injectio.Generators/SymbolHelpers.cs b/src/Injectio.Generators/SymbolHelpers.cs index 6fb441f..3c78644 100644 --- a/src/Injectio.Generators/SymbolHelpers.cs +++ b/src/Injectio.Generators/SymbolHelpers.cs @@ -137,6 +137,21 @@ public static bool IsServiceProvider(IParameterSymbol parameterSymbol) }; } + public static INamedTypeSymbol ToUnboundGenericType(INamedTypeSymbol typeSymbol) + { + if (!typeSymbol.IsGenericType || typeSymbol.IsUnboundGenericType) + return typeSymbol; + + foreach (var typeArgument in typeSymbol.TypeArguments) + { + // If TypeKind is TypeParameter, it's actually the name of a locally declared type-parameter -> placeholder + if (typeArgument.TypeKind != TypeKind.TypeParameter) + return typeSymbol; + } + + return typeSymbol.ConstructUnboundGenericType(); + } + public static string ResolveRegistrationStrategy(object? value) { return value switch diff --git a/tests/Injectio.Acceptance.Tests/Injectio.Acceptance.Tests.csproj b/tests/Injectio.Acceptance.Tests/Injectio.Acceptance.Tests.csproj index 4f9d7a1..2cdb10b 100644 --- a/tests/Injectio.Acceptance.Tests/Injectio.Acceptance.Tests.csproj +++ b/tests/Injectio.Acceptance.Tests/Injectio.Acceptance.Tests.csproj @@ -20,10 +20,6 @@ - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - diff --git a/tests/Injectio.Tests/Injectio.Tests.csproj b/tests/Injectio.Tests/Injectio.Tests.csproj index 5664316..236d23b 100644 --- a/tests/Injectio.Tests/Injectio.Tests.csproj +++ b/tests/Injectio.Tests/Injectio.Tests.csproj @@ -14,12 +14,8 @@ - + - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - runtime; build; native; contentfiles; analyzers; buildtransitive all From d75ca934824347286430828a330745ce53094978 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 00:47:12 +0000 Subject: [PATCH 6/7] Fix TestHostBase constructor for XUnit.Hosting v4 and symbol comparison warnings - Remove ITestOutputHelper parameter from test base classes (XUnit.Hosting v4 TestHostBase constructor takes only the fixture) - Use SymbolEqualityComparer.Default.Equals instead of != for ISymbol comparisons to fix RS1024 warnings https://claude.ai/code/session_01FpKv78qmKTTnNZAGyXxGwv --- src/Injectio.Generators/ServiceRegistrationAnalyzer.cs | 4 ++-- tests/Injectio.Acceptance.Tests/DependencyInjectionBase.cs | 6 ++---- tests/Injectio.Acceptance.Tests/LibraryServiceTests.cs | 4 +--- tests/Injectio.Acceptance.Tests/LocalServiceTests.cs | 4 +--- 4 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/Injectio.Generators/ServiceRegistrationAnalyzer.cs b/src/Injectio.Generators/ServiceRegistrationAnalyzer.cs index f12d073..4c422e9 100644 --- a/src/Injectio.Generators/ServiceRegistrationAnalyzer.cs +++ b/src/Injectio.Generators/ServiceRegistrationAnalyzer.cs @@ -331,7 +331,7 @@ private static void ValidateServiceTypes( // also check unbound generic form (e.g. IOpenGeneric<> vs IOpenGeneric) var unboundIface = SymbolHelpers.ToUnboundGenericType(iface); - if (unboundIface != iface) + if (!SymbolEqualityComparer.Default.Equals(unboundIface, iface)) { var unboundName = unboundIface.ToDisplayString(SymbolHelpers.FullyQualifiedNullableFormat); if (unboundName == serviceType) @@ -355,7 +355,7 @@ private static void ValidateServiceTypes( } var unboundBase = SymbolHelpers.ToUnboundGenericType(baseType); - if (unboundBase != baseType) + if (!SymbolEqualityComparer.Default.Equals(unboundBase, baseType)) { var unboundBaseName = unboundBase.ToDisplayString(SymbolHelpers.FullyQualifiedNullableFormat); if (unboundBaseName == serviceType) diff --git a/tests/Injectio.Acceptance.Tests/DependencyInjectionBase.cs b/tests/Injectio.Acceptance.Tests/DependencyInjectionBase.cs index 46633c7..bcbba2f 100644 --- a/tests/Injectio.Acceptance.Tests/DependencyInjectionBase.cs +++ b/tests/Injectio.Acceptance.Tests/DependencyInjectionBase.cs @@ -1,11 +1,9 @@ -using Xunit; - using XUnit.Hosting; namespace Injectio.Acceptance.Tests; [Collection(DependencyInjectionCollection.CollectionName)] -public abstract class DependencyInjectionBase(ITestOutputHelper output, DependencyInjectionFixture fixture) - : TestHostBase(output, fixture) +public abstract class DependencyInjectionBase(DependencyInjectionFixture fixture) + : TestHostBase(fixture) { } diff --git a/tests/Injectio.Acceptance.Tests/LibraryServiceTests.cs b/tests/Injectio.Acceptance.Tests/LibraryServiceTests.cs index 65a552d..4b7f6e0 100644 --- a/tests/Injectio.Acceptance.Tests/LibraryServiceTests.cs +++ b/tests/Injectio.Acceptance.Tests/LibraryServiceTests.cs @@ -4,12 +4,10 @@ using Microsoft.Extensions.DependencyInjection; -using Xunit; - namespace Injectio.Acceptance.Tests; [Collection(DependencyInjectionCollection.CollectionName)] -public class LibraryServiceTests(ITestOutputHelper output, DependencyInjectionFixture fixture) : DependencyInjectionBase(output, fixture) +public class LibraryServiceTests(DependencyInjectionFixture fixture) : DependencyInjectionBase(fixture) { [Fact] public void ShouldResolveService() diff --git a/tests/Injectio.Acceptance.Tests/LocalServiceTests.cs b/tests/Injectio.Acceptance.Tests/LocalServiceTests.cs index 00d6c9f..2fbf1f9 100644 --- a/tests/Injectio.Acceptance.Tests/LocalServiceTests.cs +++ b/tests/Injectio.Acceptance.Tests/LocalServiceTests.cs @@ -4,12 +4,10 @@ using Microsoft.Extensions.DependencyInjection; -using Xunit; - namespace Injectio.Acceptance.Tests; [Collection(DependencyInjectionCollection.CollectionName)] -public class LocalServiceTests(ITestOutputHelper output, DependencyInjectionFixture fixture) : DependencyInjectionBase(output, fixture) +public class LocalServiceTests(DependencyInjectionFixture fixture) : DependencyInjectionBase(fixture) { [Fact] public void ShouldResolveLocalService() From b945cae6949ee63fc79182d2b5a5e6c78f76587d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 03:01:35 +0000 Subject: [PATCH 7/7] Address review feedback and fix TestHostBase constructor - Remove IsAbstract early return in AnalyzeNamedType so INJECT0008 is reachable for abstract types with registration attributes - Rewrite factory validation to find any valid overload before reporting, avoiding false positives with multiple overloads - Validate factory second parameter is object? (not arbitrary types) - Add string type argument check to IsStringCollection (reject IEnumerable etc.) - Add tests for INJECT0008 (abstract type with/without factory) - Fix TestHostBase constructor for XUnit.Hosting v4 (single fixture parameter, no ITestOutputHelper) https://claude.ai/code/session_01FpKv78qmKTTnNZAGyXxGwv --- .../ServiceRegistrationAnalyzer.cs | 49 ++++++++++--------- src/Injectio.Generators/SymbolHelpers.cs | 30 +++++++----- .../ServiceRegistrationDiagnosticTests.cs | 47 ++++++++++++++++++ 3 files changed, 90 insertions(+), 36 deletions(-) diff --git a/src/Injectio.Generators/ServiceRegistrationAnalyzer.cs b/src/Injectio.Generators/ServiceRegistrationAnalyzer.cs index 4c422e9..025c05a 100644 --- a/src/Injectio.Generators/ServiceRegistrationAnalyzer.cs +++ b/src/Injectio.Generators/ServiceRegistrationAnalyzer.cs @@ -119,7 +119,7 @@ private static void AnalyzeNamedType(SymbolAnalysisContext context) if (context.Symbol is not INamedTypeSymbol classSymbol) return; - if (classSymbol.IsAbstract || classSymbol.IsStatic) + if (classSymbol.IsStatic) return; var attributes = classSymbol.GetAttributes(); @@ -272,37 +272,38 @@ private static void ValidateFactoryMethod( return; } + // find at least one valid overload; only report if none exist + var hasStaticOverload = false; + foreach (var method in factoryMethods) { if (!method.IsStatic) - { - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.FactoryMethodNotStatic, - location, - factoryMethodName, - className)); - return; - } + continue; + + hasStaticOverload = true; if (method.Parameters.Length is not (1 or 2)) - { - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.FactoryMethodInvalidSignature, - location, - factoryMethodName, - className)); - return; - } + continue; if (!SymbolHelpers.IsServiceProvider(method.Parameters[0])) - { - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.FactoryMethodInvalidSignature, - location, - factoryMethodName, - className)); - } + continue; + + // validate second parameter is object? (for keyed services) + if (method.Parameters.Length == 2 + && method.Parameters[1].Type.SpecialType != SpecialType.System_Object) + continue; + + // found a valid overload + return; } + + context.ReportDiagnostic(Diagnostic.Create( + hasStaticOverload + ? DiagnosticDescriptors.FactoryMethodInvalidSignature + : DiagnosticDescriptors.FactoryMethodNotStatic, + location, + factoryMethodName, + className)); } private static void ValidateServiceTypes( diff --git a/src/Injectio.Generators/SymbolHelpers.cs b/src/Injectio.Generators/SymbolHelpers.cs index 3c78644..54cc867 100644 --- a/src/Injectio.Generators/SymbolHelpers.cs +++ b/src/Injectio.Generators/SymbolHelpers.cs @@ -106,22 +106,28 @@ public static bool IsStringCollection(IParameterSymbol parameterSymbol) { var type = parameterSymbol?.Type as INamedTypeSymbol; - return type is - { - Name: "IEnumerable" or "IReadOnlySet" or "IReadOnlyCollection" or "ICollection" or "ISet" or "HashSet", - IsGenericType: true, - TypeArguments.Length: 1, - TypeParameters.Length: 1, - ContainingNamespace: + if (type is not { - Name: "Generic", + Name: "IEnumerable" or "IReadOnlySet" or "IReadOnlyCollection" or "ICollection" or "ISet" or "HashSet", + IsGenericType: true, + TypeArguments.Length: 1, + TypeParameters.Length: 1, ContainingNamespace: { - Name: "Collections", - ContainingNamespace.Name: "System" + Name: "Generic", + ContainingNamespace: + { + Name: "Collections", + ContainingNamespace.Name: "System" + } } - } - }; + }) + { + return false; + } + + // verify the generic argument is string + return type.TypeArguments[0].SpecialType == SpecialType.System_String; } public static bool IsServiceProvider(IParameterSymbol parameterSymbol) diff --git a/tests/Injectio.Tests/ServiceRegistrationDiagnosticTests.cs b/tests/Injectio.Tests/ServiceRegistrationDiagnosticTests.cs index ee1250d..0aa4b15 100644 --- a/tests/Injectio.Tests/ServiceRegistrationDiagnosticTests.cs +++ b/tests/Injectio.Tests/ServiceRegistrationDiagnosticTests.cs @@ -226,6 +226,53 @@ public void Register(IServiceCollection services) diagnostics.Should().ContainSingle(d => d.Id == "INJECT0009"); } + [Fact] + public async Task DiagnoseAbstractImplementationTypeWithoutFactory() + { + var source = @" +using Injectio.Attributes; + +namespace Injectio.Sample; + +public interface IService { } + +[RegisterSingleton] +public abstract class AbstractService : IService +{ +} +"; + + var diagnostics = await GetDiagnosticsAsync(source); + + diagnostics.Should().ContainSingle(d => d.Id == "INJECT0008"); + } + + [Fact] + public async Task NoDiagnosticsForAbstractImplementationTypeWithFactory() + { + var source = @" +using System; +using Injectio.Attributes; + +namespace Injectio.Sample; + +public interface IService { } + +[RegisterSingleton(ServiceType = typeof(IService), Factory = nameof(Create))] +public abstract class AbstractService : IService +{ + public static IService Create(IServiceProvider serviceProvider) + { + return null!; + } +} +"; + + var diagnostics = await GetDiagnosticsAsync(source); + + diagnostics.Should().BeEmpty(); + } + [Fact] public async Task NoDiagnosticsForValidRegistration() {