diff --git a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md index 125bce3..342dc07 100644 --- a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -7,6 +7,7 @@ AWT103 | Awaiten | Error | An implementation type is abstract or an interface AWT104 | Awaiten | Error | An implementation type has no accessible constructor AWT105 | Awaiten | Error | A singleton captures a shorter-lived scoped dependency + AWT106 | Awaiten | Warning | A synchronous factory's body provably produces an IAsyncInitializable concrete type its declared return type hides AWT107 | Awaiten | Error | An implementation is registered with conflicting lifetimes AWT108 | Awaiten | Error | A Factory registration names a member that is not a usable factory method AWT109 | Awaiten | Error | An Instance registration names a member that is not a usable instance member diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs index d4f5ce9..9daafba 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs @@ -419,39 +419,11 @@ private static Dictionary> BuildDependencyGraph( } // Select the producer: a container method (Factory) or the implementation's constructor (the - // default). A factory produces the instance, so the registered type may be an interface and is - // not subject to the not-instantiable check that a constructed type is. - IMethodSymbol? producer; - if (info.Production == ProductionKind.Factory) + // default). A null result means the registration is unusable and a diagnostic was already reported. + IMethodSymbol? producer = SelectProducer(info, containerSymbol, compilation, serviceToImpl, diagnostics); + if (producer is null) { - producer = ResolveFactory(containerSymbol, info, compilation, diagnostics); - if (producer is null) - { - return null; - } - } - else - { - // An abstract type or interface cannot be constructed; reject it instead of emitting a 'new' - // against it (which would fail to compile in the generated source). - if (info.Symbol.IsAbstract || info.Symbol.TypeKind == TypeKind.Interface) - { - diagnostics.Add(new DiagnosticInfo( - Diagnostics.NotInstantiable, - info.Location, - new EquatableArray([Display(info.ImplementationType),]))); - return null; - } - - producer = SelectConstructor(info.Symbol, containerSymbol, serviceToImpl.Keys.Select(k => k.Service)); - if (producer is null) - { - diagnostics.Add(new DiagnosticInfo( - Diagnostics.NoAccessibleConstructor, - info.Location, - new EquatableArray([Display(info.ImplementationType),]))); - return null; - } + return null; } // An asynchronous factory returns Task / ValueTask: the container awaits it, so the type it @@ -494,6 +466,16 @@ private static Dictionary> BuildDependencyGraph( // IAsyncInitializable, the container additionally awaits its InitializeAsync after the factory completes. bool asyncInit = asyncInitializableSymbol is not null && ImplementsInterface(disposalType, asyncInitializableSymbol); + // Best-effort lint (AWT106): a synchronous factory whose declared return type hides the asynchronous + // initialization its body provably produces. The container reads async-init taint off producer.ReturnType + // (above), so a concrete IAsyncInitializable returned behind a plainer interface is never initialized. + // An async Task/ValueTask factory owns its own initialization (the container awaits the factory), + // and a hidden IDisposable is disposed at runtime via RuntimeDisposalCheck - neither is reported. + if (info.Production == ProductionKind.Factory && !asyncFactory) + { + ReportFactoryHidingAsyncInitialization(producer, compilation, asyncInitializableSymbol, diagnostics); + } + return new InstanceModel( info.ImplementationType, info.Symbol.Name, @@ -524,6 +506,172 @@ static bool CouldHideDisposable(ITypeSymbol type) || (type.TypeKind == TypeKind.Class && !type.IsSealed); } + /// + /// Selects the method that produces an implementation: a container method for a Factory + /// registration, or the implementation's own constructor otherwise. Returns + /// when the registration is unusable - an unresolved factory (AWT108), a non-instantiable abstract or + /// interface type (AWT103), or a type with no accessible constructor (AWT104) - having already appended + /// the corresponding diagnostic. A factory produces the instance, so the registered type may be an + /// interface and is not subject to the not-instantiable check a constructed type is. + /// + private static IMethodSymbol? SelectProducer( + ImplInfo info, + INamedTypeSymbol containerSymbol, + Compilation compilation, + Dictionary serviceToImpl, + List diagnostics) + { + if (info.Production == ProductionKind.Factory) + { + return ResolveFactory(containerSymbol, info, compilation, diagnostics); + } + + // An abstract type or interface cannot be constructed; reject it instead of emitting a 'new' + // against it (which would fail to compile in the generated source). + if (info.Symbol.IsAbstract || info.Symbol.TypeKind == TypeKind.Interface) + { + diagnostics.Add(new DiagnosticInfo( + Diagnostics.NotInstantiable, + info.Location, + new EquatableArray([Display(info.ImplementationType),]))); + return null; + } + + IMethodSymbol? constructor = SelectConstructor(info.Symbol, containerSymbol, serviceToImpl.Keys.Select(k => k.Service)); + if (constructor is null) + { + diagnostics.Add(new DiagnosticInfo( + Diagnostics.NoAccessibleConstructor, + info.Location, + new EquatableArray([Display(info.ImplementationType),]))); + } + + return constructor; + } + + /// + /// Reports AWT106 when a synchronous + /// factory method's body provably returns a concrete type that implements IAsyncInitializable + /// while the method's declared return type does not - so the initialization is invisible to the + /// container and never runs. + /// + /// + /// Conservative by design: it inspects only the producer's own return expressions (both + /// expression-bodied and block-bodied), never descending into nested lambdas or local functions, and + /// fires only when the statically determined type of the returned expression is a non-abstract, + /// non-interface named type that is async-initializable. A metadata-only factory (no syntax) or an + /// unresolved/unanalyzable return type yields no diagnostic. False negatives (helper-returned or + /// runtime-selected implementations) are accepted; false positives are not. A hidden IDisposable + /// is not reported (the container disposes factory outputs behind a runtime check); an asynchronous + /// factory is excluded by the caller (it owns its own initialization). + /// + private static void ReportFactoryHidingAsyncInitialization( + IMethodSymbol producer, + Compilation compilation, + INamedTypeSymbol? asyncInitializableSymbol, + List diagnostics) + { + if (asyncInitializableSymbol is null) + { + return; + } + + ITypeSymbol declaredReturnType = producer.ReturnType; + + // The container already sees the initialization when the declared return type is itself + // async-initializable, so nothing it hides could be missed - there is no diagnostic to report. + if (Implements(declaredReturnType, asyncInitializableSymbol)) + { + return; + } + + // Already-reported concrete types: a factory with several returns of the same hidden type should + // surface a single diagnostic, not one per return. + HashSet reported = new(SymbolEqualityComparer.Default); + + foreach (SyntaxReference reference in producer.DeclaringSyntaxReferences) + { + // A factory must be a method on the container; anything else (or metadata-only, no syntax) is + // not analyzable here and is left silent. + if (reference.GetSyntax() is not MethodDeclarationSyntax method) + { + continue; + } + + SemanticModel model = compilation.GetSemanticModel(method.SyntaxTree); + + foreach (ExpressionSyntax returnExpression in CollectReturnExpressions(method)) + { + ITypeSymbol? returnedType = model.GetTypeInfo(returnExpression).Type; + if (returnedType is not INamedTypeSymbol concrete + || concrete.TypeKind == TypeKind.Interface + || concrete.IsAbstract + || concrete.TypeKind == TypeKind.Error + || !reported.Add(concrete) + || !Implements(concrete, asyncInitializableSymbol)) + { + continue; + } + + diagnostics.Add(new DiagnosticInfo( + Diagnostics.FactoryHidesAsyncInitialization, + LocationInfo.From(returnExpression.GetLocation()), + new EquatableArray([ + producer.Name, + Display(concrete.ToDisplayString(FullyQualified)), + Display(declaredReturnType.ToDisplayString(FullyQualified)), + ]))); + } + } + + static bool Implements(ITypeSymbol type, INamedTypeSymbol @interface) + { + return SymbolEqualityComparer.Default.Equals(type, @interface) + || type.AllInterfaces.Any(implemented => SymbolEqualityComparer.Default.Equals(implemented, @interface)); + } + } + + /// + /// The expressions a method directly returns: the arrow expression of an expression-bodied method, or + /// every return x; in a block body. Nested lambdas and local functions are not descended into, + /// so their returns are never attributed to the enclosing factory. + /// + private static IEnumerable CollectReturnExpressions(MethodDeclarationSyntax method) + { + if (method.ExpressionBody?.Expression is { } arrow) + { + yield return arrow; + yield break; + } + + if (method.Body is null) + { + yield break; + } + + Stack pending = new(); + pending.Push(method.Body); + while (pending.Count > 0) + { + SyntaxNode node = pending.Pop(); + foreach (SyntaxNode child in node.ChildNodes()) + { + // Do not cross into a nested function: its returns belong to it, not to the factory. + if (child is AnonymousFunctionExpressionSyntax or LocalFunctionStatementSyntax) + { + continue; + } + + if (child is ReturnStatementSyntax { Expression: { } returned }) + { + yield return returned; + } + + pending.Push(child); + } + } + } + /// /// Classifies the producer's parameters (a constructor's or a factory method's) and reports /// AWT101 for any non-[Arg] parameter whose diff --git a/Source/Awaiten.SourceGenerators/Diagnostics.cs b/Source/Awaiten.SourceGenerators/Diagnostics.cs index a68117a..0a647d6 100644 --- a/Source/Awaiten.SourceGenerators/Diagnostics.cs +++ b/Source/Awaiten.SourceGenerators/Diagnostics.cs @@ -65,6 +65,27 @@ internal static class Diagnostics DiagnosticSeverity.Error, isEnabledByDefault: true); + /// + /// A synchronous Factory method's body provably constructs (or returns a local of) a concrete + /// type that implements IAsyncInitializable, while its declared return type does not expose it. + /// The container reads async-initialization taint off the declared return type, so it cannot see that + /// the produced instance needs initialization - the hidden InitializeAsync never runs and the + /// instance is handed out uninitialized. A hidden IDisposable is not reported: the + /// container disposes factory outputs behind a runtime check, so it does not leak. An asynchronous + /// Task<T> / ValueTask<T> factory is not reported either: it owns its + /// own initialization (the container awaits the factory itself). This is a best-effort lint (Warning): + /// it fires only when the concrete returned type is statically provable from the body, so it has false + /// negatives by design (a helper-returned or runtime-selected implementation is not reported), but is + /// intended to have no false positives. + /// + public static readonly DiagnosticDescriptor FactoryHidesAsyncInitialization = new( + "AWT106", + "Factory hides asynchronous initialization behind its declared return type", + "factory '{0}' constructs '{1}', which is async-initialized, but declares return type '{2}'; the container cannot see that it needs initialization, so its InitializeAsync never runs. Return '{1}', or make the factory 'async Task<{2}>' and handle initialization inside it.", + "Awaiten", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + /// /// The same implementation is registered with more than one lifetime; coalescing into a single /// instance would silently drop one of the declared lifetimes. diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt106FactoryHidesAsyncInitialization.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt106FactoryHidesAsyncInitialization.cs new file mode 100644 index 0000000..ba37726 --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt106FactoryHidesAsyncInitialization.cs @@ -0,0 +1,416 @@ +using System.Linq; + +namespace Awaiten.SourceGenerators.Tests; + +public partial class DiagnosticTests +{ + public class Awt106FactoryHidesAsyncInitialization + { + [Fact] + public async Task ReportsWhenTheFactoryConstructsAnAsyncInitializableConcreteType() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Threading; + using System.Threading.Tasks; + + namespace MyCode; + + public interface IFoo { } + + public sealed class FooImpl : IFoo, IAsyncInitializable + { + public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + [Container] + [Singleton(Factory = nameof(Create))] + public static partial class MyContainer + { + private static IFoo Create() => new FooImpl(); + } + """); + + await That(result.Diagnostics).Contains("*AWT106*").AsWildcard() + .Because("the factory provably constructs an IAsyncInitializable type its declared return type hides"); + + string diagnostic = result.Diagnostics.Single(d => d.Contains("AWT106")); + await That(diagnostic).Contains("MyCode.FooImpl") + .Because("the message names the concrete type the author should return"); + await That(diagnostic).Contains("MyCode.IFoo") + .Because("the message names the declared return type that hides the capability"); + await That(diagnostic).Contains("async-initialized") + .Because("the message identifies the hidden capability as asynchronous initialization"); + } + + [Fact] + public async Task ReportsWhenTheBodyReturnsALocalOfTheConcreteType() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Threading; + using System.Threading.Tasks; + + namespace MyCode; + + public interface IFoo { } + + public sealed class FooImpl : IFoo, IAsyncInitializable + { + public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + [Container] + [Singleton(Factory = nameof(Create))] + public static partial class MyContainer + { + private static IFoo Create() + { + FooImpl foo = new FooImpl(); + return foo; + } + } + """); + + await That(result.Diagnostics).Contains("*AWT106*").AsWildcard() + .Because("the returned local's inferred type is the hidden concrete type"); + } + + [Fact] + public async Task ReportsASingleDiagnostic_WhenSeveralReturnsYieldTheSameHiddenType() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Threading; + using System.Threading.Tasks; + + namespace MyCode; + + public interface IFoo { } + + public sealed class FooImpl : IFoo, IAsyncInitializable + { + public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + [Container] + [Singleton(Factory = nameof(Create))] + public static partial class MyContainer + { + private static IFoo Create() + { + FooImpl foo = new FooImpl(); + if (foo.ToString().Length > 100) + { + return new FooImpl(); + } + + return foo; + } + } + """); + + await That(result.Diagnostics.Count(d => d.Contains("AWT106"))).IsEqualTo(1) + .Because("several returns of the same hidden concrete type surface one diagnostic, not one per return"); + } + + [Fact] + public async Task DoesNotReport_WhenTheDeclaredReturnTypeAlreadyExposesTheCapability() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Threading; + using System.Threading.Tasks; + + namespace MyCode; + + public sealed class FooImpl : IAsyncInitializable + { + public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + [Container] + [Singleton(Factory = nameof(Create))] + public static partial class MyContainer + { + private static FooImpl Create() => new FooImpl(); + } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT106*").AsWildcard(); + } + + [Fact] + public async Task DoesNotReport_WhenTheDeclaredInterfaceExtendsIAsyncInitializable() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Threading; + using System.Threading.Tasks; + + namespace MyCode; + + public interface IFooAsync : IAsyncInitializable { } + + public sealed class FooImpl : IFooAsync + { + public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + [Container] + [Singleton(Factory = nameof(Create))] + public static partial class MyContainer + { + private static IFooAsync Create() => new FooImpl(); + } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT106*").AsWildcard(); + } + + [Fact] + public async Task DoesNotReport_WhenTheBodyReturnsANonSpecialType() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IFoo { } + + public sealed class FooImpl : IFoo { } + + [Container] + [Singleton(Factory = nameof(Create))] + public static partial class MyContainer + { + private static IFoo Create() => new FooImpl(); + } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT106*").AsWildcard(); + } + + [Fact] + public async Task DoesNotReport_WhenTheConcreteTypeIsNotStaticallyProvableFromTheBody() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Threading; + using System.Threading.Tasks; + + namespace MyCode; + + public interface IFoo { } + + public sealed class FooImpl : IFoo, IAsyncInitializable + { + public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + [Container] + [Singleton(Factory = nameof(Create))] + public static partial class MyContainer + { + private static IFoo Build() => new FooImpl(); + private static IFoo Create() => Build(); + } + """); + + // The body returns the value of a helper call typed as IFoo; the concrete FooImpl is not provable + // without interprocedural analysis, so the lint stays silent. + await That(result.Diagnostics).DoesNotContain("*AWT106*").AsWildcard(); + } + + [Fact] + public async Task DoesNotReport_ForAConstructedRegistrationWithoutAFactory() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Threading; + using System.Threading.Tasks; + + namespace MyCode; + + public sealed class FooImpl : IAsyncInitializable + { + public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + [Container] + [Singleton] + public static partial class MyContainer + { + } + """); + + // No factory: the container constructs the type directly and already sees its capability. + await That(result.Diagnostics).DoesNotContain("*AWT106*").AsWildcard(); + } + + [Fact] + public async Task DoesNotReport_WhenANestedLambdaReturnsTheConcreteType() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System; + using System.Threading; + using System.Threading.Tasks; + + namespace MyCode; + + public interface IFoo { } + + public sealed class FooImpl : IFoo, IAsyncInitializable + { + public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + [Container] + [Singleton(Factory = nameof(Create))] + public static partial class MyContainer + { + private static IFoo Create() + { + Func make = () => new FooImpl(); + IFoo result = make(); + return result; + } + } + """); + + // The 'new FooImpl()' belongs to the nested lambda, not the factory's own return; the factory + // returns an IFoo-typed local, so nothing is provable. + await That(result.Diagnostics).DoesNotContain("*AWT106*").AsWildcard(); + } + + [Fact] + public async Task DoesNotReport_ForAnAsyncTaskFactory_WhichOwnsItsInitialization() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Threading; + using System.Threading.Tasks; + + namespace MyCode; + + public interface IFoo { } + + public sealed class FooImpl : IFoo, IAsyncInitializable + { + public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + [Container] + [Singleton(Factory = nameof(Create))] + public static partial class MyContainer + { + private static async Task Create() + { + FooImpl foo = new FooImpl(); + await foo.InitializeAsync(default); + return foo; + } + } + """); + + // An async Task factory is the explicit manual-initialization escape hatch: the container awaits + // the factory and does not drive InitializeAsync itself, so the lint must not nag a correct one. + await That(result.Diagnostics).DoesNotContain("*AWT106*").AsWildcard(); + } + + [Fact] + public async Task DoesNotReport_ForAnAsyncValueTaskFactory_WhichOwnsItsInitialization() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Threading; + using System.Threading.Tasks; + + namespace MyCode; + + public interface IFoo { } + + public sealed class FooImpl : IFoo, IAsyncInitializable + { + public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + [Container] + [Singleton(Factory = nameof(Create))] + public static partial class MyContainer + { + private static async ValueTask Create() + { + FooImpl foo = new FooImpl(); + await foo.InitializeAsync(default); + return foo; + } + } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT106*").AsWildcard(); + } + + [Fact] + public async Task DoesNotReport_ForAHiddenDisposable_SinceTheContainerDisposesFactoryOutputs() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System; + + namespace MyCode; + + public interface IFoo { } + + public sealed class FooImpl : IFoo, IDisposable + { + public void Dispose() { } + } + + [Container] + [Singleton(Factory = nameof(Create))] + public static partial class MyContainer + { + private static IFoo Create() => new FooImpl(); + } + """); + + // A hidden IDisposable is not reported: the container disposes factory outputs behind a runtime + // `is IDisposable` check (RuntimeDisposalCheck), so there is no leak to warn about. + await That(result.Diagnostics).DoesNotContain("*AWT106*").AsWildcard(); + } + + [Fact] + public async Task ReportsAsyncInitialization_WhenTheHiddenTypeIsAlsoDisposable() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System; + using System.Threading; + using System.Threading.Tasks; + + namespace MyCode; + + public interface IFoo { } + + public sealed class FooImpl : IFoo, IAsyncInitializable, IDisposable + { + public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public void Dispose() { } + } + + [Container] + [Singleton(Factory = nameof(Create))] + public static partial class MyContainer + { + private static IFoo Create() => new FooImpl(); + } + """); + + string diagnostic = result.Diagnostics.Single(d => d.Contains("AWT106")); + await That(diagnostic).Contains("async-initialized") + .Because("async initialization - not disposal - is the unfixable capability the lint reports"); + } + } +}