From 038eed038c1e39466d8427179307e29082446742 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 17 Jul 2026 12:03:04 +0200 Subject: [PATCH 1/3] fix: honor InternalsVisibleTo when checking constructor accessibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Awaiten used two different accessibility tests and only one of them was correct. Module member resolution asks Roslyn (`compilation.IsSymbolAccessibleWithin(candidate, containerSymbol)`), which honors `InternalsVisibleTo`. Constructor selection, setter injection and the container's inherited-member lookup instead hand-rolled a `SymbolEqualityComparer.Default.Equals(a.ContainingAssembly, b.ContainingAssembly)` "same assembly" comparison, which does not. The two disagreed within a single scan. A referenced assembly that grants `[assembly: InternalsVisibleTo("MyApp")]` and exposes a `public sealed class Roaster` with an `internal` constructor passed `IsScanCandidate` (that check already asked Roslyn), and was then rejected by `IsAccessibleConstructor` — failing the build with AWT104 on a `new` the generated container could legally have emitted. Replaces the hand-rolled comparison with Roslyn's own check at all three sites: - `SelectConstructor` / `IsAccessibleConstructor` (AWT104): an internal constructor across an `InternalsVisibleTo` boundary is now selected, and without the grant still reports AWT104. - `IsAccessibleSetter` (AWT136): property injection into an internal setter across the boundary now works; without the grant it still surfaces as AWT136 rather than an inaccessible-setter error in generated code. - `AccessibleMembers` / `IsAccessibleFromDerived`: the base-type walk for factory / instance / hook lookup. The container's own members keep their deliberate permissiveness — they are yielded by the first loop untouched, and `IsSymbolAccessibleWithin(privateMember, containerSymbol)` would return true for them anyway, since a generated partial can use its container's private members. Threads `Compilation` to the sites that lacked it (`SelectConstructor` and its five callers, `InjectionContext`, `ExpandOpenGenerics` / `RequiredServiceTypes`, and the scan's `FirstUnconstructableReason` / `UnsatisfiableInjectedMemberReason`). Beyond `InternalsVisibleTo`, Roslyn's check also walks containing types, so a constructor that is public on a type nested privately in some other class is no longer treated as reachable. That code never compiled anyway. `AwaitenGenerator.OpenGenerics.cs:593` looks like the same pattern but is not: it checks `DeclaredAccessibility == Accessibility.Public` for a `new()` constraint, and the constraint genuinely requires a public parameterless constructor. `where T : new()` over a type whose only parameterless constructor is `internal` is CS0310 even within one assembly, so relaxing it would make expansion synthesize closed generics that do not compile. Left as is. All 591 generator tests pass unchanged, including `CrossAssemblyModuleTests.CrossAssemblyModule_InternalFactory_ReportsAwt108NamingTheModule`, which pins the opposite boundary: a cross-assembly internal member without `InternalsVisibleTo` is not imported into the referencing compilation's symbol tables at all, so it stays AWT108 (invisibility) rather than becoming AWT153 (inaccessibility). --- .../AwaitenGenerator.Classification.cs | 36 ++-- .../AwaitenGenerator.Collection.cs | 2 +- .../AwaitenGenerator.Decorators.cs | 4 +- .../AwaitenGenerator.OpenGenerics.cs | 5 +- .../AwaitenGenerator.Production.cs | 47 ++---- .../AwaitenGenerator.Scan.cs | 10 +- .../CrossAssemblyAccessibilityTests.cs | 158 ++++++++++++++++++ 7 files changed, 204 insertions(+), 58 deletions(-) create mode 100644 Tests/Awaiten.SourceGenerators.Tests/CrossAssemblyAccessibilityTests.cs diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Classification.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Classification.cs index 7b46d6e..c9a8a4c 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Classification.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Classification.cs @@ -188,12 +188,14 @@ private static void ReportWhenUnregistered(ParameterModel parameterModel, ImplIn /// /// The container-wide inputs an injected-member classification reads, bundled so the classification methods - /// stay small: the container symbol, the coalesced service map, the constraint-rejected and consumed-context - /// sets, the [ImportService<T>] external service types (a member of one resolves externally - /// rather than being reported missing), and the diagnostics sink. + /// stay small: the container symbol, the compilation (which answers whether a setter is reachable from the + /// container), the coalesced service map, the constraint-rejected and consumed-context sets, the + /// [ImportService<T>] external service types (a member of one resolves externally rather than + /// being reported missing), and the diagnostics sink. /// private sealed record InjectionContext( INamedTypeSymbol ContainerSymbol, + Compilation Compilation, Dictionary ServiceToImpl, HashSet ConstraintRejected, HashSet ConsumedConditionals, @@ -341,11 +343,12 @@ private static IEnumerable InjectedProperties(INamedTypeSymbol LocationInfo? location = spec.EntryLocation ?? LocationInfo.From(property.Locations.FirstOrDefault()); // AWT136: an injected property must have a set/init accessor the container can assign through the object - // initializer. The container is not a derived type, so a protected/private-protected setter (and a - // cross-assembly internal one) is out of reach even though not private. Apply the same accessibility test - // the constructor path uses rather than a bare not-private check, so an unreachable setter surfaces as - // AWT136 instead of an inaccessible-setter error in generated code. - if (property.SetMethod is not { } setter || !IsAccessibleSetter(setter, context.ContainerSymbol)) + // initializer. The container is not a derived type, so a protected/private-protected setter (and an + // internal one in an assembly that grants the container no [InternalsVisibleTo]) is out of reach even + // though not private. Apply the same accessibility test the constructor path uses rather than a bare + // not-private check, so an unreachable setter surfaces as AWT136 instead of an inaccessible-setter error + // in generated code. + if (property.SetMethod is not { } setter || !IsAccessibleSetter(setter, context.ContainerSymbol, context.Compilation)) { context.Diagnostics.Add(new DiagnosticInfo( Diagnostics.InjectedPropertyNotSettable, @@ -496,17 +499,12 @@ private static bool RejectsInjectedPropertyShape( return false; } - // The setter must be reachable from the container's object initializer, which is not a derived context. - // Mirrors IsAccessibleConstructor: public always, internal/protected-internal only within the container's own - // assembly, and protected/private-protected/private never (the container cannot reach them). - private static bool IsAccessibleSetter(IMethodSymbol setter, INamedTypeSymbol containerSymbol) - => setter.DeclaredAccessibility switch - { - Accessibility.Public => true, - Accessibility.Internal or Accessibility.ProtectedOrInternal => - SymbolEqualityComparer.Default.Equals(setter.ContainingAssembly, containerSymbol.ContainingAssembly), - _ => false, - }; + // The setter must be reachable from the container's object initializer. Asks Roslyn the same way constructor + // selection does (see SelectConstructor), so the two agree: public always, internal/protected-internal within + // the container's own assembly or across an [InternalsVisibleTo] boundary, and protected/private-protected/ + // private never - the container neither derives from the implementation nor sits inside it. + private static bool IsAccessibleSetter(IMethodSymbol setter, INamedTypeSymbol containerSymbol, Compilation compilation) + => compilation.IsSymbolAccessibleWithin(setter, containerSymbol); /// /// Classifies a constructor parameter as a runtime argument ([Arg]), a deferred relationship type diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs index 06a8e0b..20ac8fe 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs @@ -45,7 +45,7 @@ private static (List Raw, HashSet ConstraintRejectedSer // implementation (iterating to a fixpoint over its own generic dependencies). if (open.Count > 0) { - ExpandOpenGenerics(result, open, containerSymbol, external, diagnostics, constraintRejected); + ExpandOpenGenerics(result, open, containerSymbol, compilation, external, diagnostics, constraintRejected); } // ...then moved back to the end: coalescing is first-wins per service, so the explicit registrations and diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Decorators.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Decorators.cs index fd09795..6caff21 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Decorators.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Decorators.cs @@ -321,7 +321,7 @@ private readonly record struct ServiceChain( /// private string? SingleInnerParameterType(INamedTypeSymbol decorator, INamedTypeSymbol service) { - IMethodSymbol? constructor = SelectConstructor(decorator, _containerSymbol, _serviceToImpl.Keys.Select(k => k.Service), _external); + IMethodSymbol? constructor = SelectConstructor(decorator, _containerSymbol, _compilation, _serviceToImpl.Keys.Select(k => k.Service), _external); if (constructor is null) { return null; @@ -612,7 +612,7 @@ private static CompositeCollectionKind ClassifyCompositeCollection( out string? relatedElement) { relatedElement = null; - IMethodSymbol? constructor = SelectConstructor(composite, containerSymbol, serviceToImpl.Keys.Select(k => k.Service), external); + IMethodSymbol? constructor = SelectConstructor(composite, containerSymbol, compilation, serviceToImpl.Keys.Select(k => k.Service), external); if (constructor is null) { return CompositeCollectionKind.Missing; diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.OpenGenerics.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.OpenGenerics.cs index ec07031..a3e5e21 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.OpenGenerics.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.OpenGenerics.cs @@ -190,6 +190,7 @@ private static void ExpandOpenGenerics( List raw, List open, INamedTypeSymbol containerSymbol, + Compilation compilation, ExternalSurface external, List diagnostics, HashSet constraintRejected) @@ -247,7 +248,7 @@ private static void ExpandOpenGenerics( continue; } - foreach (ITypeSymbol required in RequiredServiceTypes(impl, containerSymbol, raw, openServices, external)) + foreach (ITypeSymbol required in RequiredServiceTypes(impl, containerSymbol, compilation, raw, openServices, external)) { if (required is INamedTypeSymbol { IsGenericType: true, IsUnboundGenericType: false, } closed && expandedServices.Add(closed.ToDisplayString(FullyQualified))) @@ -379,6 +380,7 @@ private static void ReportExpansionTooDeep(INamedTypeSymbol impl, int depth, Exp private static IEnumerable RequiredServiceTypes( INamedTypeSymbol implementation, INamedTypeSymbol containerSymbol, + Compilation compilation, List raw, HashSet openServices, ExternalSurface external) @@ -386,6 +388,7 @@ private static IEnumerable RequiredServiceTypes( IMethodSymbol? constructor = AwaitenGenerator.SelectConstructor( implementation, containerSymbol, + compilation, raw.Select(r => r.ServiceType), external, p => IsOpenGenericSatisfiable(p.Type, openServices)); diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs index 2aadf01..5dc8092 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs @@ -172,7 +172,7 @@ private static List BuildInjectedMembers(ImplInfo info, BuildContex DiscoverInjectedMembers( info, - new InjectionContext(context.ContainerSymbol, context.ServiceToImpl, context.ConstraintRejected, context.ConsumedConditionals, context.External.ServiceTypes, context.Diagnostics), + new InjectionContext(context.ContainerSymbol, context.Compilation, context.ServiceToImpl, context.ConstraintRejected, context.ConsumedConditionals, context.External.ServiceTypes, context.Diagnostics), injectProperties, members); return members; @@ -253,7 +253,7 @@ private static InstanceModel BuildPrebuiltInstance( return null; } - IMethodSymbol? constructor = SelectConstructor(info.Symbol, containerSymbol, serviceToImpl.Keys.Select(k => k.Service), external); + IMethodSymbol? constructor = SelectConstructor(info.Symbol, containerSymbol, compilation, serviceToImpl.Keys.Select(k => k.Service), external); if (constructor is null) { diagnostics.Add(new DiagnosticInfo( @@ -298,7 +298,7 @@ private static (string? Hook, EquatableArray Parameters) Resolve Compilation compilation = context.Compilation; List matches = new(); - foreach (ISymbol member in AccessibleMembers(info.Origin ?? containerSymbol, hookName)) + foreach (ISymbol member in AccessibleMembers(info.Origin ?? containerSymbol, hookName, compilation)) { // A module hook must also be accessible from the generated container (its own private members are // reachable from the partial, a module's are not); an inaccessible module method is not a usable hook. @@ -591,7 +591,7 @@ private static void ValidateInstanceMember( List diagnostics) { bool inaccessibleMatch = false; - foreach (ISymbol member in AccessibleMembers(info.Origin ?? containerSymbol, info.ProductionMember!)) + foreach (ISymbol member in AccessibleMembers(info.Origin ?? containerSymbol, info.ProductionMember!, compilation)) { ITypeSymbol? memberType = member switch { @@ -666,12 +666,17 @@ private static string DescribeOwner(ImplInfo info) internal static IMethodSymbol? SelectConstructor( INamedTypeSymbol implementation, INamedTypeSymbol containerSymbol, + Compilation compilation, IEnumerable registeredServices, ExternalSurface external, Func? additionallySatisfiable = null) { + // The generated partial emits the 'new' from inside the container, so what the container can reach is + // exactly Roslyn's own accessibility question - which honors [InternalsVisibleTo], where a bare + // same-assembly comparison would reject a referenced assembly's internal constructor the generated code + // could in fact call. The module member checks ask it the same way (see ResolveFactory). List constructors = implementation.InstanceConstructors - .Where(c => IsAccessibleConstructor(c, containerSymbol)) + .Where(c => compilation.IsSymbolAccessibleWithin(c, containerSymbol)) .ToList(); if (constructors.Count <= 1) { @@ -699,27 +704,16 @@ private static string DescribeOwner(ImplInfo info) // Fall back to the greediest constructor so its unresolved parameters surface as AWT101. return resolvable ?? constructors.OrderByDescending(c => c.Parameters.Length).First(); - - static bool IsAccessibleConstructor(IMethodSymbol constructor, INamedTypeSymbol containerSymbol) - { - return constructor.DeclaredAccessibility switch - { - Accessibility.Public => true, - Accessibility.Internal or Accessibility.ProtectedOrInternal => - SymbolEqualityComparer.Default.Equals( - constructor.ContainingAssembly, containerSymbol.ContainingAssembly), - _ => false, - }; - } } /// /// The members named the generated container partial can reach: the /// container's own members (any accessibility, since a partial can use its own private members) plus - /// inherited members a derived type can access (everything but private, with internal / private-protected - /// restricted to the same assembly). + /// inherited members the container can access - Roslyn's own check, so a base type's protected member + /// qualifies (the container derives from it) and an internal one does when the declaring assembly is the + /// container's or grants it [InternalsVisibleTo]. /// - private static IEnumerable AccessibleMembers(INamedTypeSymbol container, string name) + private static IEnumerable AccessibleMembers(INamedTypeSymbol container, string name, Compilation compilation) { foreach (ISymbol member in container.GetMembers(name)) { @@ -728,20 +722,11 @@ private static IEnumerable AccessibleMembers(INamedTypeSymbol container for (INamedTypeSymbol? baseType = container.BaseType; baseType is not null; baseType = baseType.BaseType) { - foreach (ISymbol member in baseType.GetMembers(name).Where(m => IsAccessibleFromDerived(m, container))) + foreach (ISymbol member in baseType.GetMembers(name).Where(m => compilation.IsSymbolAccessibleWithin(m, container))) { yield return member; } } - - static bool IsAccessibleFromDerived(ISymbol member, INamedTypeSymbol container) - => member.DeclaredAccessibility switch - { - Accessibility.Public or Accessibility.Protected or Accessibility.ProtectedOrInternal => true, - Accessibility.Internal or Accessibility.ProtectedAndInternal => - SymbolEqualityComparer.Default.Equals(member.ContainingAssembly, container.ContainingAssembly), - _ => false, - }; } /// @@ -756,7 +741,7 @@ private static List FindFactoryCandidates( INamedTypeSymbol container, string name, ITypeSymbol serviceType, Compilation compilation) { List candidates = new(); - foreach (ISymbol member in AccessibleMembers(container, name)) + foreach (ISymbol member in AccessibleMembers(container, name, compilation)) { if (member is IMethodSymbol { MethodKind: MethodKind.Ordinary, } method && compilation.HasImplicitConversion(ProducedType(method.ReturnType, compilation), serviceType)) diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs index c462a74..6ced642 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs @@ -1102,7 +1102,7 @@ private static bool PruneUnconstructableScanRound( { if (pinnedImpls.Contains(registration.ImplementationType) || !checkedImpls.Add(registration.ImplementationType) - || FirstUnconstructableReason(registration.Implementation, containerSymbol, services, constraintRejected, external, variance) is not { } reason) + || FirstUnconstructableReason(registration.Implementation, containerSymbol, compilation, services, constraintRejected, external, variance) is not { } reason) { continue; } @@ -1134,13 +1134,14 @@ private static bool PruneUnconstructableScanRound( private static string? FirstUnconstructableReason( INamedTypeSymbol implementation, INamedTypeSymbol containerSymbol, + Compilation compilation, HashSet services, HashSet constraintRejected, ExternalSurface external, VarianceState variance) { IMethodSymbol? constructor = SelectConstructor( - implementation, containerSymbol, services.Select(service => service.Service), external); + implementation, containerSymbol, compilation, services.Select(service => service.Service), external); if (constructor is null) { return "it has no constructor accessible to the container"; @@ -1164,7 +1165,7 @@ model.Kind is DependencyKind.Arg or DependencyKind.External foreach (IPropertySymbol property in InjectedProperties(implementation)) { - if (UnsatisfiableInjectedMemberReason(property, containerSymbol, services, constraintRejected, external.ServiceTypes) is { } reason) + if (UnsatisfiableInjectedMemberReason(property, containerSymbol, compilation, services, constraintRejected, external.ServiceTypes) is { } reason) { return reason; } @@ -1182,12 +1183,13 @@ model.Kind is DependencyKind.Arg or DependencyKind.External private static string? UnsatisfiableInjectedMemberReason( IPropertySymbol property, INamedTypeSymbol containerSymbol, + Compilation compilation, HashSet services, HashSet constraintRejected, HashSet externalServiceTypes) { ParameterModel member = ClassifyDependency(property.Type, property.GetAttributes(), asyncFactory: false, location: null, externalServiceTypes); - if (property.SetMethod is not { } setter || !IsAccessibleSetter(setter, containerSymbol) + if (property.SetMethod is not { } setter || !IsAccessibleSetter(setter, containerSymbol, compilation) || member.Kind is DependencyKind.Arg or DependencyKind.External || IsInjectOptional(property.GetAttributes())) { return null; diff --git a/Tests/Awaiten.SourceGenerators.Tests/CrossAssemblyAccessibilityTests.cs b/Tests/Awaiten.SourceGenerators.Tests/CrossAssemblyAccessibilityTests.cs new file mode 100644 index 0000000..1a0b1de --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/CrossAssemblyAccessibilityTests.cs @@ -0,0 +1,158 @@ +using System.Linq; + +namespace Awaiten.SourceGenerators.Tests; + +/// +/// Accessibility across an assembly boundary: the container reaches an internal constructor or setter of +/// a referenced assembly exactly when that assembly grants it [InternalsVisibleTo], and not otherwise. +/// The generated container lives in "TestAssembly", the referenced source compiles as "ReferencedAssembly". +/// +public class CrossAssemblyAccessibilityTests +{ + [Fact] + public async Task InternalConstructorAcrossInternalsVisibleTo_IsSelected() + { + GeneratorResult result = Generator.RunWithReferencedAssembly(""" + [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("TestAssembly")] + + namespace Lib; + + public interface IEquipment { } + public sealed class Roaster : IEquipment { internal Roaster() { } } + """, """ + using Awaiten; + + namespace MyCode; + + [Container] + [Scan(typeof(Lib.IEquipment), InAssembliesOf = new[] { typeof(Lib.IEquipment) })] + public static partial class MyContainer + { + } + """); + + // The scan admits Roaster (it asks Roslyn, which honors the grant), so constructor selection must agree: + // rejecting the constructor here would raise AWT104 on a 'new' the generated code may legally emit. An + // empty diagnostic set covers the emitted source too, so this also proves the construction compiles. + await That(result.Diagnostics).IsEmpty() + .Because("[InternalsVisibleTo] makes the internal constructor callable from the container's assembly"); + await That(result.Sources["Awaiten.MyCode.MyContainer.g.cs"]).Contains("new global::Lib.Roaster()") + .Because("the container constructs the scanned implementation through its internal constructor"); + } + + [Fact] + public async Task InternalConstructorWithoutInternalsVisibleTo_ReportsAwt104() + { + GeneratorResult result = Generator.RunWithReferencedAssembly(""" + namespace Lib; + + public interface IEquipment { } + public sealed class Roaster : IEquipment { internal Roaster() { } } + """, """ + using Awaiten; + + namespace MyCode; + + [Container] + [Scan(typeof(Lib.IEquipment), InAssembliesOf = new[] { typeof(Lib.IEquipment) })] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).Contains("*AWT104*Lib.Roaster*").AsWildcard() + .Because("without the grant the container's assembly cannot call the internal constructor"); + } + + [Fact] + public async Task InternalImplementationAcrossInternalsVisibleTo_IsScannedAndConstructed() + { + GeneratorResult result = Generator.RunWithReferencedAssembly(""" + [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("TestAssembly")] + + namespace Lib; + + public interface IEquipment { } + internal sealed class Roaster : IEquipment { } + """, """ + using Awaiten; + + namespace MyCode; + + [Container] + [Scan(typeof(Lib.IEquipment), InAssembliesOf = new[] { typeof(Lib.IEquipment) })] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("the grant makes the internal implementation nameable from the container's assembly"); + await That(result.Sources["Awaiten.MyCode.MyContainer.g.cs"]).Contains("new global::Lib.Roaster()") + .Because("an internal type the container can see is constructed like a public one"); + } + + [Fact] + public async Task InternalSetterAcrossInternalsVisibleTo_IsInjected() + { + GeneratorResult result = Generator.RunWithReferencedAssembly(""" + using Awaiten; + + [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("TestAssembly")] + + namespace Lib; + + public sealed class Grinder { } + public sealed class Roaster + { + [Inject] public Grinder Grinder { get; internal set; } + } + """, """ + using Awaiten; + + namespace MyCode; + + [Container] + [Transient] + [Transient] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("the grant lets the container's object initializer assign the internal setter"); + await That(result.Sources["Awaiten.MyCode.MyContainer.g.cs"]).Contains("Grinder =") + .Because("the injected member is filled from the object initializer"); + } + + [Fact] + public async Task InternalSetterWithoutInternalsVisibleTo_ReportsAwt136() + { + GeneratorResult result = Generator.RunWithReferencedAssembly(""" + using Awaiten; + + namespace Lib; + + public sealed class Grinder { } + public sealed class Roaster + { + [Inject] public Grinder Grinder { get; internal set; } + } + """, """ + using Awaiten; + + namespace MyCode; + + [Container] + [Transient] + [Transient] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).Contains("*AWT136*Grinder*").AsWildcard() + .Because("without the grant the setter is out of the container's reach, so it must surface as AWT136 rather than an inaccessible-setter error in generated code"); + } +} From 17f40431cf8118836a82071bca03efde3ff91eb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 17 Jul 2026 12:20:21 +0200 Subject: [PATCH 2/3] docs: drop spaced-hyphen parentheticals from the accessibility comments Three comments added by the accessibility fix used a spaced hyphen where the surrounding code expresses the same aside with a comma or a colon: SelectConstructor's rationale, IsAccessibleSetter's, and the AccessibleMembers remarks. No behavior change. --- .../AwaitenGenerator.Classification.cs | 2 +- .../AwaitenGenerator.Production.cs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Classification.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Classification.cs index c9a8a4c..1e204d3 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Classification.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Classification.cs @@ -502,7 +502,7 @@ private static bool RejectsInjectedPropertyShape( // The setter must be reachable from the container's object initializer. Asks Roslyn the same way constructor // selection does (see SelectConstructor), so the two agree: public always, internal/protected-internal within // the container's own assembly or across an [InternalsVisibleTo] boundary, and protected/private-protected/ - // private never - the container neither derives from the implementation nor sits inside it. + // private never, since the container neither derives from the implementation nor sits inside it. private static bool IsAccessibleSetter(IMethodSymbol setter, INamedTypeSymbol containerSymbol, Compilation compilation) => compilation.IsSymbolAccessibleWithin(setter, containerSymbol); diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs index 5dc8092..c31a4b9 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs @@ -672,9 +672,9 @@ private static string DescribeOwner(ImplInfo info) Func? additionallySatisfiable = null) { // The generated partial emits the 'new' from inside the container, so what the container can reach is - // exactly Roslyn's own accessibility question - which honors [InternalsVisibleTo], where a bare - // same-assembly comparison would reject a referenced assembly's internal constructor the generated code - // could in fact call. The module member checks ask it the same way (see ResolveFactory). + // exactly Roslyn's own accessibility question, which honors [InternalsVisibleTo]. A bare same-assembly + // comparison would reject a referenced assembly's internal constructor the generated code could in fact + // call. The module member checks ask it the same way (see ResolveFactory). List constructors = implementation.InstanceConstructors .Where(c => compilation.IsSymbolAccessibleWithin(c, containerSymbol)) .ToList(); @@ -709,7 +709,7 @@ private static string DescribeOwner(ImplInfo info) /// /// The members named the generated container partial can reach: the /// container's own members (any accessibility, since a partial can use its own private members) plus - /// inherited members the container can access - Roslyn's own check, so a base type's protected member + /// inherited members the container can access, which is Roslyn's own check: a base type's protected member /// qualifies (the container derives from it) and an internal one does when the declaring assembly is the /// container's or grants it [InternalsVisibleTo]. /// From 678b7e906778070d97f166a7b3660379f72743bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 17 Jul 2026 15:00:21 +0200 Subject: [PATCH 3/3] test: cover multi-constructor selection across InternalsVisibleTo Honoring [InternalsVisibleTo] can change which constructor selection picks, not just whether a lone internal one is admitted. Add a with/without-grant pair where a resolvable internal parameterless constructor is preferred over an unresolvable greedy public one only when the grant makes it a candidate. --- .../CrossAssemblyAccessibilityTests.cs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/Tests/Awaiten.SourceGenerators.Tests/CrossAssemblyAccessibilityTests.cs b/Tests/Awaiten.SourceGenerators.Tests/CrossAssemblyAccessibilityTests.cs index 1a0b1de..2705885 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/CrossAssemblyAccessibilityTests.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/CrossAssemblyAccessibilityTests.cs @@ -155,4 +155,64 @@ public static partial class MyContainer await That(result.Diagnostics).Contains("*AWT136*Grinder*").AsWildcard() .Because("without the grant the setter is out of the container's reach, so it must surface as AWT136 rather than an inaccessible-setter error in generated code"); } + + [Fact] + public async Task MultipleConstructors_ResolvableInternalOneSelectedAcrossInternalsVisibleTo() + { + GeneratorResult result = Generator.RunWithReferencedAssembly(""" + [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("TestAssembly")] + + namespace Lib; + + public sealed class Grinder { } + public sealed class Roaster + { + public Roaster(Grinder grinder) { } + internal Roaster() { } + } + """, """ + using Awaiten; + + namespace MyCode; + + [Container] + [Transient] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("[InternalsVisibleTo] admits the internal constructor as a candidate, and it is the resolvable overload"); + await That(result.Sources["Awaiten.MyCode.MyContainer.g.cs"]).Contains("new global::Lib.Roaster()") + .Because("constructor selection prefers the resolvable parameterless overload over the greedy public one"); + } + + [Fact] + public async Task MultipleConstructors_WithoutInternalsVisibleTo_FallsToPublicConstructorAndReportsAwt101() + { + GeneratorResult result = Generator.RunWithReferencedAssembly(""" + namespace Lib; + + public sealed class Grinder { } + public sealed class Roaster + { + public Roaster(Grinder grinder) { } + internal Roaster() { } + } + """, """ + using Awaiten; + + namespace MyCode; + + [Container] + [Transient] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).Contains("*AWT101*Grinder*").AsWildcard() + .Because("without the grant only the public constructor is reachable, and its dependency is unregistered"); + } }