diff --git a/Docs/pages/09-diagnostics.md b/Docs/pages/09-diagnostics.md index 0db04de..14ac436 100644 --- a/Docs/pages/09-diagnostics.md +++ b/Docs/pages/09-diagnostics.md @@ -1238,6 +1238,25 @@ public sealed class Roaster : IRoaster, IEquipment; public static partial class CoffeeShop; ``` +### AWT193 + +:::warning[Warning] +A `[Scan]` matched a type that is inaccessible to the generated container, so it is not registered. +::: + +```csharp +// In a referenced assembly: the implementation itself is internal. +public interface IEquipment; +internal sealed class Roaster : IEquipment; + +// Roaster is assignable to the marker, but the container cannot name it to construct it. +[Container] +[Scan(InAssembliesOf = [typeof(IEquipment)])] +public static partial class CoffeeShop; +``` + +Where [AWT188](#awt188) is about an interface the match cannot be exposed under, this one is about the match itself, so no `ScanAs` flag rescues it: every registration has to name the implementation. Make the type public, grant the container's assembly `InternalsVisibleTo`, or exclude it with a `NamePatterns`/`NamespacePatterns` entry (the `Exclude` type list cannot name an inaccessible type). Reported only for a type the marker matched and the scan's filters kept, so an unrelated internal type in a scanned assembly stays silent, as does one you already excluded. + ## Modules ### AWT149 diff --git a/Docs/pages/registration/07-scanning.md b/Docs/pages/registration/07-scanning.md index 28de803..4f1afce 100644 --- a/Docs/pages/registration/07-scanning.md +++ b/Docs/pages/registration/07-scanning.md @@ -16,6 +16,8 @@ Every concrete, public, non-generic type assignable to `IDrink` is registered. T [Scan(typeof(IDrink))] ``` +A type the container cannot name is skipped rather than registered: an implementation that is `internal` to another assembly (with no `InternalsVisibleTo`), or a private nested class. Since a match quietly vanishing from the container is almost never what you meant, the scan reports the skip as [AWT193](../diagnostics#awt193). Widening the type to `public` brings it back. Only a type your scan actually asked for is reported, so the internal plumbing of a scanned assembly stays quiet, as does anything you filtered out. Whether the *interfaces* a match is exposed under are accessible is a separate question, covered below. + ## Choose how they register By default each match registers as itself. Use `As` to register under the marker interface instead, or both. diff --git a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md index 8cf7611..fd0f4fc 100644 --- a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -94,3 +94,4 @@ AWT190 | Awaiten | Error | A lifecycle hook (OnActivated / OnRelease) names an overloaded method, so the container cannot choose which one to call AWT191 | Awaiten | Error | An OnRelease hook parameter is a Func/Lazy relationship, which would defer resolution past the owner's teardown AWT192 | Awaiten | Error | SuppressDisposal is set on a pre-built Instance, which the container does not own or dispose, so it has no effect + AWT193 | Awaiten | Warning | A [Scan] matched a type that is inaccessible to the generated container, so it is not registered diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs index c462a74..cc176ab 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs @@ -13,9 +13,10 @@ partial class AwaitenGenerator /// AsClosedTypesOf). Covers the container's own assembly, or the assemblies named by /// InAssembliesOf, sorted by name for reproducibility, then narrowed by the optional /// NamePatterns/NamespacePatterns/Exclude filters. Abstract/static classes, generic - /// definitions, inaccessible types and the marker itself are skipped. A markerless scan (the parameterless - /// [Scan]) matches every concrete type instead, narrowed by those same filters. Reports - /// AWT138/AWT139/AWT140/AWT143/AWT172/AWT173/AWT174/AWT182/AWT183/AWT184/AWT185/AWT187/AWT188. The synthesized + /// definitions and the marker itself are skipped; a match the generated container cannot name is reported as + /// AWT193 and then dropped. A markerless scan (the parameterless [Scan]) matches every concrete type + /// instead, narrowed by those same filters. Reports + /// AWT138/AWT139/AWT140/AWT143/AWT172/AWT173/AWT174/AWT182/AWT183/AWT184/AWT185/AWT187/AWT188/AWT193. The synthesized /// registrations are , so an explicit registration wins single /// resolution while every match still joins its collection. /// @@ -115,16 +116,31 @@ private static void ExpandScan( int assignable = 0; int registered = 0; int produced = 0; - foreach (INamedTypeSymbol type in ScanCandidates(assemblies, compilation, marker, location, diagnostics)) + foreach (ScanCandidate candidate in ScanCandidates(assemblies, compilation, marker, location, diagnostics)) { assignable++; - if (!PassesScanFilters(type, filters, hits)) + if (!PassesScanFilters(candidate.Type, filters, hits)) { continue; } registered++; - produced += RegisterScanMatch(type, ScanContracts(type, match, marker, openMarker, markerDefinition, compilation), markerDisplay, match, result, diagnostics); + + // AWT193: the match survived the filters, so the author does mean to register it, but the generated + // container cannot name the type and no registration could reference it. Reported here rather than + // during gathering, where it would fire once per internal type in every scanned assembly, and unlike + // the other per-match warnings also for a markerless scan: AWT183 forces one to narrow its candidates, + // so a match reaching this point was asked for either way. + if (candidate.Inaccessible) + { + diagnostics.Add(new DiagnosticInfo( + Diagnostics.ScanTypeInaccessible, + LocationInfo.From(location), + new EquatableArray([Display(candidate.Type.ToDisplayString(FullyQualified)),]))); + continue; + } + + produced += RegisterScanMatch(candidate.Type, ScanContracts(candidate.Type, match, marker, openMarker, markerDefinition, compilation), markerDisplay, match, result, diagnostics); } ReportScanFilterDiagnostics(filters, hits, new ScanFilterCounts(assignable, registered, produced), assemblies, markerDisplay, location, diagnostics); @@ -435,19 +451,18 @@ private static IEnumerable SelfAndBaseTypes(INamedTypeSymbol t /// name so the registrations are reproducible. Reports AWT140 for any InAssembliesOf assembly that /// holds no such type (a likely missing ProjectReference). /// - private static List ScanCandidates( + private static List ScanCandidates( List? assemblies, Compilation compilation, INamedTypeSymbol? marker, Location? location, List diagnostics) { - List candidates = new(); + List candidates = new(); if (assemblies is null) { - candidates.AddRange(EnumerateTypes(compilation.Assembly.GlobalNamespace) - .Where(type => IsScanCandidate(type, marker, compilation))); + candidates.AddRange(ScanCandidatesIn(compilation.Assembly.GlobalNamespace, marker, compilation)); } else { @@ -460,11 +475,28 @@ private static List ScanCandidates( // Cross-assembly enumeration order is not guaranteed stable; sort by fully-qualified name so the // generated output (and any snapshot) is reproducible. candidates.Sort((left, right) => string.CompareOrdinal( - left.ToDisplayString(FullyQualified), - right.ToDisplayString(FullyQualified))); + left.Type.ToDisplayString(FullyQualified), + right.Type.ToDisplayString(FullyQualified))); return candidates; } + /// + /// The scan candidates one namespace tree contributes: every type the marker matched, each flagged with + /// whether the generated container can name it. The accessibility verdict is carried rather than acted on, + /// mirroring on the contract side, so that it is the caller (which + /// has applied the scan's filters) that decides between reporting AWT193 and staying silent. + /// + private static IEnumerable ScanCandidatesIn(INamespaceSymbol ns, INamedTypeSymbol? marker, Compilation compilation) + => EnumerateTypes(ns) + .Where(type => IsScanCandidate(type, marker, compilation)) + .Select(type => new ScanCandidate(type, !compilation.IsSymbolAccessibleWithin(type, compilation.Assembly))); + + /// + /// One type a [Scan] matched, and whether it is inaccessible to the generated container (internal to + /// another assembly, or a private/protected nested type), in which case nothing can be registered for it. + /// + private readonly record struct ScanCandidate(INamedTypeSymbol Type, bool Inaccessible); + /// /// The assemblies named by InAssembliesOf (each entry's containing assembly, deduped). Null when the /// argument is unset or explicitly null, meaning the container's own assembly is scanned instead; an empty @@ -496,19 +528,19 @@ private static List ScanCandidates( /// /// Appends one referenced assembly's scan candidates, reporting AWT140 when it holds none (almost always a - /// missing ProjectReference or the wrong marker type). + /// missing ProjectReference or the wrong marker type). An inaccessible match still counts as a + /// candidate: it is the more specific AWT193, not a "found nothing at all" hint, that the assembly earns. /// private static void AddAssemblyCandidates( IAssemblySymbol assembly, INamedTypeSymbol? marker, Compilation compilation, - List candidates, + List candidates, Location? location, List diagnostics) { int contributed = candidates.Count; - candidates.AddRange(EnumerateTypes(assembly.GlobalNamespace) - .Where(type => IsScanCandidate(type, marker, compilation))); + candidates.AddRange(ScanCandidatesIn(assembly.GlobalNamespace, marker, compilation)); if (candidates.Count == contributed) { @@ -523,15 +555,18 @@ private static void AddAssemblyCandidates( /// Whether a type is a concrete class assignable to the marker (and not the marker itself), shared by /// candidate gathering and the AWT140 emptiness check. An unbound generic marker requires the type to /// implement a closed form of it; a marker (a markerless scan) accepts every - /// concrete class, leaving the narrowing to the filters. Generic type definitions and inaccessible types - /// are skipped, since neither can be referenced from generated code. + /// concrete class, leaving the narrowing to the filters. Generic type definitions are skipped, since there + /// is no closed type to construct, and so is a type with no name the generated code could write at all + /// (<Module>, a compiler-generated closure): no author wrote it, so it must not reach AWT193. + /// Accessibility itself deliberately plays no part here: it is decided last, on what the marker (and then the + /// filters) already selected, so that AWT193 names only the types the author asked for rather than every + /// internal type in a scanned assembly. /// private static bool IsScanCandidate(INamedTypeSymbol type, INamedTypeSymbol? marker, Compilation compilation) { - if (type is not { TypeKind: TypeKind.Class, IsAbstract: false, IsStatic: false, IsImplicitClass: false, } + if (type is not { TypeKind: TypeKind.Class, IsAbstract: false, IsStatic: false, IsImplicitClass: false, CanBeReferencedByName: true, } || (marker is not null && SymbolEqualityComparer.Default.Equals(type, marker)) - || HasOpenTypeParameters(type) - || !compilation.IsSymbolAccessibleWithin(type, compilation.Assembly)) + || HasOpenTypeParameters(type)) { return false; } @@ -678,8 +713,9 @@ private sealed class ScanFilterHits /// /// Candidates a scan's marker matched (), how many survived its filters /// (), and how many registrations those survivors actually contributed - /// (, which a MatchingInterface match can leave short of - /// when a survivor has no I + name interface). + /// (, which falls short of for a survivor the + /// container cannot name (AWT193) and for a MatchingInterface survivor with no I + name + /// interface). /// private readonly record struct ScanFilterCounts(int Assignable, int Registered, int Produced); diff --git a/Source/Awaiten.SourceGenerators/Diagnostics.cs b/Source/Awaiten.SourceGenerators/Diagnostics.cs index 5980bf8..7855f6b 100644 --- a/Source/Awaiten.SourceGenerators/Diagnostics.cs +++ b/Source/Awaiten.SourceGenerators/Diagnostics.cs @@ -1374,4 +1374,24 @@ internal static class Diagnostics "Awaiten", DiagnosticSeverity.Error, isEnabledByDefault: true); + + /// + /// A [Scan] matched a type the generated container cannot name: it is internal to another assembly + /// (with no InternalsVisibleTo), or a private/protected nested type. No exposure can save it, because + /// every registration has to name the implementation in order to construct it. That is what separates this + /// from AWT188, where only the interface is out of reach and + /// ScanAs.Self still registers the type. Without this warning the match would vanish silently on a + /// green build, which is exactly what a library author hits after narrowing an implementation to + /// internal. Reported only for a type the marker matched and the scan's filters kept, so an unrelated + /// internal type, or one the author already excluded, stays quiet. Widen the type's accessibility, or exclude + /// it with a NamePatterns/NamespacePatterns entry to say the skip is intended (the Exclude + /// type list cannot name an inaccessible type). + /// + public static readonly DiagnosticDescriptor ScanTypeInaccessible = new( + "AWT193", + "Scan matched an inaccessible type", + "'{0}' matched the scan, but the type itself is not accessible to the generated container, so it is not registered; widen the type's accessibility (or grant the container's assembly InternalsVisibleTo), or exclude it from the scan with a NamePatterns/NamespacePatterns entry", + "Awaiten", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); } diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt193ScanTypeInaccessible.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt193ScanTypeInaccessible.cs new file mode 100644 index 0000000..5cc24cc --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt193ScanTypeInaccessible.cs @@ -0,0 +1,238 @@ +using System.Linq; + +namespace Awaiten.SourceGenerators.Tests; + +public partial class DiagnosticTests +{ + public class Awt193ScanTypeInaccessible + { + [Fact] + public async Task ReportsWhenTheMatchedTypeIsInaccessible() + { + GeneratorResult result = Generator.RunWithReferencedAssembly(""" + 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).Contains("*AWT193*Roaster*").AsWildcard() + .Because("Roaster is assignable to the marker, but the container cannot name an internal type of another assembly"); + await That(result.Diagnostics).DoesNotContain("*AWT140*").AsWildcard() + .Because("the assembly does hold a match, so hinting at a missing ProjectReference would mislead"); + await That(result.Diagnostics).DoesNotContain("*AWT138*").AsWildcard() + .Because("the marker did match a type; the reason nothing registered is the accessibility, which AWT193 names"); + } + + [Fact] + public async Task ReportsOnlyForTypesTheMarkerMatched() + { + GeneratorResult result = Generator.RunWithReferencedAssembly(""" + namespace Lib; + + public interface IEquipment { } + public sealed class Grinder : IEquipment { } + internal sealed class Roaster : IEquipment { } + + // The internal plumbing every real library has, none of it assignable to the marker. + internal sealed class Cache { } + internal sealed class Buffer { } + internal sealed class Formatter { } + internal sealed class Poller { } + """, """ + using Awaiten; + + namespace MyCode; + + [Container] + [Scan(typeof(Lib.IEquipment), InAssembliesOf = new[] { typeof(Lib.IEquipment) })] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics.Count(diagnostic => diagnostic.Contains("AWT193"))).IsEqualTo(1) + .Because("only Roaster is both inaccessible and assignable to the marker; the other internal types are none of the scan's business"); + await That(result.Diagnostics).Contains("*AWT193*Roaster*").AsWildcard() + .Because("Roaster is the one match the container cannot name"); + } + + [Fact] + public async Task DoesNotReportWhenTheTypeIsAccessible() + { + GeneratorResult result = Generator.RunWithReferencedAssembly(""" + namespace Lib; + + public interface IEquipment { } + public sealed class Grinder : 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 public Grinder registers, so there is nothing to warn about"); + } + + [Fact] + public async Task DoesNotReportForAnInternalTypeOfTheContainersOwnAssembly() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IPlugin { } + internal sealed class OrderPlugin : IPlugin { } + + [Container] + [Scan(typeof(IPlugin))] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("the generated container lives in the same assembly, so an internal type is perfectly nameable"); + } + + [Fact] + public async Task DoesNotReportWhenNamePatternsExcludeTheType() + { + GeneratorResult result = Generator.RunWithReferencedAssembly(""" + namespace Lib; + + public interface IEquipment { } + public sealed class Grinder : IEquipment { } + internal sealed class RoasterStub : IEquipment { } + """, """ + using Awaiten; + + namespace MyCode; + + [Container] + [Scan(typeof(Lib.IEquipment), NamePatterns = new[] { "!*Stub" }, InAssembliesOf = new[] { typeof(Lib.IEquipment) })] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT193*").AsWildcard() + .Because("the author already said the stub is not part of the scan, so its accessibility is irrelevant"); + await That(result.Diagnostics).DoesNotContain("*AWT173*").AsWildcard() + .Because("the exclusion did remove a candidate, so it is not stale"); + } + + [Fact] + public async Task DoesNotReportWhenNamespacePatternsExcludeTheType() + { + GeneratorResult result = Generator.RunWithReferencedAssembly(""" + namespace Lib.Public + { + public interface IEquipment { } + public sealed class Grinder : IEquipment { } + } + + namespace Lib.Internals + { + internal sealed class Roaster : Lib.Public.IEquipment { } + } + """, """ + using Awaiten; + + namespace MyCode; + + [Container] + [Scan(typeof(Lib.Public.IEquipment), NamespacePatterns = new[] { "Lib.Public" }, InAssembliesOf = new[] { typeof(Lib.Public.IEquipment) })] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT193*").AsWildcard() + .Because("the namespace filter never included Lib.Internals, so nothing there was asked for"); + } + + [Fact] + public async Task ReportsForAMarkerlessScanInsideItsNamespace() + { + GeneratorResult result = Generator.RunWithReferencedAssembly(""" + namespace Lib.Equipment + { + public interface IRoaster { } + internal sealed class Roaster : IRoaster { } + } + + namespace Lib.Internals + { + public interface IPoller { } + internal sealed class Poller : IPoller { } + } + """, """ + using Awaiten; + + namespace MyCode; + + [Container] + [Scan(As = ScanAs.MatchingInterface, NamespacePatterns = new[] { "Lib.Equipment" }, InAssembliesOf = new[] { typeof(Lib.Equipment.IRoaster) })] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).Contains("*AWT193*Roaster*").AsWildcard() + .Because("a markerless scan is narrowed by AWT183 to a namespace the author named, so a type it cannot register there is worth saying out loud"); + await That(result.Diagnostics).DoesNotContain("*AWT193*Poller*").AsWildcard() + .Because("Lib.Internals is outside the scan's namespace filter"); + await That(result.Diagnostics).Contains("*AWT184*").AsWildcard() + .Because("the scan did register nothing, and AWT193 saying why does not make that untrue; the pair is deliberate"); + } + + [Fact] + public async Task DoesNotReportWhenInternalsVisibleToGrantsAccess() + { + 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 + { + } + """); + + // The diagnostic offers InternalsVisibleTo as a remedy, so pin that taking it actually works. An empty + // diagnostic set covers the generated source too, so this also proves the container can name Roaster. + await That(result.Diagnostics).IsEmpty() + .Because("the grant makes the internal type nameable from the container's assembly, so there is nothing to warn about"); + await That(result.Sources["Awaiten.MyCode.MyContainer.g.cs"]).Contains("new global::Lib.Roaster()") + .Because("an internal type the container can see is registered like a public one"); + } + } +} diff --git a/Tests/Awaiten.SourceGenerators.Tests/ScanTests.cs b/Tests/Awaiten.SourceGenerators.Tests/ScanTests.cs index 032c3b1..591ac22 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/ScanTests.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/ScanTests.cs @@ -156,7 +156,10 @@ public static partial class MyContainer } """, typeof(global::Awaiten.Tests.Support.ICrossAssemblyPlugin)); - await That(result.Diagnostics).IsEmpty(); + // The support assembly's internal InternalPlugin is assignable to the marker but not nameable from here, + // which AWT193 reports; nothing else about this scan is remarkable. + await That(result.Diagnostics).Contains("*AWT193*InternalPlugin*").AsWildcard(); + await That(result.Diagnostics).HasCount(1); string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; await That(source).Contains("new __Bucket(typeof(global::Awaiten.Tests.Support.GammaPlugin)"); @@ -336,8 +339,8 @@ public static partial class MyContainer } """); - await That(result.Diagnostics).IsEmpty() - .Because("a private nested match cannot be referenced from the generated code and must be skipped"); + await That(result.Diagnostics).Contains("*AWT193*HiddenPlugin*").AsWildcard() + .Because("a private nested match cannot be referenced from the generated code, so it is skipped, but not silently"); string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; await That(source).Contains("new global::MyCode.VisiblePlugin()"); await That(source).DoesNotContain("HiddenPlugin"); @@ -392,7 +395,9 @@ public static partial class MyContainer } """, typeof(global::Awaiten.Tests.Support.ICrossAssemblyPlugin)); - await That(result.Diagnostics).IsEmpty(); + // As above, the support assembly's internal InternalPlugin earns an AWT193 and nothing else does. + await That(result.Diagnostics).Contains("*AWT193*InternalPlugin*").AsWildcard(); + await That(result.Diagnostics).HasCount(1); string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; // The generic form threads through InAssembliesOf exactly like the typeof form. diff --git a/Tests/Awaiten.Tests/Awaiten.Tests.csproj b/Tests/Awaiten.Tests/Awaiten.Tests.csproj index e9862c5..32f7a5b 100644 --- a/Tests/Awaiten.Tests/Awaiten.Tests.csproj +++ b/Tests/Awaiten.Tests/Awaiten.Tests.csproj @@ -1,5 +1,14 @@  + + + $(NoWarn);AWT193 + +