From d712b8e8e86f61b1f2c196d36992f7a5b60bc1c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 17 Jul 2026 11:44:21 +0200 Subject: [PATCH 1/3] feat: report scan matches whose type is inaccessible (AWT193) A `[Scan]` dropped a candidate the generated container cannot name (an implementation `internal` to another assembly, or a private nested class) without a word: the type vanished from the container and the build stayed green. The contract side of the same feature already carries dropped interfaces through to AWT188, so an inaccessible exposure interface warned while an inaccessible implementation type did not. The accessibility test moves out of `IsScanCandidate`'s early-out, where it ran before marker assignability and so could only have warned once per internal type in every scanned assembly. Candidates are now carried as a `ScanCandidate` flagged with the accessibility verdict (mirroring `ScanContractSet.Inaccessible`), the scan's `NamePatterns`/`NamespacePatterns`/`Exclude` filters run as usual, and only a flagged candidate that survives them is reported as AWT193 and dropped. A markerless scan reports it too, since AWT183 already forces it to narrow its candidates. `IsScanCandidate` now also skips a type with no source name at all (`CanBeReferencedByName`), which the accessibility test used to filter incidentally: `` and compiler-generated closures are nobody's scan match and must not reach the diagnostic. Two consequences worth naming: an `InAssembliesOf` assembly whose only matches are inaccessible reports AWT193 rather than AWT140, which no longer has to hint at a missing `ProjectReference`; and `Awaiten.Tests` sets `NoWarn=AWT193`, because its cross-assembly scan fixture deliberately keeps an internal plugin it has no way to filter out. --- Docs/pages/09-diagnostics.md | 19 ++ Docs/pages/registration/07-scanning.md | 2 + .../AnalyzerReleases.Unshipped.md | 1 + .../AwaitenGenerator.Scan.cs | 82 +++++-- .../Awaiten.SourceGenerators/Diagnostics.cs | 19 ++ ...gnosticTests.Awt193ScanTypeInaccessible.cs | 206 ++++++++++++++++++ .../ScanTests.cs | 13 +- Tests/Awaiten.Tests/Awaiten.Tests.csproj | 7 + 8 files changed, 322 insertions(+), 27 deletions(-) create mode 100644 Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt193ScanTypeInaccessible.cs diff --git a/Docs/pages/09-diagnostics.md b/Docs/pages/09-diagnostics.md index 0db04de6..faf83a82 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, or grant the container's assembly `InternalsVisibleTo`. 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 28de803e..4f1afce7 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 8cf76113..fd0f4fc8 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 c462a746..cc176ab1 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 5980bf8c..51d06dc8 100644 --- a/Source/Awaiten.SourceGenerators/Diagnostics.cs +++ b/Source/Awaiten.SourceGenerators/Diagnostics.cs @@ -1374,4 +1374,23 @@ 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 - unlike + /// AWT188, where only the interface is out of reach and + /// ScanAs.Self still registers the type - because every registration has to name the implementation to + /// construct it. 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 from the scan to say the skip is + /// intended. + /// + 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", + "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 00000000..5aebf7f3 --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt193ScanTypeInaccessible.cs @@ -0,0 +1,206 @@ +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"); + } + } +} diff --git a/Tests/Awaiten.SourceGenerators.Tests/ScanTests.cs b/Tests/Awaiten.SourceGenerators.Tests/ScanTests.cs index 032c3b1d..591ac22d 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 e9862c5d..9ac9cf23 100644 --- a/Tests/Awaiten.Tests/Awaiten.Tests.csproj +++ b/Tests/Awaiten.Tests/Awaiten.Tests.csproj @@ -1,5 +1,12 @@  + + + $(NoWarn);AWT193 + + From 35bda441fb7f294844e4d3110f5cc6737e642935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 17 Jul 2026 12:14:34 +0200 Subject: [PATCH 2/3] fix: address review findings on AWT193 Corrects the NoWarn justification in Awaiten.Tests. The old comment claimed the cross-assembly scans had no way to filter the internal fixture out, which contradicted both the diagnostic's own advice ("exclude it from the scan") and this branch's own DoesNotReportWhenNamePatternsExcludeTheType, which pins that a `!`-prefixed NamePatterns entry silences AWT193 on an inaccessible type without ever naming it. The real reason to suppress rather than filter is that a name filter would become the reason the type is skipped, when those scans exist to prove that the accessibility is. Pins the AWT184 a markerless scan reports alongside AWT193. It was previously unasserted, so the pair was an accident rather than a decision. Both are true and complementary: AWT184 says nothing registered, AWT193 says why, which is strictly more than AWT184 said on its own before. Covers the InternalsVisibleTo remedy the message advertises, which had no test. It holds for an internal type with an implicit constructor; an explicitly internal constructor still reports AWT104 until the sibling accessibility fix lands, so the test pins only the half that is stable here. Rewrites AWT193's XML doc to drop a spaced-hyphen parenthetical, which the rest of Diagnostics.cs expresses with commas and colons instead. --- .../Awaiten.SourceGenerators/Diagnostics.cs | 16 +++++----- ...gnosticTests.Awt193ScanTypeInaccessible.cs | 32 +++++++++++++++++++ Tests/Awaiten.Tests/Awaiten.Tests.csproj | 6 ++-- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/Source/Awaiten.SourceGenerators/Diagnostics.cs b/Source/Awaiten.SourceGenerators/Diagnostics.cs index 51d06dc8..4e04ebf4 100644 --- a/Source/Awaiten.SourceGenerators/Diagnostics.cs +++ b/Source/Awaiten.SourceGenerators/Diagnostics.cs @@ -1377,14 +1377,14 @@ internal static class Diagnostics /// /// 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 - unlike - /// AWT188, where only the interface is out of reach and - /// ScanAs.Self still registers the type - because every registration has to name the implementation to - /// construct it. 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 from the scan to say the skip is - /// intended. + /// (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 from the scan to say the skip is intended. /// public static readonly DiagnosticDescriptor ScanTypeInaccessible = new( "AWT193", diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt193ScanTypeInaccessible.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt193ScanTypeInaccessible.cs index 5aebf7f3..5cc24cc3 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt193ScanTypeInaccessible.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt193ScanTypeInaccessible.cs @@ -201,6 +201,38 @@ 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.Tests/Awaiten.Tests.csproj b/Tests/Awaiten.Tests/Awaiten.Tests.csproj index 9ac9cf23..bfd52a35 100644 --- a/Tests/Awaiten.Tests/Awaiten.Tests.csproj +++ b/Tests/Awaiten.Tests/Awaiten.Tests.csproj @@ -2,8 +2,10 @@ + support assembly, so the cross-assembly scans here can pin that it is not registered. A "!InternalPlugin" + NamePatterns entry would silence the warning, but it would also make the filter the reason the type is + skipped, which is exactly what these scans exist to prove is not the case: it is the accessibility that + skips it. So the warning is expected here, and suppressed rather than filtered away. --> $(NoWarn);AWT193 From ffc92a69cdc0053f3c2977ade604bc9442eee261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 17 Jul 2026 15:02:50 +0200 Subject: [PATCH 3/3] fix: sharpen AWT193 remedy wording and document its NoWarn The message and docs advised 'exclude it from the scan', but the Exclude type list cannot name an inaccessible type; only a NamePatterns/ NamespacePatterns entry can. Say so. Also record why the Awaiten.Tests suppression is project-wide NoWarn: AWT193 is emitted by the source generator, which neither #pragma warning disable nor a per-file .editorconfig severity reaches. --- Docs/pages/09-diagnostics.md | 2 +- Source/Awaiten.SourceGenerators/Diagnostics.cs | 5 +++-- Tests/Awaiten.Tests/Awaiten.Tests.csproj | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Docs/pages/09-diagnostics.md b/Docs/pages/09-diagnostics.md index faf83a82..14ac436f 100644 --- a/Docs/pages/09-diagnostics.md +++ b/Docs/pages/09-diagnostics.md @@ -1255,7 +1255,7 @@ internal sealed class Roaster : 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, or grant the container's assembly `InternalsVisibleTo`. 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. +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 diff --git a/Source/Awaiten.SourceGenerators/Diagnostics.cs b/Source/Awaiten.SourceGenerators/Diagnostics.cs index 4e04ebf4..7855f6ba 100644 --- a/Source/Awaiten.SourceGenerators/Diagnostics.cs +++ b/Source/Awaiten.SourceGenerators/Diagnostics.cs @@ -1384,12 +1384,13 @@ internal static class Diagnostics /// 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 from the scan to say the skip is intended. + /// 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", + "'{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.Tests/Awaiten.Tests.csproj b/Tests/Awaiten.Tests/Awaiten.Tests.csproj index bfd52a35..32f7a5b1 100644 --- a/Tests/Awaiten.Tests/Awaiten.Tests.csproj +++ b/Tests/Awaiten.Tests/Awaiten.Tests.csproj @@ -5,7 +5,7 @@ support assembly, so the cross-assembly scans here can pin that it is not registered. A "!InternalPlugin" NamePatterns entry would silence the warning, but it would also make the filter the reason the type is skipped, which is exactly what these scans exist to prove is not the case: it is the accessibility that - skips it. So the warning is expected here, and suppressed rather than filtered away. --> + skips it. Suppressed project-wide because AWT193 is emitted by the source generator. --> $(NoWarn);AWT193