From c019b39179dfd1f84e0676a005db5e349376c3d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 17 Jul 2026 16:19:46 +0200 Subject: [PATCH 1/8] feat: support lifecycle hooks on [Scan], with generic dispatch (#112) A [Scan] can now name OnActivated/OnRelease, applied to every match and resolved through the same pipeline as an explicit registration's hook. For an open-generic marker the hook may be generic: the match's closed marker form binds its type argument, so [Scan(typeof(IView<>), OnActivated = nameof(WireView))] dispatches WireView(view, vm) with the matching view model resolved from the graph and no reflection. A match that closes the marker more than once is ambiguous and reported as AWT198. --- Docs/pages/09-diagnostics.md | 20 +++++ Docs/pages/lifetime/05-lifecycle-hooks.md | 5 ++ Docs/pages/registration/07-scanning.md | 34 ++++++++ .../AnalyzerReleases.Unshipped.md | 1 + .../AwaitenGenerator.Coalescing.cs | 1 + .../AwaitenGenerator.ModuleScan.cs | 6 +- .../AwaitenGenerator.Production.cs | 56 +++++++++++-- .../AwaitenGenerator.Registrations.cs | 12 ++- .../AwaitenGenerator.Scan.cs | 84 +++++++++++++++++-- .../Awaiten.SourceGenerators/Diagnostics.cs | 15 ++++ Source/Awaiten/ScanAttribute.cs | 32 +++++++ .../Expected/Awaiten_net10.0.txt | 4 + .../Expected/Awaiten_net8.0.txt | 4 + .../Expected/Awaiten_netstandard2.0.txt | 4 + ...gnosticTests.Awt164InvalidLifecycleHook.cs | 47 +++++++++++ ...cTests.Awt198GenericHookAmbiguousMarker.cs | 82 ++++++++++++++++++ Tests/Awaiten.Tests/ScanTests.cs | 78 +++++++++++++++++ 17 files changed, 467 insertions(+), 18 deletions(-) create mode 100644 Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt198GenericHookAmbiguousMarker.cs diff --git a/Docs/pages/09-diagnostics.md b/Docs/pages/09-diagnostics.md index bc21942..da16832 100644 --- a/Docs/pages/09-diagnostics.md +++ b/Docs/pages/09-diagnostics.md @@ -1257,6 +1257,26 @@ 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. +### AWT198 + +:::danger[Error] +A generic lifecycle hook on an open-generic `[Scan]` marker matched a type that closes the marker more than once, so the hook's type argument is ambiguous. +::: + +```csharp +public interface IView; +public sealed class DualView : IView, IView; // closes IView<> twice + +[Container] +[Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] +public static partial class CoffeeShop +{ + private static void Wire(IView view) { } // TViewModel would be Orders or Payments? +} +``` + +A [generic scan hook](./registration/scanning#lifecycle-hooks) binds its type argument from the match's *single* closed marker form, so `DualView` — which closes `IView<>` at both `Orders` and `Payments` — leaves it ambiguous. Register the type explicitly with the intended hook, or split the family so each match closes the marker once. A match closing the marker several times is fine *without* a hook (each closed form registers as its own collection member). + ## Modules ### AWT149 diff --git a/Docs/pages/lifetime/05-lifecycle-hooks.md b/Docs/pages/lifetime/05-lifecycle-hooks.md index 0d89e10..3bf9b94 100644 --- a/Docs/pages/lifetime/05-lifecycle-hooks.md +++ b/Docs/pages/lifetime/05-lifecycle-hooks.md @@ -58,7 +58,12 @@ Combine `Eager` with async and `SyncResolveAfterInit`, and the singleton is cons *Note: hooks are for services the container owns. Setting one on a pre-built `Instance` registration is an error ([AWT165](../diagnostics#awt165)). Conflicting coalesced directives across registrations of one implementation are caught too ([AWT166](../diagnostics#awt166)).* +## Hooks on a scan + +A `[Scan]` can name the same `OnActivated`/`OnRelease` hooks, applied to every match, so a whole scanned family shares one routine. See [Scanning → Lifecycle hooks](../registration/scanning#lifecycle-hooks). + ## Where to go next - [Async initialization](../async-initialization) for `InitializeAsync`. - [Disposal](./disposal) for teardown order. +- [Scanning](../registration/scanning#lifecycle-hooks) to apply a hook to a whole matched family. diff --git a/Docs/pages/registration/07-scanning.md b/Docs/pages/registration/07-scanning.md index b0595d2..4a68bd3 100644 --- a/Docs/pages/registration/07-scanning.md +++ b/Docs/pages/registration/07-scanning.md @@ -100,6 +100,40 @@ An unbound generic marker matches closed forms, the way Autofac's closed-types-o [Scan(typeof(IView<>), As = ScanAs.Marker)] ``` +## Lifecycle hooks + +A scan can name `OnActivated` and `OnRelease` hooks, applied to every match — so a whole family shares one activation or teardown routine without a registration line per type. + +```csharp +[Container] +[Scan(Lifetime = AwaitenLifetime.Singleton, OnActivated = nameof(Prime))] +public static partial class CoffeeShop +{ + private static void Prime(IDrink drink) => drink.Prime(); +} +``` + +The hook's first parameter is the match, so it must accept every one: type it as the scanned marker (or `object`). Parameters after it are resolved from the graph exactly as for an [explicit registration's hook](../lifetime/lifecycle-hooks#hook-parameters), and the same rules and diagnostics apply — an unusable hook name is [AWT164](../diagnostics#awt164), an unregistered parameter is [AWT101](../diagnostics#awt101). A hook that conflicts with an explicit registration of the same type is caught as [AWT166](../diagnostics#awt166). + +### Generic hooks on an open marker + +An [open generic marker](#open-generic-markers) knows each match's closed type argument at compile time, so a hook can be *generic* and receive it directly — no reflection, no `object`. The classic case is a WPF-style view/view-model family: bind each view to its matching view model as it is activated. + +```csharp +[Container] +[Singleton] +[Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(WireView))] +public static partial class App +{ + private static void WireView(IView view, TViewModel viewModel) + => view.DataContext = viewModel; +} +``` + +For `MainWindow : IView` the generator dispatches `WireView(mainWindow, viewModel)`, resolving the matching `IMainViewModel` from the graph. The type argument is visible to graph analysis, so an unregistered view model fails the build ([AWT101](../diagnostics#awt101)) rather than at runtime. A non-generic method works too (its parameters typed as the marker); the type argument only applies when the method is generic. + +Because the type argument comes from the match's *single* closed marker form, a match that closes the marker more than once (`Dual : IView, IView`) is ambiguous and reported as [AWT198](../diagnostics#awt198) — register such a type explicitly with the hook it needs. + ## Overriding a scanned type An explicit registration of a scanned type wins over the scan, so you can special-case one drink while scanning the rest. diff --git a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md index 3f2705c..bdd34e3 100644 --- a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -99,6 +99,7 @@ AWT195 | Awaiten | Error | A self-compiled module [Scan] match has a constructor parameter type inaccessible outside the module's assembly AWT196 | Awaiten | Warning | A self-compiled module [Scan] match has no exposure interface accessible outside the module's assembly, so it is skipped AWT197 | Awaiten | Error | A self-compiled module [Scan] match would be exposed under multiple interfaces, which a single-exposure factory cannot express + AWT198 | Awaiten | Error | A generic lifecycle hook on an open-generic [Scan] marker matched a type that closes the marker more than once, so the hook's type argument is ambiguous AWT200 | Awaiten | Error | A self-compiled module [Scan] match carries [Inject]/[Arg] metadata the generated factory cannot mirror AWT201 | Awaiten | Error | A generic [Module] (or one nested in a generic type) declares a [Scan], so no closed module exists for a consumer to import AWT202 | Awaiten | Error | A module [Scan] declares InAssembliesOf, but a self-compiled scan sweeps only the module's own assembly diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs index c722c7b..12a3a7f 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs @@ -187,6 +187,7 @@ static ImplInfo EnsureImpl(Dictionary implInfos, List Parameters) Resolve { // 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. - if (member is IMethodSymbol { MethodKind: MethodKind.Ordinary, IsStatic: true, ReturnsVoid: true, Parameters.Length: >= 1, } method - && compilation.HasImplicitConversion(info.Symbol, method.Parameters[0].Type) - && (info.Origin is null || compilation.IsSymbolAccessibleWithin(method, containerSymbol))) + if (member is not IMethodSymbol { MethodKind: MethodKind.Ordinary, IsStatic: true, ReturnsVoid: true, Parameters.Length: >= 1, } method + || (info.Origin is not null && !compilation.IsSymbolAccessibleWithin(method, containerSymbol))) { - matches.Add(method); + continue; + } + + // A generic hook (named on an open-generic [Scan] marker) binds its type parameters from the match's + // closed marker form, so WireView is dispatched as WireView; a non-generic + // method is used as-is. The first parameter of the constructed method must accept the instance. + if (ConstructHook(method, info.HookClosedMarker) is { } constructed + && compilation.HasImplicitConversion(info.Symbol, constructed.Parameters[0].Type)) + { + matches.Add(constructed); } } if (matches.Count == 1) { - return (QualifiedHook(info, hookName), ClassifyHookParameters(matches[0], info, release, context)); + return (QualifiedHook(info, hookName, matches[0]), ClassifyHookParameters(matches[0], info, release, context)); } // No usable match is AWT164 (unusable hook); more than one is AWT190 (an overload the container cannot pick @@ -410,10 +418,42 @@ private static EquatableArray ClassifyHookParameters(IMethodSymb /// /// A module's lifecycle hook is emitted qualified with the module type (the generated container is another /// class, so the simple name would not bind); the container's own hooks stay unqualified, in scope inside - /// the generated partial. Mirrors QualifiedProductionMember for Factory/Instance members. + /// the generated partial. Mirrors QualifiedProductionMember for Factory/Instance members. A generic + /// hook carries its bound type arguments (WireView<global::App.IMainViewModel>) so the emitter, + /// which writes the name verbatim, needs no generic awareness. /// - private static string QualifiedHook(ImplInfo info, string hookName) - => info.Origin is { } origin ? $"{origin.ToDisplayString(FullyQualified)}.{hookName}" : hookName; + private static string QualifiedHook(ImplInfo info, string hookName, IMethodSymbol hook) + { + string name = info.Origin is { } origin ? $"{origin.ToDisplayString(FullyQualified)}.{hookName}" : hookName; + if (hook.TypeArguments.Length == 0) + { + return name; + } + + string arguments = string.Join(", ", hook.TypeArguments.Select(argument => argument.ToDisplayString(FullyQualified))); + return $"{name}<{arguments}>"; + } + + /// + /// Binds a lifecycle hook method's type parameters for dispatch: a non-generic method (arity 0) is used + /// as-is, and a generic method is constructed from the match's closed marker form's type arguments + /// () when the arities match. Returns when a generic + /// method has no marker to bind or the arities differ, so it is not a usable hook and falls through to AWT164. + /// + private static IMethodSymbol? ConstructHook(IMethodSymbol method, INamedTypeSymbol? closedMarker) + { + if (method.Arity == 0) + { + return method; + } + + if (closedMarker is null || method.Arity != closedMarker.TypeArguments.Length) + { + return null; + } + + return method.Construct(closedMarker.TypeArguments.ToArray()); + } /// /// Reports AWT106 when a synchronous diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Registrations.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Registrations.cs index 715e05a..71f5102 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Registrations.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Registrations.cs @@ -45,7 +45,8 @@ internal sealed record RawRegistration( string? OnRelease = null, bool SuppressDisposal = false, string? WhenInjectedInto = null, - bool GreedyConstructor = false); + bool GreedyConstructor = false, + INamedTypeSymbol? HookClosedMarker = null); /// /// An imported module: its symbol and the location of the container's [Import] attribute that @@ -180,6 +181,15 @@ public ImplInfo( /// public string? OnRelease { get; init; } + /// + /// The closed marker form a generic scan hook binds its type argument from - set only when the winning + /// registration came from a [Scan(typeof(IView<>))] open-generic marker that names a hook, so + /// MainWindow : IView<IMainViewModel> carries IView<IMainViewModel> here and its + /// hook is dispatched as WireView<IMainViewModel>. for a non-generic + /// hook (the type argument does not apply), resolved in ResolveHook. + /// + public INamedTypeSymbol? HookClosedMarker { get; init; } + /// /// Whether the winning registration opted out of the container's built-in disposal /// (SuppressDisposal = true): the container constructs the instance but never calls its diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs index e27ff92..1cc3b35 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs @@ -16,7 +16,7 @@ partial class AwaitenGenerator /// 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 + /// AWT138/AWT139/AWT140/AWT143/AWT172/AWT173/AWT174/AWT182/AWT183/AWT184/AWT185/AWT187/AWT188/AWT193/AWT198. The synthesized /// registrations are , so an explicit registration wins single /// resolution while every match still joins its collection. /// @@ -82,7 +82,13 @@ private static void ExpandScan( return; } - ScanMatch match = new(ScanExposureOf(attribute), ScanLifetime(attribute), location, ScanSkipsUnconstructable(attribute)); + ScanMatch match = new( + ScanExposureOf(attribute), + ScanLifetime(attribute), + location, + ScanSkipsUnconstructable(attribute), + ScanHook(attribute, "OnActivated"), + ScanHook(attribute, "OnRelease")); ScanFilters filters = ScanFiltersOf(attribute); // AWT185: As resolved to no recognized ScanAs flag (e.g. `Self & Marker`, or an out-of-range cast), so the @@ -140,7 +146,8 @@ private static void ExpandScan( continue; } - produced += RegisterScanMatch(candidate.Type, ScanContracts(candidate.Type, match, marker, openMarker, markerDefinition, compilation), markerDisplay, match, result, diagnostics); + (ScanMatch candidateMatch, INamedTypeSymbol? hookClosedMarker) = ResolveScanHook(candidate.Type, match, openMarker, markerDefinition, location, diagnostics); + produced += RegisterScanMatch(candidate.Type, ScanContracts(candidate.Type, match, marker, openMarker, markerDefinition, compilation), markerDisplay, candidateMatch, hookClosedMarker, result, diagnostics); } ReportScanFilterDiagnostics(filters, hits, new ScanFilterCounts(assignable, registered, produced), assemblies, markerDisplay, location, diagnostics); @@ -270,6 +277,7 @@ private static int RegisterScanMatch( ScanContractSet contracts, string? markerDisplay, ScanMatch match, + INamedTypeSymbol? hookClosedMarker, List result, List diagnostics) { @@ -278,13 +286,13 @@ private static int RegisterScanMatch( if (match.RegisterSelf) { - result.Add(ScanRegistration(typeName, typeName, type, type, match)); + result.Add(ScanRegistration(typeName, typeName, type, type, match, hookClosedMarker)); produced++; } foreach (INamedTypeSymbol contract in contracts.Contracts) { - result.Add(ScanRegistration(contract.ToDisplayString(FullyQualified), typeName, type, contract, match)); + result.Add(ScanRegistration(contract.ToDisplayString(FullyQualified), typeName, type, contract, match, hookClosedMarker)); produced++; } @@ -385,7 +393,13 @@ private static List ClosedMarkerInterfaces(INamedTypeSymbol ty /// the attribute location for diagnostics, and whether an unconstructable match is skipped with a warning /// (SkipUnconstructable) instead of erroring. Bundled so the per-match registration takes one handle. /// - private sealed record ScanMatch(ScanExposures Exposure, Lifetime Lifetime, Location? Location, bool SkipUnconstructable) + private sealed record ScanMatch( + ScanExposures Exposure, + Lifetime Lifetime, + Location? Location, + bool SkipUnconstructable, + string? OnActivated, + string? OnRelease) { public bool RegisterSelf => (Exposure & ScanExposures.Self) != 0; @@ -604,8 +618,44 @@ private static bool HasOpenTypeParameters(INamedTypeSymbol type) /// service's collection, carrying the scan's SkipUnconstructable opt-in for the /// unconstructable-match prune. /// - private static RawRegistration ScanRegistration(string service, string implementation, INamedTypeSymbol type, INamedTypeSymbol serviceSymbol, ScanMatch match) - => new(service, implementation, match.Lifetime, type, match.Location, ProductionKind.Constructor, null, false, null, serviceSymbol, true, match.SkipUnconstructable); + private static RawRegistration ScanRegistration(string service, string implementation, INamedTypeSymbol type, INamedTypeSymbol serviceSymbol, ScanMatch match, INamedTypeSymbol? hookClosedMarker) + => new(service, implementation, match.Lifetime, type, match.Location, ProductionKind.Constructor, null, false, null, serviceSymbol, true, match.SkipUnconstructable, + OnActivated: match.OnActivated, OnRelease: match.OnRelease, HookClosedMarker: hookClosedMarker); + + /// + /// Resolves one match's lifecycle hooks for a [Scan]. For an open-generic marker that names a hook, the + /// closed marker form the match implements binds the hook's type argument, so + /// MainWindow : IView<IMainViewModel> dispatches WireView<IMainViewModel>. A match + /// that closes the marker more than once has an ambiguous type argument, reported as AWT198 with the hooks + /// dropped for that match (so no secondary AWT164 fires from resolving a generic method non-generically). A + /// closed or markerless scan, or a scan with no hook, passes the scan's hooks through unchanged and binds no + /// type argument (the hook is resolved non-generically in ResolveHook). + /// + private static (ScanMatch Match, INamedTypeSymbol? HookClosedMarker) ResolveScanHook( + INamedTypeSymbol type, + ScanMatch match, + bool openMarker, + INamedTypeSymbol? markerDefinition, + Location? location, + List diagnostics) + { + if (!openMarker || (match.OnActivated is null && match.OnRelease is null)) + { + return (match, null); + } + + List closed = ClosedMarkerForms(type, markerDefinition!); + if (closed.Count == 1) + { + return (match, closed[0]); + } + + diagnostics.Add(new DiagnosticInfo( + Diagnostics.GenericHookAmbiguousMarker, + LocationInfo.From(location), + new EquatableArray([Display(type.ToDisplayString(FullyQualified)), Display(markerDefinition!.ToDisplayString(FullyQualified)),]))); + return (match with { OnActivated = null, OnRelease = null, }, null); + } /// - private static ITypeSymbol SubstituteTypeParameters(ITypeSymbol type, IMethodSymbol method, ImmutableArray arguments) + private static ITypeSymbol SubstituteTypeParameters(ITypeSymbol type, IMethodSymbol method, ImmutableArray arguments, Compilation compilation) { if (type is ITypeParameterSymbol parameter && SymbolEqualityComparer.Default.Equals(parameter.DeclaringMethod, method)) { @@ -573,10 +583,15 @@ private static ITypeSymbol SubstituteTypeParameters(ITypeSymbol type, IMethodSym if (type is INamedTypeSymbol { IsGenericType: true, } named) { return named.ConstructedFrom.Construct(named.TypeArguments - .Select(argument => SubstituteTypeParameters(argument, method, arguments)) + .Select(argument => SubstituteTypeParameters(argument, method, arguments, compilation)) .ToArray()); } + if (type is IArrayTypeSymbol array) + { + return compilation.CreateArrayTypeSymbol(SubstituteTypeParameters(array.ElementType, method, arguments, compilation), array.Rank); + } + return type; } diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt164InvalidLifecycleHook.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt164InvalidLifecycleHook.cs index ab6dd4a..1629cfa 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt164InvalidLifecycleHook.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt164InvalidLifecycleHook.cs @@ -238,5 +238,37 @@ await That(result.Diagnostics).Contains("*AWT164*").AsWildcard() await That(result.Diagnostics).DoesNotContain("*error CS*").AsWildcard() .Because("the violation is caught before construction rather than surfacing as a compiler error inside the generated source"); } + + [Fact] + public async Task DoesNotReportForAGenericScanHookConstraintContainingAnArrayOfTheTypeParameter() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Collections; + using System.Collections.Generic; + + namespace MyCode; + + public interface IView { } + public sealed class Rows : IEnumerable + { + public IEnumerator GetEnumerator() => null!; + IEnumerator IEnumerable.GetEnumerator() => null!; + } + public sealed class RowsView : IView { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(IView view) where TViewModel : IEnumerable { } + } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT164*").AsWildcard() + .Because("Rows satisfies IEnumerable, so the constraint check must substitute the type parameter inside the array element type too"); + await That(result.Diagnostics).IsEmpty() + .Because("the closing binds cleanly, so the hook is constructed as Wire without any diagnostic"); + } } } diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt190AmbiguousLifecycleHook.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt190AmbiguousLifecycleHook.cs index ae829c9..fcb653c 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt190AmbiguousLifecycleHook.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt190AmbiguousLifecycleHook.cs @@ -79,6 +79,56 @@ await That(result.Diagnostics).DoesNotContain("*AWT164*").AsWildcard() .Because("the int overload does not accept the instance, so there is exactly one usable hook and no ambiguity"); } + [Fact] + public async Task ReportsWhenAGenericScanHookOverloadBindsBesideANonGenericOne() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IView { } + public sealed class MainWindow : IView { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(IView view) { } + private static void Wire(object instance) { } + } + """); + + await That(result.Diagnostics).Contains("*AWT190*").AsWildcard() + .Because("the generic overload binds the single closing and the object overload also accepts the match, so the container cannot choose one"); + } + + [Fact] + public async Task ReportsWhenAnAmbiguousGenericScanHookOverloadSitsBesideANonGenericOne() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IView { } + public sealed class DualView : IView, IView { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(IView view) { } + private static void Wire(object instance) { } + } + """); + + await That(result.Diagnostics).Contains("*AWT190*").AsWildcard() + .Because("the generic overload could dispatch through either closing and the object overload also accepts the match, so silently preferring the sibling would let an extra closing change which method runs"); + await That(result.Diagnostics).DoesNotContain("*AWT198*").AsWildcard() + .Because("the collision is between overloads, not closings: settling the closings would still leave two usable overloads"); + } + [Fact] public async Task DoesNotReportWhenTheSameNameServesDistinctRegistrationsOneMatchEach() { From 43546eae839d4a489209e321c409a0aa76ac9d5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sun, 19 Jul 2026 18:48:54 +0200 Subject: [PATCH 6/8] fix: keep inaccessible or constraint-violating closings out of generic hook binding Three review findings on generic scan hook binding, all leaking raw compiler errors into the generated source or falsely rejecting valid closings: - A closed marker form whose type argument is inaccessible to the container (for example an internal view model from another assembly) passed binding and emitted CS0122/CS1503 inside the generated code. Such forms are now unbindable, falling to AWT164 when they were the only candidate and quietly settling to an accessible sibling otherwise, consistent with the constraint-settling behavior. - Constraint checking used HasImplicitConversion, which admits user-defined implicit operators that C# constraint satisfaction rejects, producing CS0311 in generated source. It now classifies the conversion and accepts only identity, implicit reference, and boxing conversions. - SubstituteTypeParameters did not substitute the containing-type arguments of nested constraint types like Outer.Inner, falsely rejecting valid closings. Substitution now walks the containing chain and re-finds the nested member, failing safe to rejection if the container cannot be resolved. Adds regression tests for all three cases and documents the accessibility criterion in the AWT198 and scanning pages. --- Docs/pages/09-diagnostics.md | 2 +- Docs/pages/registration/07-scanning.md | 2 +- .../AwaitenGenerator.Production.cs | 98 +++++++++++++++---- ...gnosticTests.Awt164InvalidLifecycleHook.cs | 89 +++++++++++++++++ ...cTests.Awt198GenericHookAmbiguousMarker.cs | 30 ++++++ 5 files changed, 199 insertions(+), 22 deletions(-) diff --git a/Docs/pages/09-diagnostics.md b/Docs/pages/09-diagnostics.md index d8ccc8c..491a838 100644 --- a/Docs/pages/09-diagnostics.md +++ b/Docs/pages/09-diagnostics.md @@ -1275,7 +1275,7 @@ public static partial class CoffeeShop } ``` -A [generic scan hook](./registration/scanning#lifecycle-hooks) binds its type argument from a closed marker form, so it needs exactly one it can bind. `DualView`, which closes `IView<>` at both `Orders` and `Payments`, offers two; the same happens when two scans bind the same generic hook through differently-closed markers of one type. Only forms the hook could actually bind count: matching arity, satisfied constraints, and a first parameter that accepts the match. If exactly one form remains after those checks the hook binds it, so a `where TViewModel : class` constraint can settle a family that closes the marker at one class and one struct. Register the type explicitly with the intended hook, or split the family so only one closed form binds it. Reported only when the ambiguous generic method is the sole usable overload of the hook name: if another overload also accepts the match, the collision is between overloads rather than closings (settling the closings would still leave two usable overloads), so it is [AWT190](#awt190) instead. Only a *generic* hook is affected: a non-generic hook (its parameter typed as the marker or `object`) takes no type argument, so a match with several closings is fine for it, as is a match closing the marker several times *without* any hook (each closed form registers as its own collection member). +A [generic scan hook](./registration/scanning#lifecycle-hooks) binds its type argument from a closed marker form, so it needs exactly one it can bind. `DualView`, which closes `IView<>` at both `Orders` and `Payments`, offers two; the same happens when two scans bind the same generic hook through differently-closed markers of one type. Only forms the hook could actually bind count: matching arity, type arguments the generated container can access, satisfied constraints, and a first parameter that accepts the match. If exactly one form remains after those checks the hook binds it, so a `where TViewModel : class` constraint can settle a family that closes the marker at one class and one struct, and a closing at another assembly's internal type yields to an accessible sibling. Register the type explicitly with the intended hook, or split the family so only one closed form binds it. Reported only when the ambiguous generic method is the sole usable overload of the hook name: if another overload also accepts the match, the collision is between overloads rather than closings (settling the closings would still leave two usable overloads), so it is [AWT190](#awt190) instead. Only a *generic* hook is affected: a non-generic hook (its parameter typed as the marker or `object`) takes no type argument, so a match with several closings is fine for it, as is a match closing the marker several times *without* any hook (each closed form registers as its own collection member). ### AWT199 diff --git a/Docs/pages/registration/07-scanning.md b/Docs/pages/registration/07-scanning.md index e7bbcd8..b58d935 100644 --- a/Docs/pages/registration/07-scanning.md +++ b/Docs/pages/registration/07-scanning.md @@ -134,7 +134,7 @@ public static partial class App For `MainWindow : IView` the generator dispatches `WireView(mainWindow, viewModel)`, resolving the matching `IMainViewModel` from the graph. The type argument is visible to graph analysis, so an unregistered view model fails the build ([AWT101](../diagnostics#awt101)) rather than at runtime. A non-generic method works too (its parameters typed as the marker or `object`); the type argument only applies when the method is generic. -Because a *generic* hook takes its type argument from a closed marker form, it needs exactly one it can bind. A match offering several, by closing the marker more than once (`Dual : IView, IView`), leaves that argument ambiguous and is reported as [AWT198](../diagnostics#awt198): register such a type explicitly with the hook it needs, or split the family so only one closed form binds it. Only forms the hook could actually bind count, so a constraint (`where TViewModel : class`) that rules out all closings but one settles the choice. If the hook name is overloaded and another overload also accepts the match, the collision is between overloads rather than closings and is reported as [AWT190](../diagnostics#awt190) instead. A non-generic hook takes no type argument, so a match with several closings is fine for it. +Because a *generic* hook takes its type argument from a closed marker form, it needs exactly one it can bind. A match offering several, by closing the marker more than once (`Dual : IView, IView`), leaves that argument ambiguous and is reported as [AWT198](../diagnostics#awt198): register such a type explicitly with the hook it needs, or split the family so only one closed form binds it. Only forms the hook could actually bind count, so a constraint (`where TViewModel : class`) that rules out all closings but one settles the choice, as does accessibility: a closing at another assembly's internal type cannot be dispatched and yields to an accessible sibling. If the hook name is overloaded and another overload also accepts the match, the collision is between overloads rather than closings and is reported as [AWT190](../diagnostics#awt190) instead. A non-generic hook takes no type argument, so a match with several closings is fine for it. ## Overriding a scanned type diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs index a09741f..e80c652 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs @@ -3,6 +3,7 @@ using Awaiten.SourceGenerators.Internals; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; namespace Awaiten.SourceGenerators; @@ -289,8 +290,9 @@ private static InstanceModel BuildPrebuiltInstance( /// as its first parameter, and AWT190 (also returning /// (null, empty)) when more than one does, so the choice would be order-dependent. A generic hook /// (named on an open-generic [Scan] marker) binds its type arguments from the closed marker forms - /// kept per hook slot on ImplInfo: exactly one bindable form (matching arity, satisfied constraints, - /// a first parameter accepting the match; see ) dispatches the + /// kept per hook slot on ImplInfo: exactly one bindable form (matching arity, accessible type + /// arguments, satisfied constraints, a first parameter accepting the match; see + /// ) dispatches the /// constructed method, none leaves it unusable like any other shape mismatch (falling through to AWT164), /// and more than one is reported as AWT198 when it /// is the only usable overload (a non-generic hook, needing no arguments, is unaffected). A multi-form @@ -342,7 +344,7 @@ private static (string? Hook, EquatableArray Parameters) Resolve // leaves the type arguments ambiguous (AWT198, with the forms recorded for the message) yet still a // usable overload, so it counts toward AWT190 when a sibling also matches; none means the method is // not a usable hook and falls through to AWT164 with any other shape mismatch. - List<(INamedTypeSymbol Form, IMethodSymbol Constructed)> bindable = BindableHookConstructions(method, closedMarkers, info.Symbol, compilation); + List<(INamedTypeSymbol Form, IMethodSymbol Constructed)> bindable = BindableHookConstructions(method, closedMarkers, info.Symbol, containerSymbol, compilation); if (bindable.Count == 1) { matches.Add(bindable[0].Constructed); @@ -487,24 +489,30 @@ private static string QualifiedHook(ImplInfo info, string hookName, IMethodSymbo /// /// The closed marker forms a generic hook method can actually bind, each paired with the method constructed - /// from its type arguments. A form is bindable when its arity matches the method's, its type arguments - /// satisfy the method's constraints (see , so the constructed call - /// compiles rather than erroring inside the generated source), and the constructed method's first parameter - /// accepts the implementation. Two forms constructing the identical method (say IView<int> and - /// IEditor<int> both yielding Wire<int>) count once: the dispatch is the same, so - /// there is nothing to be ambiguous about. An empty result means the method is not a usable hook (AWT164); - /// more than one entry is the ambiguity AWT198 reports. + /// from its type arguments. A form is bindable when its arity matches the method's, the form (including its + /// type arguments, which become the constructed call's) is accessible from the generated container (an + /// internal type argument from a scanned foreign assembly would otherwise leak CS0122 into the generated + /// source), its type arguments satisfy the method's constraints (see , so + /// the constructed call compiles rather than erroring inside the generated source), and the constructed + /// method's first parameter accepts the implementation. Two forms constructing the identical method (say + /// IView<int> and IEditor<int> both yielding Wire<int>) count once: + /// the dispatch is the same, so there is nothing to be ambiguous about. An empty result means the method is + /// not a usable hook (AWT164); more than one entry is the ambiguity AWT198 reports. Like a constraint, the + /// accessibility check can settle a family: an inaccessible closing beside an accessible one leaves a single + /// bindable form rather than an ambiguity. /// private static List<(INamedTypeSymbol Form, IMethodSymbol Constructed)> BindableHookConstructions( IMethodSymbol method, IReadOnlyList closedMarkers, INamedTypeSymbol implementation, + INamedTypeSymbol containerSymbol, Compilation compilation) { List<(INamedTypeSymbol Form, IMethodSymbol Constructed)> bindable = new(); foreach (INamedTypeSymbol form in closedMarkers) { if (method.Arity != form.TypeArguments.Length + || !compilation.IsSymbolAccessibleWithin(form, containerSymbol) || !SatisfiesConstraints(method, form.TypeArguments, compilation)) { continue; @@ -527,8 +535,12 @@ private static string QualifiedHook(ImplInfo info, string hookName, IMethodSymbo /// otherwise surface as a raw compiler error inside the generated source). Covers the constraint kinds C# /// has: class, struct (excluding Nullable<T>), unmanaged, new(), /// and constraint types, with the method's own type parameters substituted so where T : IComparable<T> - /// is checked at the bound argument. notnull is not checked: violating it is only a compiler warning, - /// so the constructed call still compiles. + /// is checked at the bound argument. A constraint type is satisfied only by an identity, implicit reference, + /// or boxing conversion (see ), the conversions the language admits for + /// constraint satisfaction - notably not a user-defined implicit operator, which + /// HasImplicitConversion would accept and which would leak CS0311 into the generated source. + /// notnull is not checked: violating it is only a compiler warning, so the constructed call still + /// compiles. /// private static bool SatisfiesConstraints(IMethodSymbol method, ImmutableArray arguments, Compilation compilation) { @@ -547,7 +559,7 @@ private static bool SatisfiesConstraints(IMethodSymbol method, ImmutableArray + /// Whether a type argument satisfies one constraint type under the language's constraint-satisfaction rules: + /// an identity, implicit reference, or boxing conversion. A user-defined implicit operator does not count + /// (the compiler rejects such a closing with CS0311), which is why this is not + /// Compilation.HasImplicitConversion. + /// + private static bool SatisfiesConstraintType(ITypeSymbol argument, ITypeSymbol constraint, Compilation compilation) + { + Conversion conversion = ((CSharpCompilation)compilation).ClassifyConversion(argument, constraint); + return conversion.IsIdentity || (conversion.IsImplicit && (conversion.IsReference || conversion.IsBoxing)); + } + /// /// Whether a type argument satisfies new(): any value type, or a non-abstract type with a public /// parameterless instance constructor. @@ -569,9 +593,10 @@ private static bool SatisfiesConstructorConstraint(ITypeSymbol argument) /// /// Substitutes a generic hook method's own type parameters with the bound arguments inside a constraint type, /// so where T : IComparable<T> becomes IComparable<int> for the conversion check in - /// . Descends into generic type arguments and array element types - /// (where T : IEnumerable<T[]>); anything else that is not the method's type parameter passes - /// through unchanged. + /// . Descends into generic type arguments, array element types + /// (where T : IEnumerable<T[]>), and the containing types of a nested constraint type + /// (where T : Outer<T>.IInner, whose type parameter lives on the container rather than the + /// nested type itself); anything else that is not the method's type parameter passes through unchanged. /// private static ITypeSymbol SubstituteTypeParameters(ITypeSymbol type, IMethodSymbol method, ImmutableArray arguments, Compilation compilation) { @@ -580,11 +605,9 @@ private static ITypeSymbol SubstituteTypeParameters(ITypeSymbol type, IMethodSym return arguments[parameter.Ordinal]; } - if (type is INamedTypeSymbol { IsGenericType: true, } named) + if (type is INamedTypeSymbol named) { - return named.ConstructedFrom.Construct(named.TypeArguments - .Select(argument => SubstituteTypeParameters(argument, method, arguments, compilation)) - .ToArray()); + return SubstituteNamedType(named, method, arguments, compilation); } if (type is IArrayTypeSymbol array) @@ -595,6 +618,41 @@ private static ITypeSymbol SubstituteTypeParameters(ITypeSymbol type, IMethodSym return type; } + /// + /// Substitutes through a named constraint type (see ): the containing + /// chain first, so a nested type's outer arguments close too (Outer<T>.IInner becomes + /// Outer<int>.IInner by re-finding IInner on the substituted container), then the type's + /// own arguments. A container lookup miss fails safe by returning the type unsubstituted - the conversion + /// check in then simply rejects the closing (AWT164) instead of + /// constructing a call that would not compile. + /// + private static INamedTypeSymbol SubstituteNamedType(INamedTypeSymbol named, IMethodSymbol method, ImmutableArray arguments, Compilation compilation) + { + INamedTypeSymbol generic; + if (named.ContainingType is { } outer) + { + INamedTypeSymbol? member = SubstituteNamedType(outer, method, arguments, compilation) + .GetTypeMembers(named.Name, named.Arity) + .FirstOrDefault(); + if (member is null) + { + return named; + } + + generic = member; + } + else + { + generic = named.ConstructedFrom; + } + + return named.Arity == 0 + ? generic + : generic.Construct(named.TypeArguments + .Select(argument => SubstituteTypeParameters(argument, method, arguments, compilation)) + .ToArray()); + } + /// /// Reports AWT106 when a synchronous /// factory method's body provably returns a concrete type that implements IAsyncInitializable diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt164InvalidLifecycleHook.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt164InvalidLifecycleHook.cs index 1629cfa..c0ed989 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt164InvalidLifecycleHook.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt164InvalidLifecycleHook.cs @@ -270,5 +270,94 @@ await That(result.Diagnostics).DoesNotContain("*AWT164*").AsWildcard() await That(result.Diagnostics).IsEmpty() .Because("the closing binds cleanly, so the hook is constructed as Wire without any diagnostic"); } + + [Fact] + public async Task ReportsForAGenericScanHookClosingAtAnInaccessibleTypeArgument() + { + GeneratorResult result = Generator.RunWithReferencedAssembly(""" + namespace Lib; + + public interface IView { } + internal sealed class SecretVm { } + public sealed class TheView : IView { } + """, """ + using Awaiten; + using Lib; + + namespace MyCode; + + [Container] + [Scan(typeof(IView<>), InAssembliesOf = new[] { typeof(IView<>) }, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(IView view) { } + } + """); + + await That(result.Diagnostics).Contains("*AWT164*").AsWildcard() + .Because("the container cannot name the internal SecretVm as a type argument, so the closing is unbindable and the hook unusable"); + await That(result.Diagnostics).DoesNotContain("*error CS*").AsWildcard() + .Because("the inaccessible closing is rejected before construction rather than leaking CS0122 into the generated source"); + } + + [Fact] + public async Task ReportsForAGenericScanHookConstraintSatisfiedOnlyByAUserDefinedConversion() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public class Widget { } + public sealed class WidgetModel + { + public static implicit operator Widget(WidgetModel model) => new Widget(); + } + public interface IView { } + public sealed class WidgetView : IView { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(IView view) where TViewModel : Widget { } + } + """); + + await That(result.Diagnostics).Contains("*AWT164*").AsWildcard() + .Because("constraint satisfaction admits only identity, reference, and boxing conversions, so the user-defined operator does not make WidgetModel satisfy the Widget constraint"); + await That(result.Diagnostics).DoesNotContain("*error CS*").AsWildcard() + .Because("the violation is caught before construction rather than leaking CS0311 into the generated source"); + } + + [Fact] + public async Task DoesNotReportForAGenericScanHookConstraintNamingANestedTypeOfTheTypeParameter() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public class Outer + { + public interface IInner { } + } + public sealed class Model : Outer.IInner { } + public interface IView { } + public sealed class ModelView : IView { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(IView view) where TViewModel : Outer.IInner { } + } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT164*").AsWildcard() + .Because("Model satisfies Outer.IInner, so the constraint check must substitute the type parameter through the nested type's containing type too"); + await That(result.Diagnostics).IsEmpty() + .Because("the closing binds cleanly, so the hook is constructed as Wire without any diagnostic"); + } } } diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt198GenericHookAmbiguousMarker.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt198GenericHookAmbiguousMarker.cs index 4eb0129..feeb1b1 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt198GenericHookAmbiguousMarker.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt198GenericHookAmbiguousMarker.cs @@ -229,6 +229,36 @@ await That(result.Diagnostics).IsEmpty() .Because("the class constraint rules out the int closing, so only IView can bind the hook and nothing is ambiguous"); } + [Fact] + public async Task DoesNotReportWhenAccessibilityLeavesASingleBindableForm() + { + GeneratorResult result = Generator.RunWithReferencedAssembly(""" + namespace Lib; + + public interface IView { } + public sealed class PublicVm { } + internal sealed class SecretVm { } + public sealed class DualView : IView, IView { } + """, """ + using Awaiten; + using Lib; + + namespace MyCode; + + [Container] + [Scan(typeof(IView<>), InAssembliesOf = new[] { typeof(IView<>) }, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(IView view) { } + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("the container cannot name the internal SecretVm, so like a constraint the accessibility check settles the family on IView"); + await That(result.Sources.Values.Any(source => source.Contains("Wire"))).IsTrue() + .Because("the single accessible closing is the one dispatched"); + } + [Fact] public async Task ReportsAwt164WhenTheGenericHookAritiesNeverMatchTheMarker() { From 8d5a7d5cff5375df081d0b8accdaa18df2d98ea0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Mon, 20 Jul 2026 12:58:06 +0200 Subject: [PATCH 7/8] refactor: address sonar findings in scan hook binding Extract the hook overload collection loop out of ResolveHook into CollectHookOverloads, which drops the method's cognitive complexity and lets the ambiguous-marker case be tracked as a list of closed-form sets rather than a counter plus a separately nulled-out list, removing the always-true null guard. Simplify the marker union in MergeScanHookSlot and the bindable-form filter in BindableHookConstructions to use Where instead of a continue guard. --- .../AwaitenGenerator.Coalescing.cs | 8 +- .../AwaitenGenerator.Production.cs | 104 ++++++++++-------- 2 files changed, 62 insertions(+), 50 deletions(-) diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs index 12405e2..4bbd970 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs @@ -259,12 +259,10 @@ private static void MergeScanHooks( if (registration.HookClosedMarkers is { } markers) { - foreach (INamedTypeSymbol marker in markers) + foreach (INamedTypeSymbol marker in markers.Where(marker => + !slotMarkers.Any(seen => SymbolEqualityComparer.Default.Equals(seen, marker)))) { - if (!slotMarkers.Any(seen => SymbolEqualityComparer.Default.Equals(seen, marker))) - { - slotMarkers.Add(marker); - } + slotMarkers.Add(marker); } } diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs index e80c652..aa35743 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs @@ -310,13 +310,64 @@ private static (string? Hook, EquatableArray Parameters) Resolve return (null, default); } + (List matches, List> ambiguousMethods) = + CollectHookOverloads(info, hookName, release, context); + + if (matches.Count == 1 && ambiguousMethods.Count == 0) + { + return (QualifiedHook(info, hookName, matches[0]), ClassifyHookParameters(matches[0], info, release, context)); + } + + // A generic hook whose only obstacle is an ambiguous closed marker, with no other usable overload, is + // AWT198, distinct from an unusable name (AWT164) or an overload the container cannot pick between (AWT190): + // several closed forms could bind the hook, so its type arguments cannot be chosen. When another overload + // is also usable the collision is between methods, not closings (settling the closings would still leave + // two usable overloads), so it falls to AWT190 below - exactly as if the generic overload bound uniquely. + if (matches.Count == 0 && ambiguousMethods.Count == 1) + { + context.Diagnostics.Add(new DiagnosticInfo( + Diagnostics.GenericHookAmbiguousMarker, + info.Location, + new EquatableArray([ + Display(info.ImplementationType), + hookName, + string.Join(", ", ambiguousMethods[0].Select(form => Display(form.ToDisplayString(FullyQualified)))), + ]))); + return (null, default); + } + + // No usable match is AWT164 (unusable hook); more than one is AWT190 (an overload the container cannot pick + // between), where a generic overload with several bindable closings counts as usable - it could dispatch, + // so silently preferring its sibling would let an extra marker closing change which method runs. Either way + // no hook is emitted - the error fails the build, and picking one arbitrarily would only add a confusing + // secondary diagnostic from the parameters of the guessed overload. + context.Diagnostics.Add(new DiagnosticInfo( + matches.Count + ambiguousMethods.Count == 0 ? Diagnostics.InvalidLifecycleHook : Diagnostics.AmbiguousLifecycleHook, + info.Location, + new EquatableArray([Display(info.OwningServiceOrImpl), hookName, DescribeOwner(info),]))); + return (null, default); + } + + /// + /// Collects the usable overloads of for : the + /// dispatchable methods (non-generic ones whose first parameter accepts the instance, and generic ones bound + /// to a single closed marker form), and separately the closed-form sets of any generic overload that several + /// forms could bind, which stay usable (an AWT190 collision when a sibling also matches) yet cannot be + /// dispatched (AWT198 when they are the only candidate). The forms of each ambiguous overload are kept so the + /// AWT198 message can name them. + /// + private static (List Matches, List> AmbiguousMethods) CollectHookOverloads( + ImplInfo info, + string hookName, + bool release, + BuildContext context) + { INamedTypeSymbol containerSymbol = context.ContainerSymbol; Compilation compilation = context.Compilation; IReadOnlyList closedMarkers = release ? info.OnReleaseMarkers : info.OnActivatedMarkers; List matches = new(); - List? ambiguousForms = null; - int ambiguousMethods = 0; + List> ambiguousMethods = new(); 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 @@ -351,44 +402,11 @@ private static (string? Hook, EquatableArray Parameters) Resolve } else if (bindable.Count > 1) { - ambiguousMethods++; - ambiguousForms ??= bindable.Select(candidate => candidate.Form).ToList(); + ambiguousMethods.Add(bindable.Select(candidate => candidate.Form).ToList()); } } - if (matches.Count == 1 && ambiguousMethods == 0) - { - return (QualifiedHook(info, hookName, matches[0]), ClassifyHookParameters(matches[0], info, release, context)); - } - - // A generic hook whose only obstacle is an ambiguous closed marker, with no other usable overload, is - // AWT198, distinct from an unusable name (AWT164) or an overload the container cannot pick between (AWT190): - // several closed forms could bind the hook, so its type arguments cannot be chosen. When another overload - // is also usable the collision is between methods, not closings (settling the closings would still leave - // two usable overloads), so it falls to AWT190 below - exactly as if the generic overload bound uniquely. - if (matches.Count == 0 && ambiguousMethods == 1 && ambiguousForms is not null) - { - context.Diagnostics.Add(new DiagnosticInfo( - Diagnostics.GenericHookAmbiguousMarker, - info.Location, - new EquatableArray([ - Display(info.ImplementationType), - hookName, - string.Join(", ", ambiguousForms.Select(form => Display(form.ToDisplayString(FullyQualified)))), - ]))); - return (null, default); - } - - // No usable match is AWT164 (unusable hook); more than one is AWT190 (an overload the container cannot pick - // between), where a generic overload with several bindable closings counts as usable - it could dispatch, - // so silently preferring its sibling would let an extra marker closing change which method runs. Either way - // no hook is emitted - the error fails the build, and picking one arbitrarily would only add a confusing - // secondary diagnostic from the parameters of the guessed overload. - context.Diagnostics.Add(new DiagnosticInfo( - matches.Count + ambiguousMethods == 0 ? Diagnostics.InvalidLifecycleHook : Diagnostics.AmbiguousLifecycleHook, - info.Location, - new EquatableArray([Display(info.OwningServiceOrImpl), hookName, DescribeOwner(info),]))); - return (null, default); + return (matches, ambiguousMethods); } /// @@ -509,15 +527,11 @@ private static string QualifiedHook(ImplInfo info, string hookName, IMethodSymbo Compilation compilation) { List<(INamedTypeSymbol Form, IMethodSymbol Constructed)> bindable = new(); - foreach (INamedTypeSymbol form in closedMarkers) + foreach (INamedTypeSymbol form in closedMarkers.Where(form => + method.Arity == form.TypeArguments.Length + && compilation.IsSymbolAccessibleWithin(form, containerSymbol) + && SatisfiesConstraints(method, form.TypeArguments, compilation))) { - if (method.Arity != form.TypeArguments.Length - || !compilation.IsSymbolAccessibleWithin(form, containerSymbol) - || !SatisfiesConstraints(method, form.TypeArguments, compilation)) - { - continue; - } - IMethodSymbol constructed = method.Construct(form.TypeArguments.ToArray()); if (compilation.HasImplicitConversion(implementation, constructed.Parameters[0].Type) && !bindable.Any(candidate => SymbolEqualityComparer.Default.Equals(candidate.Constructed, constructed))) From b96993fe39d8e635cb5b65af0128b4b2a99ccd7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Mon, 20 Jul 2026 13:18:14 +0200 Subject: [PATCH 8/8] refactor: simplify constraint-type check in SatisfiesConstraints with Any The per-constraint-type loop only returned false on the first unsatisfied constraint, so it reads as an Any over the negated predicate, which short-circuits identically. --- .../AwaitenGenerator.Production.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs index aa35743..2cb4a22 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs @@ -571,12 +571,10 @@ private static bool SatisfiesConstraints(IMethodSymbol method, ImmutableArray + !SatisfiesConstraintType(argument, SubstituteTypeParameters(constraint, method, arguments, compilation), compilation))) { - if (!SatisfiesConstraintType(argument, SubstituteTypeParameters(constraint, method, arguments, compilation), compilation)) - { - return false; - } + return false; } } /// The scanned marker: the type argument of the generic [Scan<TMarker>], or the @@ -662,6 +712,24 @@ private static bool ScanSkipsUnconstructable(AttributeData attribute) return false; } + /// + /// The OnActivated / OnRelease lifecycle hook name () declared on a + /// [Scan], applied to every match, or when unset. Resolved against the + /// container in BuildInstance like any other hook (AWT164/AWT189/AWT190/AWT191). + /// + private static string? ScanHook(AttributeData attribute, string name) + { + foreach (KeyValuePair argument in attribute.NamedArguments) + { + if (argument.Key == name && argument.Value.Value is string value) + { + return value; + } + } + + return null; + } + /// /// The exposure named on a [Scan] (As = ScanAs.X); its underlying int lines up with the /// generator's ScanExposures enum. Defaults to Self when unset, matching the attribute default. diff --git a/Source/Awaiten.SourceGenerators/Diagnostics.cs b/Source/Awaiten.SourceGenerators/Diagnostics.cs index 1b8a68f..0dae000 100644 --- a/Source/Awaiten.SourceGenerators/Diagnostics.cs +++ b/Source/Awaiten.SourceGenerators/Diagnostics.cs @@ -1501,4 +1501,19 @@ internal static class Diagnostics "Awaiten", DiagnosticSeverity.Error, isEnabledByDefault: true); + + /// + /// A generic lifecycle hook (an OnActivated/OnRelease named on a + /// [Scan(typeof(IView<>))] open-generic marker) binds its type argument from the match's closed + /// marker form, so it needs exactly one: this match implements the marker at several closings (say + /// IView<A> and IView<B>), so the type argument would be ambiguous. Register the type + /// explicitly with the intended hook, or split the family so each match closes the marker once. + /// + public static readonly DiagnosticDescriptor GenericHookAmbiguousMarker = new( + "AWT198", + "Ambiguous generic hook marker", + "'{0}' implements the scanned marker '{1}' at more than one closed form, so a generic lifecycle hook's type argument is ambiguous; register the type explicitly with the hook, or ensure it closes the marker only once", + "Awaiten", + DiagnosticSeverity.Error, + isEnabledByDefault: true); } diff --git a/Source/Awaiten/ScanAttribute.cs b/Source/Awaiten/ScanAttribute.cs index d881bd0..94aaf9c 100644 --- a/Source/Awaiten/ScanAttribute.cs +++ b/Source/Awaiten/ScanAttribute.cs @@ -60,6 +60,22 @@ public sealed class ScanAttribute : Attribute /// public bool SkipUnconstructable { get; set; } + /// + /// The name of a static void method on the container invoked once each match has been constructed - a + /// synchronous post-construction hook applied to every match. Its first parameter is the match, so it must + /// accept every one: type it as the scanned marker (or object), and any parameters after it are + /// resolved from the graph like a constructor's. + /// + public string? OnActivated { get; set; } + + /// + /// The name of a static void method on the container invoked when each match's owning container or + /// scope is disposed - in reverse creation order and before the instance's own disposal (in addition to, not + /// instead of, it). Its first parameter is the match (type it as the scanned marker or object); any + /// parameters after it are resolved from the graph. + /// + public string? OnRelease { get; set; } + /// /// Widens the scan to the assemblies containing the listed types instead of the container's own assembly. /// Each entry names one type whose is searched, typically a @@ -124,6 +140,22 @@ public sealed class ScanAttribute : Attribute /// public bool SkipUnconstructable { get; set; } + /// + /// The name of a static void method on the container invoked once each match has been constructed - a + /// synchronous post-construction hook applied to every match. Its first parameter is the match, so it must + /// accept every one: type it as (or object), and any parameters after + /// it are resolved from the graph like a constructor's. + /// + public string? OnActivated { get; set; } + + /// + /// The name of a static void method on the container invoked when each match's owning container or + /// scope is disposed - in reverse creation order and before the instance's own disposal (in addition to, not + /// instead of, it). Its first parameter is the match (type it as or + /// object); any parameters after it are resolved from the graph. + /// + public string? OnRelease { get; set; } + /// /// Widens the scan to the assemblies containing the listed types instead of the container's own assembly. /// Each entry names one type whose is searched, typically a diff --git a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt index 2bd7745..0572beb 100644 --- a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt +++ b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt @@ -240,6 +240,8 @@ namespace Awaiten public Awaiten.AwaitenLifetime Lifetime { get; set; } public string[]? NamePatterns { get; set; } public string[]? NamespacePatterns { get; set; } + public string? OnActivated { get; set; } + public string? OnRelease { get; set; } public bool SkipUnconstructable { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] @@ -252,6 +254,8 @@ namespace Awaiten public Awaiten.AwaitenLifetime Lifetime { get; set; } public string[]? NamePatterns { get; set; } public string[]? NamespacePatterns { get; set; } + public string? OnActivated { get; set; } + public string? OnRelease { get; set; } public bool SkipUnconstructable { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] diff --git a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt index b4b7f75..f4ffdd5 100644 --- a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt +++ b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt @@ -240,6 +240,8 @@ namespace Awaiten public Awaiten.AwaitenLifetime Lifetime { get; set; } public string[]? NamePatterns { get; set; } public string[]? NamespacePatterns { get; set; } + public string? OnActivated { get; set; } + public string? OnRelease { get; set; } public bool SkipUnconstructable { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] @@ -252,6 +254,8 @@ namespace Awaiten public Awaiten.AwaitenLifetime Lifetime { get; set; } public string[]? NamePatterns { get; set; } public string[]? NamespacePatterns { get; set; } + public string? OnActivated { get; set; } + public string? OnRelease { get; set; } public bool SkipUnconstructable { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] diff --git a/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt b/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt index 294e31f..c812f41 100644 --- a/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt +++ b/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt @@ -239,6 +239,8 @@ namespace Awaiten public Awaiten.AwaitenLifetime Lifetime { get; set; } public string[]? NamePatterns { get; set; } public string[]? NamespacePatterns { get; set; } + public string? OnActivated { get; set; } + public string? OnRelease { get; set; } public bool SkipUnconstructable { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] @@ -251,6 +253,8 @@ namespace Awaiten public Awaiten.AwaitenLifetime Lifetime { get; set; } public string[]? NamePatterns { get; set; } public string[]? NamespacePatterns { get; set; } + public string? OnActivated { get; set; } + public string? OnRelease { get; set; } public bool SkipUnconstructable { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt164InvalidLifecycleHook.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt164InvalidLifecycleHook.cs index 76326d7..4860752 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt164InvalidLifecycleHook.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt164InvalidLifecycleHook.cs @@ -166,5 +166,52 @@ private static void Stopping(object instance) { } await That(result.Diagnostics).DoesNotContain("*AWT164*").AsWildcard() .Because("a void method accepting the implementation (or a base type) is a usable hook"); } + + [Fact] + public async Task ReportsForAScanHookThatNamesNoMethod() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IPlugin { } + public sealed class AlphaPlugin : IPlugin { } + + [Container] + [Scan(typeof(IPlugin), OnActivated = "DoesNotExist")] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).Contains("*AWT164*").AsWildcard() + .Because("a scan's OnActivated is resolved against the container like any other hook"); + } + + [Fact] + public async Task DoesNotReportForAScanHookAcceptingTheMarker() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IPlugin { } + public sealed class AlphaPlugin : IPlugin { } + public sealed class BetaPlugin : IPlugin { } + + [Container] + [Scan(typeof(IPlugin), OnActivated = nameof(Started), OnRelease = nameof(Stopping))] + public static partial class MyContainer + { + private static void Started(IPlugin plugin) { } + private static void Stopping(IPlugin plugin) { } + } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT164*").AsWildcard() + .Because("a hook typed as the scanned marker accepts every match"); + } } } diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt198GenericHookAmbiguousMarker.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt198GenericHookAmbiguousMarker.cs new file mode 100644 index 0000000..4540d45 --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt198GenericHookAmbiguousMarker.cs @@ -0,0 +1,82 @@ +using System.Linq; + +namespace Awaiten.SourceGenerators.Tests; + +public partial class DiagnosticTests +{ + public class Awt198GenericHookAmbiguousMarker + { + [Fact] + public async Task ReportsWhenAMatchClosesTheMarkerMoreThanOnce() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IView { } + public sealed class DualView : IView, IView { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(IView view) { } + } + """); + + await That(result.Diagnostics).Contains("*AWT198*").AsWildcard() + .Because("DualView closes IView<> at both int and string, so a generic hook's type argument is ambiguous"); + } + + [Fact] + public async Task DoesNotReportForASingleClosedForm() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IView { } + public interface IMainViewModel { } + public sealed class MainViewModel : IMainViewModel { } + public sealed class MainWindow : IView { } + + [Container] + [Singleton] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(IView view, TViewModel viewModel) { } + } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT198*").AsWildcard() + .Because("MainWindow closes IView<> exactly once, so the generic hook's type argument is unambiguous"); + await That(result.Diagnostics).DoesNotContain("*AWT164*").AsWildcard() + .Because("the generic hook binds its type argument from the single closed marker form and is usable"); + } + + [Fact] + public async Task DoesNotReportWhenNoHookIsNamed() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IView { } + public sealed class DualView : IView, IView { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker)] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT198*").AsWildcard() + .Because("a match closing the marker several times is ordinary without a hook to bind a type argument for"); + } + } +} diff --git a/Tests/Awaiten.Tests/ScanTests.cs b/Tests/Awaiten.Tests/ScanTests.cs index f2669f7..a5d4f78 100644 --- a/Tests/Awaiten.Tests/ScanTests.cs +++ b/Tests/Awaiten.Tests/ScanTests.cs @@ -102,6 +102,84 @@ public sealed class SalesReport : IReport; [Scan(typeof(IReport), As = ScanAs.Self | ScanAs.Marker, Lifetime = AwaitenLifetime.Singleton)] public static partial class SelfAndInterfaceScanContainer; + [Fact] + public async Task Scan_LifecycleHooks_RunForEveryMatch_WithGraphResolvedParameters() + { + HookProbe.Log.Clear(); + using (HookScanContainer.Root container = new()) + { + container.Resolve(); + container.Resolve(); + + // The scan's OnActivated ran once each match was constructed, receiving its graph-resolved HookSettings + // (a scan hook goes through the same parameter pipeline as an explicit registration's hook). + await That(HookProbe.Log).Contains("activated:AlphaHooked:True"); + await That(HookProbe.Log).Contains("activated:BetaHooked:True"); + await That(HookProbe.Log).DoesNotContain("released:AlphaHooked") + .Because("nothing is released while the container is alive"); + } + + // The scan's OnRelease ran for every match on disposal. + await That(HookProbe.Log).Contains("released:AlphaHooked"); + await That(HookProbe.Log).Contains("released:BetaHooked"); + } + + public interface IHooked; + + public sealed class AlphaHooked : IHooked; + + public sealed class BetaHooked : IHooked; + + public sealed class HookSettings; + + private static class HookProbe + { + public static readonly List Log = new(); + } + + [Container] + [Singleton] + [Scan(typeof(IHooked), Lifetime = AwaitenLifetime.Singleton, OnActivated = nameof(Activated), OnRelease = nameof(Released))] + public static partial class HookScanContainer + { + private static void Activated(IHooked instance, HookSettings settings) + => HookProbe.Log.Add($"activated:{instance.GetType().Name}:{settings is not null}"); + + private static void Released(IHooked instance) => HookProbe.Log.Add("released:" + instance.GetType().Name); + } + + [Fact] + public async Task Scan_OpenGenericMarker_DispatchesAGenericHookWithTheClosedTypeArgument() + { + HookProbe.Log.Clear(); + using HookViewContainer.Root container = new(); + + IHookView view = container.Resolve>(); + + await That(view).IsNotNull(); + // The scan's open marker closes as IHookView for HookWindow, so the generic hook is dispatched + // as WireView - binding the matching view model, resolved from the graph, with no reflection. + await That(HookProbe.Log).Contains("wired:HookWindow:HookViewModel") + .Because("a generic scan hook binds its type argument from the match's closed marker form"); + } + + public interface IHookView; + + public interface IHookViewModel; + + public sealed class HookViewModel : IHookViewModel; + + public sealed class HookWindow : IHookView; + + [Container] + [Singleton] + [Scan(typeof(IHookView<>), As = ScanAs.Marker, Lifetime = AwaitenLifetime.Singleton, OnActivated = nameof(WireView))] + public static partial class HookViewContainer + { + private static void WireView(IHookView view, TViewModel viewModel) + => HookProbe.Log.Add($"wired:{view.GetType().Name}:{viewModel!.GetType().Name}"); + } + [Fact] public async Task GenericScan_RegistersMatchesLikeTheTypeofForm() { From 35260f0d331631c220fbca4a0231cfb968ff398e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 17 Jul 2026 17:05:17 +0200 Subject: [PATCH 2/8] fix: defer AWT198 to hook resolution so non-generic scan hooks are not falsely rejected A generic lifecycle hook on an open-generic [Scan] marker binds its type argument from the match's single closed marker form. AWT198 (ambiguous marker) was decided during scan expansion, where only the hook name is known, so it fired for any match closing the marker more than once even when the named hook was non-generic and needed no type argument, and it kept only the first-seen closing per implementation. Move the AWT198 decision to ResolveHook, where the method arity is known: a generic hook with more than one distinct closing is ambiguous, a non-generic one is unaffected. Collect the closed marker forms in ScanHookMarkers instead of judging them, and union them across every registration of an implementation in coalescing, so two open-generic scans binding the same generic hook to different closings surface as AWT198 rather than an order-dependent silent dispatch. --- Docs/pages/09-diagnostics.md | 2 +- Docs/pages/registration/07-scanning.md | 10 ++-- .../AwaitenGenerator.Coalescing.cs | 15 ++++- .../AwaitenGenerator.Production.cs | 46 ++++++++++++---- .../AwaitenGenerator.Registrations.cs | 17 +++--- .../AwaitenGenerator.Scan.cs | 55 ++++++++----------- ...cTests.Awt198GenericHookAmbiguousMarker.cs | 50 +++++++++++++++++ Tests/Awaiten.Tests/ScanTests.cs | 26 +++++++++ 8 files changed, 165 insertions(+), 56 deletions(-) diff --git a/Docs/pages/09-diagnostics.md b/Docs/pages/09-diagnostics.md index da16832..ffa0ef8 100644 --- a/Docs/pages/09-diagnostics.md +++ b/Docs/pages/09-diagnostics.md @@ -1275,7 +1275,7 @@ public static partial class CoffeeShop } ``` -A [generic scan hook](./registration/scanning#lifecycle-hooks) binds its type argument from the match's *single* closed marker form, so `DualView` — which closes `IView<>` at both `Orders` and `Payments` — leaves it ambiguous. Register the type explicitly with the intended hook, or split the family so each match closes the marker once. A match closing the marker several times is fine *without* a hook (each closed form registers as its own collection member). +A [generic scan hook](./registration/scanning#lifecycle-hooks) binds its type argument from the match's *single* closed marker form, so `DualView`, which closes `IView<>` at both `Orders` and `Payments`, leaves it ambiguous. Register the type explicitly with the intended hook, or split the family so each match closes the marker once. Only a *generic* hook is affected: a non-generic hook (its parameter typed as the marker or `object`) takes no type argument, so a match with several closings is fine for it, as is a match closing the marker several times *without* any hook (each closed form registers as its own collection member). ## Modules diff --git a/Docs/pages/registration/07-scanning.md b/Docs/pages/registration/07-scanning.md index 4a68bd3..a27c863 100644 --- a/Docs/pages/registration/07-scanning.md +++ b/Docs/pages/registration/07-scanning.md @@ -102,7 +102,7 @@ An unbound generic marker matches closed forms, the way Autofac's closed-types-o ## Lifecycle hooks -A scan can name `OnActivated` and `OnRelease` hooks, applied to every match — so a whole family shares one activation or teardown routine without a registration line per type. +A scan can name `OnActivated` and `OnRelease` hooks, applied to every match, so a whole family shares one activation or teardown routine without a registration line per type. ```csharp [Container] @@ -113,11 +113,11 @@ public static partial class CoffeeShop } ``` -The hook's first parameter is the match, so it must accept every one: type it as the scanned marker (or `object`). Parameters after it are resolved from the graph exactly as for an [explicit registration's hook](../lifetime/lifecycle-hooks#hook-parameters), and the same rules and diagnostics apply — an unusable hook name is [AWT164](../diagnostics#awt164), an unregistered parameter is [AWT101](../diagnostics#awt101). A hook that conflicts with an explicit registration of the same type is caught as [AWT166](../diagnostics#awt166). +The hook's first parameter is the match, so it must accept every one: type it as the scanned marker (or `object`). Parameters after it are resolved from the graph exactly as for an [explicit registration's hook](../lifetime/lifecycle-hooks#hook-parameters), and the same rules and diagnostics apply: an unusable hook name is [AWT164](../diagnostics#awt164), an unregistered parameter is [AWT101](../diagnostics#awt101). A hook that conflicts with an explicit registration of the same type is caught as [AWT166](../diagnostics#awt166). ### Generic hooks on an open marker -An [open generic marker](#open-generic-markers) knows each match's closed type argument at compile time, so a hook can be *generic* and receive it directly — no reflection, no `object`. The classic case is a WPF-style view/view-model family: bind each view to its matching view model as it is activated. +An [open generic marker](#open-generic-markers) knows each match's closed type argument at compile time, so a hook can be *generic* and receive it directly, with no reflection and no `object`. The classic case is a WPF-style view/view-model family: bind each view to its matching view model as it is activated. ```csharp [Container] @@ -130,9 +130,9 @@ public static partial class App } ``` -For `MainWindow : IView` the generator dispatches `WireView(mainWindow, viewModel)`, resolving the matching `IMainViewModel` from the graph. The type argument is visible to graph analysis, so an unregistered view model fails the build ([AWT101](../diagnostics#awt101)) rather than at runtime. A non-generic method works too (its parameters typed as the marker); the type argument only applies when the method is generic. +For `MainWindow : IView` the generator dispatches `WireView(mainWindow, viewModel)`, resolving the matching `IMainViewModel` from the graph. The type argument is visible to graph analysis, so an unregistered view model fails the build ([AWT101](../diagnostics#awt101)) rather than at runtime. A non-generic method works too (its parameters typed as the marker or `object`); the type argument only applies when the method is generic. -Because the type argument comes from the match's *single* closed marker form, a match that closes the marker more than once (`Dual : IView, IView`) is ambiguous and reported as [AWT198](../diagnostics#awt198) — register such a type explicitly with the hook it needs. +Because a *generic* hook takes its type argument from the match's *single* closed marker form, a match that closes the marker more than once (`Dual : IView, IView`) leaves that argument ambiguous and is reported as [AWT198](../diagnostics#awt198): register such a type explicitly with the hook it needs, or split the family so each match closes the marker once. A non-generic hook takes no type argument, so a match with several closings is fine for it. ## Overriding a scanned type diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs index 12a3a7f..ab0ccfb 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs @@ -187,13 +187,26 @@ static ImplInfo EnsureImpl(Dictionary implInfos, List SymbolEqualityComparer.Default.Equals(seen, marker))) + { + info.HookClosedMarkers.Add(marker); + } + } + } + return info; } } diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs index 18a7890..6dc16a5 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs @@ -286,7 +286,11 @@ private static InstanceModel BuildPrebuiltInstance( /// to AWT164. Reports AWT164 and returns /// (null, empty) when no accessible ordinary void method of that name accepts the implementation type /// as its first parameter, and AWT190 (also returning - /// (null, empty)) when more than one does, so the choice would be order-dependent. + /// (null, empty)) when more than one does, so the choice would be order-dependent. A generic hook + /// (named on an open-generic [Scan] marker) binds its type argument from the match's single closed + /// marker form; when the match closes the marker at more than one form the argument is ambiguous, reported as + /// AWT198 (a non-generic hook, needing no argument, is + /// unaffected). /// private static (string? Hook, EquatableArray Parameters) ResolveHook( ImplInfo info, @@ -303,6 +307,7 @@ private static (string? Hook, EquatableArray Parameters) Resolve Compilation compilation = context.Compilation; List matches = new(); + bool ambiguousMarker = false; 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 @@ -314,9 +319,17 @@ private static (string? Hook, EquatableArray Parameters) Resolve } // A generic hook (named on an open-generic [Scan] marker) binds its type parameters from the match's - // closed marker form, so WireView is dispatched as WireView; a non-generic - // method is used as-is. The first parameter of the constructed method must accept the instance. - if (ConstructHook(method, info.HookClosedMarker) is { } constructed + // single closed marker form, so WireView is dispatched as WireView. A match + // that closes the marker at more than one form leaves that type argument ambiguous (AWT198); a non-generic + // method needs no type argument, so it is unaffected and resolves as-is below. + if (method.Arity > 0 && info.HookClosedMarkers.Count > 1) + { + ambiguousMarker = true; + continue; + } + + // The first parameter of the (possibly constructed) method must accept the instance. + if (ConstructHook(method, info.HookClosedMarkers) is { } constructed && compilation.HasImplicitConversion(info.Symbol, constructed.Parameters[0].Type)) { matches.Add(constructed); @@ -328,6 +341,18 @@ private static (string? Hook, EquatableArray Parameters) Resolve return (QualifiedHook(info, hookName, matches[0]), ClassifyHookParameters(matches[0], info, release, context)); } + // A generic hook whose only obstacle was an ambiguous closed marker (and nothing else usable matched) is + // AWT198, distinct from an unusable name (AWT164) or an overload the container cannot pick between (AWT190): + // the marker closes at several forms, so the hook's type argument cannot be chosen. No hook is emitted. + if (matches.Count == 0 && ambiguousMarker) + { + context.Diagnostics.Add(new DiagnosticInfo( + Diagnostics.GenericHookAmbiguousMarker, + info.Location, + new EquatableArray([Display(info.ImplementationType), Display(info.HookClosedMarkers[0].OriginalDefinition.ToDisplayString(FullyQualified)),]))); + return (null, default); + } + // No usable match is AWT164 (unusable hook); more than one is AWT190 (an overload the container cannot pick // between). Either way no hook is emitted - the error fails the build, and picking one arbitrarily would only // add a confusing secondary diagnostic from the parameters of the guessed overload. @@ -436,23 +461,24 @@ private static string QualifiedHook(ImplInfo info, string hookName, IMethodSymbo /// /// Binds a lifecycle hook method's type parameters for dispatch: a non-generic method (arity 0) is used - /// as-is, and a generic method is constructed from the match's closed marker form's type arguments - /// () when the arities match. Returns when a generic - /// method has no marker to bind or the arities differ, so it is not a usable hook and falls through to AWT164. + /// as-is, ignoring any marker, and a generic method is constructed from the single closed marker form's type + /// arguments () when the arities match. Returns when a + /// generic method has no single marker to bind (none, or more than one, the ambiguity AWT198 reports) or the + /// arities differ, so it is not a usable hook and falls through to AWT164. /// - private static IMethodSymbol? ConstructHook(IMethodSymbol method, INamedTypeSymbol? closedMarker) + private static IMethodSymbol? ConstructHook(IMethodSymbol method, IReadOnlyList closedMarkers) { if (method.Arity == 0) { return method; } - if (closedMarker is null || method.Arity != closedMarker.TypeArguments.Length) + if (closedMarkers.Count != 1 || method.Arity != closedMarkers[0].TypeArguments.Length) { return null; } - return method.Construct(closedMarker.TypeArguments.ToArray()); + return method.Construct(closedMarkers[0].TypeArguments.ToArray()); } /// private static (string? Hook, EquatableArray Parameters) ResolveHook( ImplInfo info, @@ -312,6 +314,7 @@ private static (string? Hook, EquatableArray Parameters) Resolve List matches = new(); List? ambiguousForms = null; + int ambiguousMethods = 0; 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 @@ -336,8 +339,9 @@ private static (string? Hook, EquatableArray Parameters) Resolve // A generic hook binds its type parameters from a closed marker form, so WireView is // dispatched as WireView. Exactly one bindable form is that dispatch; more than one - // leaves the type arguments ambiguous (AWT198, with the forms recorded for the message); none means - // the method is not a usable hook and falls through to AWT164 with any other shape mismatch. + // leaves the type arguments ambiguous (AWT198, with the forms recorded for the message) yet still a + // usable overload, so it counts toward AWT190 when a sibling also matches; none means the method is + // not a usable hook and falls through to AWT164 with any other shape mismatch. List<(INamedTypeSymbol Form, IMethodSymbol Constructed)> bindable = BindableHookConstructions(method, closedMarkers, info.Symbol, compilation); if (bindable.Count == 1) { @@ -345,19 +349,22 @@ private static (string? Hook, EquatableArray Parameters) Resolve } else if (bindable.Count > 1) { + ambiguousMethods++; ambiguousForms ??= bindable.Select(candidate => candidate.Form).ToList(); } } - if (matches.Count == 1) + if (matches.Count == 1 && ambiguousMethods == 0) { return (QualifiedHook(info, hookName, matches[0]), ClassifyHookParameters(matches[0], info, release, context)); } - // A generic hook whose only obstacle was an ambiguous closed marker (and nothing else usable matched) is + // A generic hook whose only obstacle is an ambiguous closed marker, with no other usable overload, is // AWT198, distinct from an unusable name (AWT164) or an overload the container cannot pick between (AWT190): - // several closed forms could bind the hook, so its type arguments cannot be chosen. No hook is emitted. - if (matches.Count == 0 && ambiguousForms is not null) + // several closed forms could bind the hook, so its type arguments cannot be chosen. When another overload + // is also usable the collision is between methods, not closings (settling the closings would still leave + // two usable overloads), so it falls to AWT190 below - exactly as if the generic overload bound uniquely. + if (matches.Count == 0 && ambiguousMethods == 1 && ambiguousForms is not null) { context.Diagnostics.Add(new DiagnosticInfo( Diagnostics.GenericHookAmbiguousMarker, @@ -371,10 +378,12 @@ private static (string? Hook, EquatableArray Parameters) Resolve } // No usable match is AWT164 (unusable hook); more than one is AWT190 (an overload the container cannot pick - // between). Either way no hook is emitted - the error fails the build, and picking one arbitrarily would only - // add a confusing secondary diagnostic from the parameters of the guessed overload. + // between), where a generic overload with several bindable closings counts as usable - it could dispatch, + // so silently preferring its sibling would let an extra marker closing change which method runs. Either way + // no hook is emitted - the error fails the build, and picking one arbitrarily would only add a confusing + // secondary diagnostic from the parameters of the guessed overload. context.Diagnostics.Add(new DiagnosticInfo( - matches.Count == 0 ? Diagnostics.InvalidLifecycleHook : Diagnostics.AmbiguousLifecycleHook, + matches.Count + ambiguousMethods == 0 ? Diagnostics.InvalidLifecycleHook : Diagnostics.AmbiguousLifecycleHook, info.Location, new EquatableArray([Display(info.OwningServiceOrImpl), hookName, DescribeOwner(info),]))); return (null, default); @@ -538,7 +547,7 @@ private static bool SatisfiesConstraints(IMethodSymbol method, ImmutableArray /// Substitutes a generic hook method's own type parameters with the bound arguments inside a constraint type, /// so where T : IComparable<T> becomes IComparable<int> for the conversion check in - /// . Anything that is not the method's type parameter or a generic type - /// containing one passes through unchanged. + /// . Descends into generic type arguments and array element types + /// (where T : IEnumerable<T[]>); anything else that is not the method's type parameter passes + /// through unchanged. /// diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Registrations.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Registrations.cs index 71f5102..aa8aeca 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Registrations.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Registrations.cs @@ -46,7 +46,7 @@ internal sealed record RawRegistration( bool SuppressDisposal = false, string? WhenInjectedInto = null, bool GreedyConstructor = false, - INamedTypeSymbol? HookClosedMarker = null); + IReadOnlyList? HookClosedMarkers = null); /// /// An imported module: its symbol and the location of the container's [Import] attribute that @@ -182,13 +182,16 @@ public ImplInfo( public string? OnRelease { get; init; } /// - /// The closed marker form a generic scan hook binds its type argument from - set only when the winning - /// registration came from a [Scan(typeof(IView<>))] open-generic marker that names a hook, so - /// MainWindow : IView<IMainViewModel> carries IView<IMainViewModel> here and its - /// hook is dispatched as WireView<IMainViewModel>. for a non-generic - /// hook (the type argument does not apply), resolved in ResolveHook. + /// The closed marker forms a generic scan hook could bind its type argument from: the union, across every + /// registration of this implementation, of the closings each [Scan(typeof(IView<>))] + /// open-generic marker naming a hook contributed. Exactly one closing (say + /// MainWindow : IView<IMainViewModel> carries IView<IMainViewModel>) dispatches + /// the hook as WireView<IMainViewModel>; more than one leaves a generic hook's type argument + /// ambiguous (AWT198), decided in ResolveHook where the hook's arity is known. Empty when no scan + /// hook bound a marker (a closed or markerless scan, a non-scan, or a scan with no hook), so a generic + /// hook has nothing to bind and is unusable while a non-generic hook is unaffected. /// - public INamedTypeSymbol? HookClosedMarker { get; init; } + public List HookClosedMarkers { get; } = new(); /// /// Whether the winning registration opted out of the container's built-in disposal diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs index 1cc3b35..09fbae9 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs @@ -16,9 +16,11 @@ partial class AwaitenGenerator /// 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/AWT198. The synthesized + /// 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. + /// resolution while every match still joins its collection. A scan's lifecycle hook is resolved later in + /// ResolveHook like any other (AWT164/AWT190), where a generic hook bound by an open-generic marker may + /// also report AWT198 if a match closes the marker at more than one form. /// private static List CollectScans( INamedTypeSymbol containerSymbol, @@ -146,8 +148,8 @@ private static void ExpandScan( continue; } - (ScanMatch candidateMatch, INamedTypeSymbol? hookClosedMarker) = ResolveScanHook(candidate.Type, match, openMarker, markerDefinition, location, diagnostics); - produced += RegisterScanMatch(candidate.Type, ScanContracts(candidate.Type, match, marker, openMarker, markerDefinition, compilation), markerDisplay, candidateMatch, hookClosedMarker, result, diagnostics); + IReadOnlyList? hookClosedMarkers = ScanHookMarkers(candidate.Type, match, openMarker, markerDefinition); + produced += RegisterScanMatch(candidate.Type, ScanContracts(candidate.Type, match, marker, openMarker, markerDefinition, compilation), markerDisplay, match, hookClosedMarkers, result, diagnostics); } ReportScanFilterDiagnostics(filters, hits, new ScanFilterCounts(assignable, registered, produced), assemblies, markerDisplay, location, diagnostics); @@ -277,7 +279,7 @@ private static int RegisterScanMatch( ScanContractSet contracts, string? markerDisplay, ScanMatch match, - INamedTypeSymbol? hookClosedMarker, + IReadOnlyList? hookClosedMarkers, List result, List diagnostics) { @@ -286,13 +288,13 @@ private static int RegisterScanMatch( if (match.RegisterSelf) { - result.Add(ScanRegistration(typeName, typeName, type, type, match, hookClosedMarker)); + result.Add(ScanRegistration(typeName, typeName, type, type, match, hookClosedMarkers)); produced++; } foreach (INamedTypeSymbol contract in contracts.Contracts) { - result.Add(ScanRegistration(contract.ToDisplayString(FullyQualified), typeName, type, contract, match, hookClosedMarker)); + result.Add(ScanRegistration(contract.ToDisplayString(FullyQualified), typeName, type, contract, match, hookClosedMarkers)); produced++; } @@ -618,43 +620,32 @@ private static bool HasOpenTypeParameters(INamedTypeSymbol type) /// service's collection, carrying the scan's SkipUnconstructable opt-in for the /// unconstructable-match prune. /// - private static RawRegistration ScanRegistration(string service, string implementation, INamedTypeSymbol type, INamedTypeSymbol serviceSymbol, ScanMatch match, INamedTypeSymbol? hookClosedMarker) + private static RawRegistration ScanRegistration(string service, string implementation, INamedTypeSymbol type, INamedTypeSymbol serviceSymbol, ScanMatch match, IReadOnlyList? hookClosedMarkers) => new(service, implementation, match.Lifetime, type, match.Location, ProductionKind.Constructor, null, false, null, serviceSymbol, true, match.SkipUnconstructable, - OnActivated: match.OnActivated, OnRelease: match.OnRelease, HookClosedMarker: hookClosedMarker); + OnActivated: match.OnActivated, OnRelease: match.OnRelease, HookClosedMarkers: hookClosedMarkers); /// - /// Resolves one match's lifecycle hooks for a [Scan]. For an open-generic marker that names a hook, the - /// closed marker form the match implements binds the hook's type argument, so - /// MainWindow : IView<IMainViewModel> dispatches WireView<IMainViewModel>. A match - /// that closes the marker more than once has an ambiguous type argument, reported as AWT198 with the hooks - /// dropped for that match (so no secondary AWT164 fires from resolving a generic method non-generically). A - /// closed or markerless scan, or a scan with no hook, passes the scan's hooks through unchanged and binds no - /// type argument (the hook is resolved non-generically in ResolveHook). + /// The closed marker forms a match's lifecycle hook may bind its type argument from. For an open-generic marker + /// that names a hook, these are the forms the match closes the marker at, so + /// MainWindow : IView<IMainViewModel> yields IView<IMainViewModel> and the hook is + /// dispatched as WireView<IMainViewModel>. A match that closes the marker more than once yields + /// several forms; whether that is an error is decided in ResolveHook, where the hook's arity is known: a + /// generic hook has an ambiguous type argument (AWT198), a non-generic one is unaffected. A closed or markerless + /// scan, or a scan with no hook, binds no marker (), so a generic hook there is unusable + /// and a non-generic one resolves as-is. /// - private static (ScanMatch Match, INamedTypeSymbol? HookClosedMarker) ResolveScanHook( + private static IReadOnlyList? ScanHookMarkers( INamedTypeSymbol type, ScanMatch match, bool openMarker, - INamedTypeSymbol? markerDefinition, - Location? location, - List diagnostics) + INamedTypeSymbol? markerDefinition) { if (!openMarker || (match.OnActivated is null && match.OnRelease is null)) { - return (match, null); - } - - List closed = ClosedMarkerForms(type, markerDefinition!); - if (closed.Count == 1) - { - return (match, closed[0]); + return null; } - diagnostics.Add(new DiagnosticInfo( - Diagnostics.GenericHookAmbiguousMarker, - LocationInfo.From(location), - new EquatableArray([Display(type.ToDisplayString(FullyQualified)), Display(markerDefinition!.ToDisplayString(FullyQualified)),]))); - return (match with { OnActivated = null, OnRelease = null, }, null); + return ClosedMarkerForms(type, markerDefinition!); } /// diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt198GenericHookAmbiguousMarker.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt198GenericHookAmbiguousMarker.cs index 4540d45..7746a28 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt198GenericHookAmbiguousMarker.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt198GenericHookAmbiguousMarker.cs @@ -57,6 +57,56 @@ await That(result.Diagnostics).DoesNotContain("*AWT164*").AsWildcard() .Because("the generic hook binds its type argument from the single closed marker form and is usable"); } + [Fact] + public async Task DoesNotReportForANonGenericHookOnAMatchClosingTheMarkerMoreThanOnce() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IView { } + public sealed class DualView : IView, IView { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Log))] + public static partial class MyContainer + { + private static void Log(object instance) { } + } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT198*").AsWildcard() + .Because("a non-generic hook needs no type argument, so a match closing the marker several times is not ambiguous"); + await That(result.Diagnostics).DoesNotContain("*AWT164*").AsWildcard() + .Because("the non-generic hook accepts every match as object and is a usable hook"); + } + + [Fact] + public async Task ReportsWhenTwoOpenMarkersBindOneGenericHookToDifferentClosings() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IView { } + public interface IEditor { } + public sealed class Dual : IView, IEditor { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + [Scan(typeof(IEditor<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(object instance) { } + } + """); + + await That(result.Diagnostics).Contains("*AWT198*").AsWildcard() + .Because("two open-generic scans bind the same generic hook to different closings of Dual, so its type argument is ambiguous rather than silently the first-seen one"); + } + [Fact] public async Task DoesNotReportWhenNoHookIsNamed() { diff --git a/Tests/Awaiten.Tests/ScanTests.cs b/Tests/Awaiten.Tests/ScanTests.cs index a5d4f78..e41e94f 100644 --- a/Tests/Awaiten.Tests/ScanTests.cs +++ b/Tests/Awaiten.Tests/ScanTests.cs @@ -180,6 +180,32 @@ private static void WireView(IHookView view, TViewModel => HookProbe.Log.Add($"wired:{view.GetType().Name}:{viewModel!.GetType().Name}"); } + [Fact] + public async Task Scan_OpenGenericMarker_RunsANonGenericHookForAMatchClosingTheMarkerMoreThanOnce() + { + HookProbe.Log.Clear(); + using DualPanelContainer.Root container = new(); + + DualPanel panel = container.Resolve(); + + await That(panel).IsNotNull(); + // DualPanel closes IPanel<> at both int and string, but a non-generic hook needs no type argument, so it + // is not ambiguous (no AWT198) and runs once for the single instance. + await That(HookProbe.Log).Contains("tracked:DualPanel") + .Because("a non-generic scan hook accepts every match as object, whatever closings the match implements"); + } + + public interface IPanel; + + public sealed class DualPanel : IPanel, IPanel; + + [Container] + [Scan(typeof(IPanel<>), As = ScanAs.Self | ScanAs.Marker, Lifetime = AwaitenLifetime.Singleton, OnActivated = nameof(Track))] + public static partial class DualPanelContainer + { + private static void Track(object instance) => HookProbe.Log.Add("tracked:" + instance.GetType().Name); + } + [Fact] public async Task GenericScan_RegistersMatchesLikeTheTypeofForm() { From 74d9dcb125051e27217f03a239622c5734a6b11b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sat, 18 Jul 2026 08:56:36 +0200 Subject: [PATCH 3/8] fix: bind scan lifecycle hooks per slot and report same-slot conflicts (AWT199) Review of the scan lifecycle hook feature surfaced several coalescing and binding defects, fixed here: - Track closed marker forms per hook slot (OnActivated/OnRelease) instead of one per-implementation union, so a generic hook is only considered ambiguous among registrations naming the same hook in the same slot. Two scans hooking different slots of the same type no longer trigger a false AWT198. - Merge scan hooks across scans: a later scan fills a slot the winning scan left unset, so an OnActivated scan and an OnRelease scan matching the same type both apply. Same-slot conflicts with different methods now report the new AWT199 warning (first scan wins), mirroring AWT142. - Validate generic constraints before constructing a hook method, so a constraint-violating closing falls through to AWT164 instead of leaking a CS error into the generated source. - Decide AWT198 from actual bindability: closings whose arity, constraints, or first parameter cannot bind no longer count, so unusable generic hooks report AWT164 and constraints can settle a formerly ambiguous match down to a single bindable form. - Reword the AWT198 message to name the hook and the actual set of bindable closed forms, which is accurate for both the single-marker double-closing and the cross-marker case. Explicit-over-scan override intentionally stays diagnostic-free: an explicit registration replaces a scan registration wholesale, hooks included, which is the sanctioned remediation path for AWT198. The scanning docs now state this and no longer claim AWT166 covers hook conflicts. Adds regression tests for each fix plus runtime coverage for generic OnRelease hooks, arity-2 markers, base-class open markers, and the two-scan hook merge. --- Docs/pages/09-diagnostics.md | 25 ++- Docs/pages/registration/07-scanning.md | 6 +- .../AnalyzerReleases.Unshipped.md | 3 +- .../AwaitenGenerator.Coalescing.cs | 84 +++++++-- .../AwaitenGenerator.Production.cs | 157 +++++++++++++--- .../AwaitenGenerator.Registrations.cs | 33 ++-- .../AwaitenGenerator.Scan.cs | 16 +- .../Awaiten.SourceGenerators/Diagnostics.cs | 27 ++- ...gnosticTests.Awt164InvalidLifecycleHook.cs | 25 +++ ...cTests.Awt198GenericHookAmbiguousMarker.cs | 98 ++++++++++ .../DiagnosticTests.Awt199ScanHookConflict.cs | 169 ++++++++++++++++++ Tests/Awaiten.Tests/ScanTests.cs | 118 ++++++++++++ 12 files changed, 690 insertions(+), 71 deletions(-) create mode 100644 Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt199ScanHookConflict.cs diff --git a/Docs/pages/09-diagnostics.md b/Docs/pages/09-diagnostics.md index ffa0ef8..1c9b8b3 100644 --- a/Docs/pages/09-diagnostics.md +++ b/Docs/pages/09-diagnostics.md @@ -1260,7 +1260,7 @@ Where [AWT188](#awt188) is about an interface the match cannot be exposed under, ### AWT198 :::danger[Error] -A generic lifecycle hook on an open-generic `[Scan]` marker matched a type that closes the marker more than once, so the hook's type argument is ambiguous. +A generic lifecycle hook on an open-generic `[Scan]` marker could bind a match through more than one closed marker form, so its type arguments are ambiguous. ::: ```csharp @@ -1275,7 +1275,28 @@ public static partial class CoffeeShop } ``` -A [generic scan hook](./registration/scanning#lifecycle-hooks) binds its type argument from the match's *single* closed marker form, so `DualView`, which closes `IView<>` at both `Orders` and `Payments`, leaves it ambiguous. Register the type explicitly with the intended hook, or split the family so each match closes the marker once. Only a *generic* hook is affected: a non-generic hook (its parameter typed as the marker or `object`) takes no type argument, so a match with several closings is fine for it, as is a match closing the marker several times *without* any hook (each closed form registers as its own collection member). +A [generic scan hook](./registration/scanning#lifecycle-hooks) binds its type argument from a closed marker form, so it needs exactly one it can bind. `DualView`, which closes `IView<>` at both `Orders` and `Payments`, offers two; the same happens when two scans bind the same generic hook through differently-closed markers of one type. Only forms the hook could actually bind count: matching arity, satisfied constraints, and a first parameter that accepts the match. If exactly one form remains after those checks the hook binds it, so a `where TViewModel : class` constraint can settle a family that closes the marker at one class and one struct. Register the type explicitly with the intended hook, or split the family so only one closed form binds it. Only a *generic* hook is affected: a non-generic hook (its parameter typed as the marker or `object`) takes no type argument, so a match with several closings is fine for it, as is a match closing the marker several times *without* any hook (each closed form registers as its own collection member). + +### AWT199 + +:::warning[Warning] +Two `[Scan]` attributes match one implementation with conflicting lifecycle hooks; the first scan's hook is used. +::: + +```csharp +public sealed class Widget : IPlugin, IHandler; // matched by both scans + +[Container] +[Scan(typeof(IPlugin), OnActivated = nameof(PluginStarted))] +[Scan(typeof(IHandler), OnActivated = nameof(HandlerStarted))] // two OnActivated hooks for Widget +public static partial class CoffeeShop +{ + private static void PluginStarted(object instance) { } + private static void HandlerStarted(object instance) { } +} +``` + +Which method ran for `Widget` would depend on attribute order, so the contradiction is surfaced instead, mirroring [AWT142](#awt142) for lifetimes. Overlapping scans that agree are fine: two scans naming the *same* method merge, and so do scans hooking *different* slots (one scan's `OnActivated` combines with another's `OnRelease`). An explicit registration of the type is not reported either: a scan yields to it wholesale, hooks included, so the explicit registration's hooks (or its deliberate lack of them) replace the scan's. ## Modules diff --git a/Docs/pages/registration/07-scanning.md b/Docs/pages/registration/07-scanning.md index a27c863..3aee5ce 100644 --- a/Docs/pages/registration/07-scanning.md +++ b/Docs/pages/registration/07-scanning.md @@ -113,7 +113,9 @@ public static partial class CoffeeShop } ``` -The hook's first parameter is the match, so it must accept every one: type it as the scanned marker (or `object`). Parameters after it are resolved from the graph exactly as for an [explicit registration's hook](../lifetime/lifecycle-hooks#hook-parameters), and the same rules and diagnostics apply: an unusable hook name is [AWT164](../diagnostics#awt164), an unregistered parameter is [AWT101](../diagnostics#awt101). A hook that conflicts with an explicit registration of the same type is caught as [AWT166](../diagnostics#awt166). +The hook's first parameter is the match, so it must accept every one: type it as the scanned marker (or `object`). Parameters after it are resolved from the graph exactly as for an [explicit registration's hook](../lifetime/lifecycle-hooks#hook-parameters), and the same rules and diagnostics apply: an unusable hook name is [AWT164](../diagnostics#awt164), an unregistered parameter is [AWT101](../diagnostics#awt101). + +When two scans match the same type, their hooks merge: one scan's `OnActivated` combines with another's `OnRelease`, and both naming the same method is fine. Two scans naming *different* methods for the same slot contradict each other, so the first scan's method is used and the contradiction is surfaced as [AWT199](../diagnostics#awt199). An [explicit registration](#overriding-a-scanned-type) of a scanned type is different: it replaces the scan's hooks along with everything else, so name the hook on the explicit registration too if the special-cased type should keep it, and leave it off to deliberately opt that type out. ### Generic hooks on an open marker @@ -132,7 +134,7 @@ public static partial class App For `MainWindow : IView` the generator dispatches `WireView(mainWindow, viewModel)`, resolving the matching `IMainViewModel` from the graph. The type argument is visible to graph analysis, so an unregistered view model fails the build ([AWT101](../diagnostics#awt101)) rather than at runtime. A non-generic method works too (its parameters typed as the marker or `object`); the type argument only applies when the method is generic. -Because a *generic* hook takes its type argument from the match's *single* closed marker form, a match that closes the marker more than once (`Dual : IView, IView`) leaves that argument ambiguous and is reported as [AWT198](../diagnostics#awt198): register such a type explicitly with the hook it needs, or split the family so each match closes the marker once. A non-generic hook takes no type argument, so a match with several closings is fine for it. +Because a *generic* hook takes its type argument from a closed marker form, it needs exactly one it can bind. A match offering several, by closing the marker more than once (`Dual : IView, IView`), leaves that argument ambiguous and is reported as [AWT198](../diagnostics#awt198): register such a type explicitly with the hook it needs, or split the family so only one closed form binds it. Only forms the hook could actually bind count, so a constraint (`where TViewModel : class`) that rules out all closings but one settles the choice. A non-generic hook takes no type argument, so a match with several closings is fine for it. ## Overriding a scanned type diff --git a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md index bdd34e3..1d8cba8 100644 --- a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -99,7 +99,8 @@ AWT195 | Awaiten | Error | A self-compiled module [Scan] match has a constructor parameter type inaccessible outside the module's assembly AWT196 | Awaiten | Warning | A self-compiled module [Scan] match has no exposure interface accessible outside the module's assembly, so it is skipped AWT197 | Awaiten | Error | A self-compiled module [Scan] match would be exposed under multiple interfaces, which a single-exposure factory cannot express - AWT198 | Awaiten | Error | A generic lifecycle hook on an open-generic [Scan] marker matched a type that closes the marker more than once, so the hook's type argument is ambiguous + AWT198 | Awaiten | Error | A generic lifecycle hook on an open-generic [Scan] marker could bind a match through more than one closed marker form, so its type arguments are ambiguous + AWT199 | Awaiten | Warning | Two [Scan] attributes match one implementation with conflicting OnActivated/OnRelease hooks; the first scan's hook is used AWT200 | Awaiten | Error | A self-compiled module [Scan] match carries [Inject]/[Arg] metadata the generated factory cannot mirror AWT201 | Awaiten | Error | A generic [Module] (or one nested in a generic type) declares a [Scan], so no closed module exists for a consumer to import AWT202 | Awaiten | Error | A module [Scan] declares InAssembliesOf, but a self-compiled scan sweeps only the module's own assembly diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs index ab0ccfb..12405e2 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs @@ -48,6 +48,7 @@ private static (List Order, Dictionary ServiceToIm HashSet reportedConflicts = new(StringComparer.Ordinal); HashSet reportedProductionConflicts = new(StringComparer.Ordinal); HashSet reportedDirectiveConflicts = new(StringComparer.Ordinal); + HashSet reportedHookConflicts = new(StringComparer.Ordinal); // Collection membership: every registration of a service, keyed by (service type, key) and deduped by // implementation, in registration order. serviceMemberOrder preserves first-seen order for emission. @@ -149,7 +150,7 @@ private static (List Order, Dictionary ServiceToIm // Every registration is a member of the collection for its (service type, key), built even when it // loses the single-resolution slot, since it is reachable through the collection. AddCollectionMember(serviceMembers, serviceMemberOrder, serviceKey, registration.ImplementationType); - EnsureImpl(implInfos, implOrder, registration); + MergeScanHooks(EnsureImpl(implInfos, implOrder, registration), registration, reportedHookConflicts, diagnostics); // A keyed registration is also a member of its service's keyed collection, indexed by its [Key]. AddKeyedMember(keyedMembers, keyedMemberOrder, registration, alreadyChosen); @@ -193,22 +194,81 @@ static ImplInfo EnsureImpl(Dictionary implInfos, List + /// Merges a scan registration's lifecycle hooks into its implementation's coalesced info, per hook slot. + /// Two scans matching one type combine: a hook fills a slot no earlier scan claimed (so one scan's + /// OnActivated and another's OnRelease both apply), and a scan restating the slot's hook name + /// contributes its closed marker forms to that slot's set, so a generic hook bound by two open-generic scans + /// that close their markers differently is seen as ambiguous in ResolveHook (AWT198) rather than + /// silently fixed to the first-seen closing. Naming a different method for a claimed slot is + /// AWT199 (once per implementation and slot): which method ran + /// would depend on attribute order, the same order-dependence AWT142 surfaces for lifetimes. When the + /// implementation's winning registration is not a scan the whole merge is skipped: a scan yields to an + /// explicit registration, whose hooks (or deliberate lack of them) replace the scan's like its other options. + /// + private static void MergeScanHooks( + ImplInfo info, + RawRegistration registration, + HashSet reportedHookConflicts, + List diagnostics) + { + if (!registration.IsScan || !info.IsScan) + { + return; + } + + info.OnActivated = MergeScanHookSlot(registration, "OnActivated", info.OnActivated, registration.OnActivated, info.OnActivatedMarkers, reportedHookConflicts, diagnostics); + info.OnRelease = MergeScanHookSlot(registration, "OnRelease", info.OnRelease, registration.OnRelease, info.OnReleaseMarkers, reportedHookConflicts, diagnostics); + } + + /// + /// Merges one hook slot (see ): returns the slot's coalesced hook name, unioning + /// the registration's closed marker forms into the slot's set when it fills or restates the slot, and + /// reporting AWT199 (keeping the first-seen name) when it contradicts it. + /// + private static string? MergeScanHookSlot( + RawRegistration registration, + string slot, + string? current, + string? name, + List slotMarkers, + HashSet reportedHookConflicts, + List diagnostics) + { + if (name is null) + { + return current; + } + + if (current is not null && !string.Equals(current, name, StringComparison.Ordinal)) + { + if (reportedHookConflicts.Add(registration.ImplementationType + "\0" + slot)) { - foreach (INamedTypeSymbol marker in markers) + diagnostics.Add(new DiagnosticInfo( + Diagnostics.ScanHookConflict, + LocationInfo.From(registration.Location), + new EquatableArray([Display(registration.ImplementationType), slot, current, name,]))); + } + + return current; + } + + if (registration.HookClosedMarkers is { } markers) + { + foreach (INamedTypeSymbol marker in markers) + { + if (!slotMarkers.Any(seen => SymbolEqualityComparer.Default.Equals(seen, marker))) { - if (!info.HookClosedMarkers.Any(seen => SymbolEqualityComparer.Default.Equals(seen, marker))) - { - info.HookClosedMarkers.Add(marker); - } + slotMarkers.Add(marker); } } - - return info; } + + return name; } /// diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs index 6dc16a5..5b8c5c4 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using Awaiten.SourceGenerators.Entities; using Awaiten.SourceGenerators.Internals; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -287,10 +288,12 @@ private static InstanceModel BuildPrebuiltInstance( /// (null, empty) when no accessible ordinary void method of that name accepts the implementation type /// as its first parameter, and AWT190 (also returning /// (null, empty)) when more than one does, so the choice would be order-dependent. A generic hook - /// (named on an open-generic [Scan] marker) binds its type argument from the match's single closed - /// marker form; when the match closes the marker at more than one form the argument is ambiguous, reported as - /// AWT198 (a non-generic hook, needing no argument, is - /// unaffected). + /// (named on an open-generic [Scan] marker) binds its type arguments from the closed marker forms + /// kept per hook slot on ImplInfo: exactly one bindable form (matching arity, satisfied constraints, + /// a first parameter accepting the match; see ) dispatches the + /// constructed method, none leaves it unusable like any other shape mismatch (falling through to AWT164), + /// and more than one is reported as AWT198 (a + /// non-generic hook, needing no arguments, is unaffected). /// private static (string? Hook, EquatableArray Parameters) ResolveHook( ImplInfo info, @@ -305,9 +308,10 @@ private static (string? Hook, EquatableArray Parameters) Resolve INamedTypeSymbol containerSymbol = context.ContainerSymbol; Compilation compilation = context.Compilation; + IReadOnlyList closedMarkers = release ? info.OnReleaseMarkers : info.OnActivatedMarkers; List matches = new(); - bool ambiguousMarker = false; + List? ambiguousForms = null; 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 @@ -318,21 +322,30 @@ private static (string? Hook, EquatableArray Parameters) Resolve continue; } - // A generic hook (named on an open-generic [Scan] marker) binds its type parameters from the match's - // single closed marker form, so WireView is dispatched as WireView. A match - // that closes the marker at more than one form leaves that type argument ambiguous (AWT198); a non-generic - // method needs no type argument, so it is unaffected and resolves as-is below. - if (method.Arity > 0 && info.HookClosedMarkers.Count > 1) + // A non-generic method needs no type arguments (whatever the marker closings): it is a usable hook + // whenever its first parameter accepts the instance. + if (method.Arity == 0) { - ambiguousMarker = true; + if (compilation.HasImplicitConversion(info.Symbol, method.Parameters[0].Type)) + { + matches.Add(method); + } + continue; } - // The first parameter of the (possibly constructed) method must accept the instance. - if (ConstructHook(method, info.HookClosedMarkers) is { } constructed - && compilation.HasImplicitConversion(info.Symbol, constructed.Parameters[0].Type)) + // A generic hook binds its type parameters from a closed marker form, so WireView is + // dispatched as WireView. Exactly one bindable form is that dispatch; more than one + // leaves the type arguments ambiguous (AWT198, with the forms recorded for the message); none means + // the method is not a usable hook and falls through to AWT164 with any other shape mismatch. + List<(INamedTypeSymbol Form, IMethodSymbol Constructed)> bindable = BindableHookConstructions(method, closedMarkers, info.Symbol, compilation); + if (bindable.Count == 1) + { + matches.Add(bindable[0].Constructed); + } + else if (bindable.Count > 1) { - matches.Add(constructed); + ambiguousForms ??= bindable.Select(candidate => candidate.Form).ToList(); } } @@ -343,13 +356,17 @@ private static (string? Hook, EquatableArray Parameters) Resolve // A generic hook whose only obstacle was an ambiguous closed marker (and nothing else usable matched) is // AWT198, distinct from an unusable name (AWT164) or an overload the container cannot pick between (AWT190): - // the marker closes at several forms, so the hook's type argument cannot be chosen. No hook is emitted. - if (matches.Count == 0 && ambiguousMarker) + // several closed forms could bind the hook, so its type arguments cannot be chosen. No hook is emitted. + if (matches.Count == 0 && ambiguousForms is not null) { context.Diagnostics.Add(new DiagnosticInfo( Diagnostics.GenericHookAmbiguousMarker, info.Location, - new EquatableArray([Display(info.ImplementationType), Display(info.HookClosedMarkers[0].OriginalDefinition.ToDisplayString(FullyQualified)),]))); + new EquatableArray([ + Display(info.ImplementationType), + hookName, + string.Join(", ", ambiguousForms.Select(form => Display(form.ToDisplayString(FullyQualified)))), + ]))); return (null, default); } @@ -460,25 +477,107 @@ private static string QualifiedHook(ImplInfo info, string hookName, IMethodSymbo } /// - /// Binds a lifecycle hook method's type parameters for dispatch: a non-generic method (arity 0) is used - /// as-is, ignoring any marker, and a generic method is constructed from the single closed marker form's type - /// arguments () when the arities match. Returns when a - /// generic method has no single marker to bind (none, or more than one, the ambiguity AWT198 reports) or the - /// arities differ, so it is not a usable hook and falls through to AWT164. + /// The closed marker forms a generic hook method can actually bind, each paired with the method constructed + /// from its type arguments. A form is bindable when its arity matches the method's, its type arguments + /// satisfy the method's constraints (see , so the constructed call + /// compiles rather than erroring inside the generated source), and the constructed method's first parameter + /// accepts the implementation. Two forms constructing the identical method (say IView<int> and + /// IEditor<int> both yielding Wire<int>) count once: the dispatch is the same, so + /// there is nothing to be ambiguous about. An empty result means the method is not a usable hook (AWT164); + /// more than one entry is the ambiguity AWT198 reports. /// - private static IMethodSymbol? ConstructHook(IMethodSymbol method, IReadOnlyList closedMarkers) + private static List<(INamedTypeSymbol Form, IMethodSymbol Constructed)> BindableHookConstructions( + IMethodSymbol method, + IReadOnlyList closedMarkers, + INamedTypeSymbol implementation, + Compilation compilation) { - if (method.Arity == 0) + List<(INamedTypeSymbol Form, IMethodSymbol Constructed)> bindable = new(); + foreach (INamedTypeSymbol form in closedMarkers) { - return method; + if (method.Arity != form.TypeArguments.Length + || !SatisfiesConstraints(method, form.TypeArguments, compilation)) + { + continue; + } + + IMethodSymbol constructed = method.Construct(form.TypeArguments.ToArray()); + if (compilation.HasImplicitConversion(implementation, constructed.Parameters[0].Type) + && !bindable.Any(candidate => SymbolEqualityComparer.Default.Equals(candidate.Constructed, constructed))) + { + bindable.Add((form, constructed)); + } } - if (closedMarkers.Count != 1 || method.Arity != closedMarkers[0].TypeArguments.Length) + return bindable; + } + + /// + /// Whether the type arguments satisfy a generic hook method's constraints, checked before + /// (which does not validate them, so a violation would + /// otherwise surface as a raw compiler error inside the generated source). Covers the constraint kinds C# + /// has: class, struct (excluding Nullable<T>), unmanaged, new(), + /// and constraint types, with the method's own type parameters substituted so where T : IComparable<T> + /// is checked at the bound argument. notnull is not checked: violating it is only a compiler warning, + /// so the constructed call still compiles. + /// + private static bool SatisfiesConstraints(IMethodSymbol method, ImmutableArray arguments, Compilation compilation) + { + for (int index = 0; index < method.TypeParameters.Length; index++) { - return null; + ITypeParameterSymbol parameter = method.TypeParameters[index]; + ITypeSymbol argument = arguments[index]; + + if ((parameter.HasReferenceTypeConstraint && !argument.IsReferenceType) + || (parameter.HasValueTypeConstraint && (!argument.IsValueType || argument.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T)) + || (parameter.HasUnmanagedTypeConstraint && !argument.IsUnmanagedType) + || (parameter.HasConstructorConstraint && !SatisfiesConstructorConstraint(argument))) + { + return false; + } + + foreach (ITypeSymbol constraint in parameter.ConstraintTypes) + { + if (!compilation.HasImplicitConversion(argument, SubstituteTypeParameters(constraint, method, arguments))) + { + return false; + } + } + } + + return true; + } + + /// + /// Whether a type argument satisfies new(): any value type, or a non-abstract type with a public + /// parameterless instance constructor. + /// + private static bool SatisfiesConstructorConstraint(ITypeSymbol argument) + => argument.IsValueType + || (argument is INamedTypeSymbol { IsAbstract: false, } named + && named.InstanceConstructors.Any(constructor => constructor is { Parameters.Length: 0, DeclaredAccessibility: Accessibility.Public, })); + + /// + /// Substitutes a generic hook method's own type parameters with the bound arguments inside a constraint type, + /// so where T : IComparable<T> becomes IComparable<int> for the conversion check in + /// . Anything that is not the method's type parameter or a generic type + /// containing one passes through unchanged. + /// + private static ITypeSymbol SubstituteTypeParameters(ITypeSymbol type, IMethodSymbol method, ImmutableArray arguments) + { + if (type is ITypeParameterSymbol parameter && SymbolEqualityComparer.Default.Equals(parameter.DeclaringMethod, method)) + { + return arguments[parameter.Ordinal]; + } + + if (type is INamedTypeSymbol { IsGenericType: true, } named) + { + return named.ConstructedFrom.Construct(named.TypeArguments + .Select(argument => SubstituteTypeParameters(argument, method, arguments)) + .ToArray()); } - return method.Construct(closedMarkers[0].TypeArguments.ToArray()); + return type; } /// diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Registrations.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Registrations.cs index aa8aeca..656f0aa 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Registrations.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Registrations.cs @@ -173,25 +173,32 @@ public ImplInfo( /// /// The names of the static void M(TImplementation) container methods to run once the instance /// is constructed () and when its owner is disposed (), - /// or when the winning registration named none. Resolved against the container - /// in BuildInstance (AWT164 when no matching method exists). + /// or when no registration named one. Set from the winning registration; for a + /// scanned implementation a later scan's hook fills a slot the winner left unset (see + /// MergeScanHooks, AWT199 when two scans contradict). Resolved against the container in + /// BuildInstance (AWT164 when no matching method exists). /// - public string? OnActivated { get; init; } + public string? OnActivated { get; set; } /// - public string? OnRelease { get; init; } + public string? OnRelease { get; set; } /// - /// The closed marker forms a generic scan hook could bind its type argument from: the union, across every - /// registration of this implementation, of the closings each [Scan(typeof(IView<>))] - /// open-generic marker naming a hook contributed. Exactly one closing (say - /// MainWindow : IView<IMainViewModel> carries IView<IMainViewModel>) dispatches - /// the hook as WireView<IMainViewModel>; more than one leaves a generic hook's type argument - /// ambiguous (AWT198), decided in ResolveHook where the hook's arity is known. Empty when no scan - /// hook bound a marker (a closed or markerless scan, a non-scan, or a scan with no hook), so a generic - /// hook has nothing to bind and is unusable while a non-generic hook is unaffected. + /// The closed marker forms the hook could bind its type arguments from: the + /// union, across every scan registration of this implementation naming that same hook, of the closings + /// its [Scan(typeof(IView<>))] open-generic marker contributed. Kept per hook slot (its + /// twin is ) so a second scan hooking the other slot does not widen this + /// one's set. Exactly one bindable closing (say MainWindow : IView<IMainViewModel> carries + /// IView<IMainViewModel>) dispatches a generic hook as WireView<IMainViewModel>; + /// more than one leaves its type arguments ambiguous (AWT198), decided in ResolveHook where the + /// hook's arity and constraints are known. Empty when no scan hook bound a marker (a closed or markerless + /// scan, a non-scan, or a scan with no hook), so a generic hook has nothing to bind and is unusable while + /// a non-generic hook is unaffected. /// - public List HookClosedMarkers { get; } = new(); + public List OnActivatedMarkers { get; } = new(); + + /// + public List OnReleaseMarkers { get; } = new(); /// /// Whether the winning registration opted out of the container's built-in disposal diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs index 09fbae9..bc72d1b 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs @@ -18,9 +18,10 @@ partial class AwaitenGenerator /// 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. A scan's lifecycle hook is resolved later in + /// resolution while every match still joins its collection. A scan's lifecycle hooks merge per slot across + /// scans in coalescing (MergeScanHooks, AWT199 when two scans contradict) and are resolved later in /// ResolveHook like any other (AWT164/AWT190), where a generic hook bound by an open-generic marker may - /// also report AWT198 if a match closes the marker at more than one form. + /// also report AWT198 if more than one closed marker form could bind it. /// private static List CollectScans( INamedTypeSymbol containerSymbol, @@ -628,11 +629,12 @@ private static RawRegistration ScanRegistration(string service, string implement /// The closed marker forms a match's lifecycle hook may bind its type argument from. For an open-generic marker /// that names a hook, these are the forms the match closes the marker at, so /// MainWindow : IView<IMainViewModel> yields IView<IMainViewModel> and the hook is - /// dispatched as WireView<IMainViewModel>. A match that closes the marker more than once yields - /// several forms; whether that is an error is decided in ResolveHook, where the hook's arity is known: a - /// generic hook has an ambiguous type argument (AWT198), a non-generic one is unaffected. A closed or markerless - /// scan, or a scan with no hook, binds no marker (), so a generic hook there is unusable - /// and a non-generic one resolves as-is. + /// dispatched as WireView<IMainViewModel>. Coalescing files the forms under the slot(s) whose + /// hook this scan names (MergeScanHooks). A match that closes the marker more than once yields several + /// forms; whether that is an error is decided in ResolveHook, where the hook's arity and constraints + /// are known: a generic hook that could bind more than one form is ambiguous (AWT198), a non-generic one is + /// unaffected. A closed or markerless scan, or a scan with no hook, binds no marker (), + /// so a generic hook there is unusable and a non-generic one resolves as-is. /// private static IReadOnlyList? ScanHookMarkers( INamedTypeSymbol type, diff --git a/Source/Awaiten.SourceGenerators/Diagnostics.cs b/Source/Awaiten.SourceGenerators/Diagnostics.cs index 0dae000..a580e7b 100644 --- a/Source/Awaiten.SourceGenerators/Diagnostics.cs +++ b/Source/Awaiten.SourceGenerators/Diagnostics.cs @@ -1504,16 +1504,33 @@ internal static class Diagnostics /// /// A generic lifecycle hook (an OnActivated/OnRelease named on a - /// [Scan(typeof(IView<>))] open-generic marker) binds its type argument from the match's closed - /// marker form, so it needs exactly one: this match implements the marker at several closings (say - /// IView<A> and IView<B>), so the type argument would be ambiguous. Register the type - /// explicitly with the intended hook, or split the family so each match closes the marker once. + /// [Scan(typeof(IView<>))] open-generic marker) binds its type arguments from a closed marker + /// form, so it needs exactly one it can bind: this match offers several (implementing one marker at two + /// closings, say IView<A> and IView<B>, or two scans binding the same hook through + /// differently-closed markers), so the type arguments would be ambiguous. Register the type explicitly with + /// the intended hook, or split the family so only one closed form binds the hook. /// public static readonly DiagnosticDescriptor GenericHookAmbiguousMarker = new( "AWT198", "Ambiguous generic hook marker", - "'{0}' implements the scanned marker '{1}' at more than one closed form, so a generic lifecycle hook's type argument is ambiguous; register the type explicitly with the hook, or ensure it closes the marker only once", + "The generic lifecycle hook '{1}' could bind '{0}' through more than one closed marker form ({2}), so its type arguments are ambiguous; register the type explicitly with the intended hook, or ensure only one closed form binds it", "Awaiten", DiagnosticSeverity.Error, isEnabledByDefault: true); + + /// + /// Two [Scan] attributes match the same implementation and name different methods for the same + /// lifecycle hook slot (OnActivated or OnRelease); the first scan's hook wins, so the + /// contradiction is surfaced rather than silently resolved by attribute order - mirroring AWT142 for + /// lifetimes. Two scans naming the same method, or hooking different slots, merge cleanly and are not + /// reported; neither is an explicit registration of the implementation, which a scan deliberately yields + /// to, hooks included. + /// + public static readonly DiagnosticDescriptor ScanHookConflict = new( + "AWT199", + "Scans register one implementation with conflicting lifecycle hooks", + "'{0}' is matched by scans naming conflicting {1} hooks ('{2}' and '{3}'); the first scan's '{2}' is used", + "Awaiten", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); } diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt164InvalidLifecycleHook.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt164InvalidLifecycleHook.cs index 4860752..ab6dd4a 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt164InvalidLifecycleHook.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt164InvalidLifecycleHook.cs @@ -213,5 +213,30 @@ private static void Stopping(IPlugin plugin) { } await That(result.Diagnostics).DoesNotContain("*AWT164*").AsWildcard() .Because("a hook typed as the scanned marker accepts every match"); } + + [Fact] + public async Task ReportsForAGenericScanHookWhoseConstraintRejectsTheClosing() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IView { } + public sealed class IntView : IView { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(IView view) where TViewModel : class { } + } + """); + + await That(result.Diagnostics).Contains("*AWT164*").AsWildcard() + .Because("the class constraint rejects the int closing, so the hook cannot be constructed for IntView"); + await That(result.Diagnostics).DoesNotContain("*error CS*").AsWildcard() + .Because("the violation is caught before construction rather than surfacing as a compiler error inside the generated source"); + } } } diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt198GenericHookAmbiguousMarker.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt198GenericHookAmbiguousMarker.cs index 7746a28..2d11ad8 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt198GenericHookAmbiguousMarker.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt198GenericHookAmbiguousMarker.cs @@ -128,5 +128,103 @@ public static partial class MyContainer await That(result.Diagnostics).DoesNotContain("*AWT198*").AsWildcard() .Because("a match closing the marker several times is ordinary without a hook to bind a type argument for"); } + + [Fact] + public async Task DoesNotReportWhenTwoScansHookDifferentSlots() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IView { } + public interface IEditor { } + public sealed class Dual : IView, IEditor { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + [Scan(typeof(IEditor<>), As = ScanAs.Marker, OnRelease = nameof(Cleanup))] + public static partial class MyContainer + { + private static void Wire(IView view) { } + private static void Cleanup(object instance) { } + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("each hook binds through its own scan's marker: Wire sees only IView, so nothing is ambiguous and both hooks apply"); + } + + [Fact] + public async Task ReportsTheAmbiguousClosedFormsInTheMessage() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IView { } + public sealed class DualView : IView, IView { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(IView view) { } + } + """); + + await That(result.Diagnostics).Contains("*AWT198*'Wire'*MyCode.IView, MyCode.IView*").AsWildcard() + .Because("the message names the hook and every closed form it could bind, which is what makes the ambiguity actionable"); + } + + [Fact] + public async Task DoesNotReportWhenAConstraintLeavesASingleBindableForm() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IView { } + public sealed class Payments { } + public sealed class DualView : IView, IView { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(IView view) where TViewModel : class { } + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("the class constraint rules out the int closing, so only IView can bind the hook and nothing is ambiguous"); + } + + [Fact] + public async Task ReportsAwt164WhenTheGenericHookAritiesNeverMatchTheMarker() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IView { } + public sealed class DualView : IView, IView { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(object instance) { } + } + """); + + await That(result.Diagnostics).Contains("*AWT164*").AsWildcard() + .Because("a two-parameter generic hook can never bind a one-argument marker closing, so the name is unusable, not ambiguous"); + await That(result.Diagnostics).DoesNotContain("*AWT198*").AsWildcard() + .Because("ambiguity only applies to a hook that could actually bind more than one closed form"); + } } } diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt199ScanHookConflict.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt199ScanHookConflict.cs new file mode 100644 index 0000000..db7201a --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt199ScanHookConflict.cs @@ -0,0 +1,169 @@ +using System.Linq; + +namespace Awaiten.SourceGenerators.Tests; + +public partial class DiagnosticTests +{ + public class Awt199ScanHookConflict + { + [Fact] + public async Task ReportsWhenTwoScansNameDifferentOnActivatedHooks() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IPlugin { } + public interface IHandler { } + public sealed class Widget : IPlugin, IHandler { } + + [Container] + [Scan(typeof(IPlugin), OnActivated = nameof(PluginStarted))] + [Scan(typeof(IHandler), OnActivated = nameof(HandlerStarted))] + public static partial class MyContainer + { + private static void PluginStarted(object instance) { } + private static void HandlerStarted(object instance) { } + } + """); + + await That(result.Diagnostics).Contains("*AWT199*'PluginStarted'*'HandlerStarted'*").AsWildcard() + .Because("which OnActivated ran for Widget would depend on attribute order, so the contradiction is surfaced with the first scan's winner named"); + } + + [Fact] + public async Task ReportsWhenTwoScansNameDifferentOnReleaseHooks() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IPlugin { } + public interface IHandler { } + public sealed class Widget : IPlugin, IHandler { } + + [Container] + [Scan(typeof(IPlugin), Lifetime = AwaitenLifetime.Singleton, OnRelease = nameof(PluginStopped))] + [Scan(typeof(IHandler), Lifetime = AwaitenLifetime.Singleton, OnRelease = nameof(HandlerStopped))] + public static partial class MyContainer + { + private static void PluginStopped(object instance) { } + private static void HandlerStopped(object instance) { } + } + """); + + await That(result.Diagnostics).Contains("*AWT199*OnRelease*").AsWildcard() + .Because("the OnRelease slot contradicts across the two scans exactly like OnActivated"); + } + + [Fact] + public async Task DoesNotReportWhenTwoScansNameTheSameHook() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IPlugin { } + public interface IHandler { } + public sealed class Widget : IPlugin, IHandler { } + + [Container] + [Scan(typeof(IPlugin), OnActivated = nameof(Started))] + [Scan(typeof(IHandler), OnActivated = nameof(Started))] + public static partial class MyContainer + { + private static void Started(object instance) { } + } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT199*").AsWildcard() + .Because("both scans agree on the method, so there is nothing order-dependent to surface"); + } + + [Fact] + public async Task DoesNotReportWhenTwoScansHookDifferentSlots() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IStartable { } + public interface IStoppable { } + public sealed class Worker : IStartable, IStoppable { } + + [Container] + [Scan(typeof(IStartable), Lifetime = AwaitenLifetime.Singleton, OnActivated = nameof(Started))] + [Scan(typeof(IStoppable), Lifetime = AwaitenLifetime.Singleton, OnRelease = nameof(Stopped))] + public static partial class MyContainer + { + private static void Started(object instance) { } + private static void Stopped(object instance) { } + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("one scan's OnActivated and the other's OnRelease occupy different slots, so they merge instead of conflicting"); + await That(result.Sources.Values.Any(source => source.Contains("Started") && source.Contains("Stopped"))).IsTrue() + .Because("the merged registration carries both scans' hooks, not just the first scan's"); + } + + [Fact] + public async Task DoesNotReportWhenAnExplicitRegistrationOverridesTheScanHook() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IPlugin { } + public sealed class AlphaPlugin : IPlugin { } + + [Container] + [Singleton(OnActivated = nameof(Special))] + [Scan(typeof(IPlugin), OnActivated = nameof(FromScan))] + public static partial class MyContainer + { + private static void Special(IPlugin plugin) { } + private static void FromScan(IPlugin plugin) { } + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("a scan yields to an explicit registration of the same type, hooks included, so overriding is not a conflict"); + await That(result.Sources.Values.Any(source => source.Contains("Special"))).IsTrue() + .Because("the explicit registration's hook is the one that runs"); + await That(result.Sources.Values.Any(source => source.Contains("FromScan("))).IsFalse() + .Because("the scan's hook is replaced, not merged in beside the explicit one"); + } + + [Fact] + public async Task DoesNotReportWhenAnExplicitRegistrationWithoutHooksOptsOutOfTheScanHook() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IPlugin { } + public sealed class AlphaPlugin : IPlugin { } + + [Container] + [Singleton] + [Scan(typeof(IPlugin), OnActivated = nameof(FromScan))] + public static partial class MyContainer + { + private static void FromScan(IPlugin plugin) { } + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("an explicit registration replaces the scan's hooks along with everything else"); + await That(result.Sources.Values.Any(source => source.Contains("FromScan("))).IsFalse() + .Because("leaving the hook off the explicit registration deliberately opts the type out of it"); + } + } +} diff --git a/Tests/Awaiten.Tests/ScanTests.cs b/Tests/Awaiten.Tests/ScanTests.cs index e41e94f..344404f 100644 --- a/Tests/Awaiten.Tests/ScanTests.cs +++ b/Tests/Awaiten.Tests/ScanTests.cs @@ -206,6 +206,124 @@ public static partial class DualPanelContainer private static void Track(object instance) => HookProbe.Log.Add("tracked:" + instance.GetType().Name); } + [Fact] + public async Task Scan_TwoScansHookingDifferentSlots_MergeSoBothHooksRun() + { + HookProbe.Log.Clear(); + using (MergeHookContainer.Root container = new()) + { + container.Resolve(); + + // MergeWorker is matched by both scans: one names OnActivated, the other OnRelease. The hooks occupy + // different slots, so they merge instead of the second scan's being dropped (or conflicting, AWT199). + await That(HookProbe.Log).Contains("merge-activated:MergeWorker"); + } + + await That(HookProbe.Log).Contains("merge-released:MergeWorker") + .Because("the second scan's OnRelease merged onto the coalesced registration and ran at disposal"); + } + + public interface IMergeStart; + + public interface IMergeStop; + + public sealed class MergeWorker : IMergeStart, IMergeStop; + + [Container] + [Scan(typeof(IMergeStart), Lifetime = AwaitenLifetime.Singleton, OnActivated = nameof(Started))] + [Scan(typeof(IMergeStop), Lifetime = AwaitenLifetime.Singleton, OnRelease = nameof(Stopped))] + public static partial class MergeHookContainer + { + private static void Started(object instance) => HookProbe.Log.Add("merge-activated:" + instance.GetType().Name); + + private static void Stopped(object instance) => HookProbe.Log.Add("merge-released:" + instance.GetType().Name); + } + + [Fact] + public async Task Scan_OpenGenericMarker_DispatchesAGenericReleaseHookWithTheClosedTypeArgument() + { + HookProbe.Log.Clear(); + using (UnwireViewContainer.Root container = new()) + { + container.Resolve>(); + + await That(HookProbe.Log).DoesNotContain("unwired:IUnwireViewModel") + .Because("nothing is released while the container is alive"); + } + + // The marker closes as IUnwireView for UnwireWindow, so the generic OnRelease hook is + // dispatched as UnwireView at disposal, exactly like a generic OnActivated at construction. + await That(HookProbe.Log).Contains("unwired:IUnwireViewModel") + .Because("a generic scan hook binds its type argument on the release slot too"); + } + + public interface IUnwireView; + + public interface IUnwireViewModel; + + public sealed class UnwireWindow : IUnwireView; + + [Container] + [Scan(typeof(IUnwireView<>), As = ScanAs.Marker, Lifetime = AwaitenLifetime.Singleton, OnRelease = nameof(UnwireView))] + public static partial class UnwireViewContainer + { + private static void UnwireView(IUnwireView view) + => HookProbe.Log.Add("unwired:" + typeof(TViewModel).Name); + } + + [Fact] + public async Task Scan_OpenGenericMarker_DispatchesAGenericHookWithTwoTypeArguments() + { + HookProbe.Log.Clear(); + using PairBinderContainer.Root container = new(); + + container.Resolve>(); + + // The marker closes at two type arguments, so the hook is constructed with both: BindPair. + await That(HookProbe.Log).Contains("bound:String:Int32") + .Because("a generic scan hook binds every type parameter of a multi-argument marker closing"); + } + + public interface IPairBinder; + + public sealed class PriceBinder : IPairBinder; + + [Container] + [Scan(typeof(IPairBinder<,>), As = ScanAs.Marker, Lifetime = AwaitenLifetime.Singleton, OnActivated = nameof(BindPair))] + public static partial class PairBinderContainer + { + private static void BindPair(IPairBinder binder) + => HookProbe.Log.Add($"bound:{typeof(TSource).Name}:{typeof(TTarget).Name}"); + } + + [Fact] + public async Task Scan_OpenGenericBaseClassMarker_DispatchesAGenericHookWithTheClosedTypeArgument() + { + HookProbe.Log.Clear(); + using BasePanelContainer.Root container = new(); + + container.Resolve(); + + // OrdersPanel closes the base-class marker as PanelBase, so the generic hook binds the + // type argument from a base type exactly as it does from an implemented interface. + await That(HookProbe.Log).Contains("panel:OrdersPanelModel") + .Because("a marker closed through the inheritance chain feeds a generic hook like an interface closing"); + } + + public sealed class OrdersPanelModel; + + public abstract class PanelBase; + + public sealed class OrdersPanel : PanelBase; + + [Container] + [Scan(typeof(PanelBase<>), Lifetime = AwaitenLifetime.Singleton, OnActivated = nameof(WirePanel))] + public static partial class BasePanelContainer + { + private static void WirePanel(PanelBase panel) + => HookProbe.Log.Add("panel:" + typeof(TModel).Name); + } + [Fact] public async Task GenericScan_RegistersMatchesLikeTheTypeofForm() { From adbcc3744b69d89ae8dc01ccd4e6971378774069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sat, 18 Jul 2026 11:56:54 +0200 Subject: [PATCH 4/8] test: lock identical-construction dedup and document the self-only explicit override corner A review follow-up pass: add a generator test proving that two markers closing to the identical type argument dedupe into a single constructed hook dispatch with no AWT198, and extend the scanning docs to state that an explicit registration of just the concrete type strips the scan hooks even where the scan still supplies its marker mapping. --- Docs/pages/registration/07-scanning.md | 2 +- ...cTests.Awt198GenericHookAmbiguousMarker.cs | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/Docs/pages/registration/07-scanning.md b/Docs/pages/registration/07-scanning.md index 3aee5ce..5704489 100644 --- a/Docs/pages/registration/07-scanning.md +++ b/Docs/pages/registration/07-scanning.md @@ -115,7 +115,7 @@ public static partial class CoffeeShop The hook's first parameter is the match, so it must accept every one: type it as the scanned marker (or `object`). Parameters after it are resolved from the graph exactly as for an [explicit registration's hook](../lifetime/lifecycle-hooks#hook-parameters), and the same rules and diagnostics apply: an unusable hook name is [AWT164](../diagnostics#awt164), an unregistered parameter is [AWT101](../diagnostics#awt101). -When two scans match the same type, their hooks merge: one scan's `OnActivated` combines with another's `OnRelease`, and both naming the same method is fine. Two scans naming *different* methods for the same slot contradict each other, so the first scan's method is used and the contradiction is surfaced as [AWT199](../diagnostics#awt199). An [explicit registration](#overriding-a-scanned-type) of a scanned type is different: it replaces the scan's hooks along with everything else, so name the hook on the explicit registration too if the special-cased type should keep it, and leave it off to deliberately opt that type out. +When two scans match the same type, their hooks merge: one scan's `OnActivated` combines with another's `OnRelease`, and both naming the same method is fine. Two scans naming *different* methods for the same slot contradict each other, so the first scan's method is used and the contradiction is surfaced as [AWT199](../diagnostics#awt199). An [explicit registration](#overriding-a-scanned-type) of a scanned type is different: it replaces the scan's hooks along with everything else, so name the hook on the explicit registration too if the special-cased type should keep it, and leave it off to deliberately opt that type out. The replacement follows the type, not the service: an explicit registration of just the concrete type, say `[Transient]` beside a marker scan, strips the scan's hooks from `Espresso` even where the scan still supplies its marker mapping. ### Generic hooks on an open marker diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt198GenericHookAmbiguousMarker.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt198GenericHookAmbiguousMarker.cs index 2d11ad8..4eb0129 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt198GenericHookAmbiguousMarker.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt198GenericHookAmbiguousMarker.cs @@ -107,6 +107,33 @@ await That(result.Diagnostics).Contains("*AWT198*").AsWildcard() .Because("two open-generic scans bind the same generic hook to different closings of Dual, so its type argument is ambiguous rather than silently the first-seen one"); } + [Fact] + public async Task DoesNotReportWhenTwoMarkersConstructTheIdenticalMethod() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IView { } + public interface IEditor { } + public sealed class Dual : IView, IEditor { } + + [Container] + [Scan(typeof(IView<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + [Scan(typeof(IEditor<>), As = ScanAs.Marker, OnActivated = nameof(Wire))] + public static partial class MyContainer + { + private static void Wire(object instance) { } + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("both closings construct the identical Wire, so the dispatch is the same and nothing is ambiguous"); + await That(result.Sources.Values.Any(source => source.Contains("Wire"))).IsTrue() + .Because("the single constructed dispatch is emitted"); + } + [Fact] public async Task DoesNotReportWhenNoHookIsNamed() { From e42e6f61c6a06022e2e14b93ba4fc681b5db72d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sun, 19 Jul 2026 14:32:45 +0200 Subject: [PATCH 5/8] fix: report AWT190 when an ambiguous generic hook overload has a usable sibling and substitute array element types in constraint checks Two review findings on the scan lifecycle hook branch: - A generic hook overload that was ambiguous over multiple closed marker forms silently dropped out of overload resolution, so a type closing the marker twice dispatched a sibling overload without any diagnostic while the single-closing shape of the same code reported AWT190. The ambiguous method now counts as a usable overload, so both shapes report AWT190. AWT198 stays reserved for its designed case, where the sole usable candidate is one generic method with several bindable closings; the root collision here is between methods, so AWT198's split-the-family remediation would be misleading. Documented in the AWT198 section and the scanning page. - SubstituteTypeParameters did not recurse into array element types, so a constraint like where T : IEnumerable was checked against the unsubstituted form and falsely rejected a valid closing with AWT164. Arrays now substitute via CreateArrayTypeSymbol; pointers need no handling since C# forbids pointer types as generic type arguments. Adds regression tests for both overload shapes and the array constraint case. --- Docs/pages/09-diagnostics.md | 2 +- Docs/pages/registration/07-scanning.md | 2 +- .../AwaitenGenerator.Production.cs | 47 +++++++++++------ ...gnosticTests.Awt164InvalidLifecycleHook.cs | 32 ++++++++++++ ...osticTests.Awt190AmbiguousLifecycleHook.cs | 50 +++++++++++++++++++ 5 files changed, 115 insertions(+), 18 deletions(-) diff --git a/Docs/pages/09-diagnostics.md b/Docs/pages/09-diagnostics.md index 1c9b8b3..d8ccc8c 100644 --- a/Docs/pages/09-diagnostics.md +++ b/Docs/pages/09-diagnostics.md @@ -1275,7 +1275,7 @@ public static partial class CoffeeShop } ``` -A [generic scan hook](./registration/scanning#lifecycle-hooks) binds its type argument from a closed marker form, so it needs exactly one it can bind. `DualView`, which closes `IView<>` at both `Orders` and `Payments`, offers two; the same happens when two scans bind the same generic hook through differently-closed markers of one type. Only forms the hook could actually bind count: matching arity, satisfied constraints, and a first parameter that accepts the match. If exactly one form remains after those checks the hook binds it, so a `where TViewModel : class` constraint can settle a family that closes the marker at one class and one struct. Register the type explicitly with the intended hook, or split the family so only one closed form binds it. Only a *generic* hook is affected: a non-generic hook (its parameter typed as the marker or `object`) takes no type argument, so a match with several closings is fine for it, as is a match closing the marker several times *without* any hook (each closed form registers as its own collection member). +A [generic scan hook](./registration/scanning#lifecycle-hooks) binds its type argument from a closed marker form, so it needs exactly one it can bind. `DualView`, which closes `IView<>` at both `Orders` and `Payments`, offers two; the same happens when two scans bind the same generic hook through differently-closed markers of one type. Only forms the hook could actually bind count: matching arity, satisfied constraints, and a first parameter that accepts the match. If exactly one form remains after those checks the hook binds it, so a `where TViewModel : class` constraint can settle a family that closes the marker at one class and one struct. Register the type explicitly with the intended hook, or split the family so only one closed form binds it. Reported only when the ambiguous generic method is the sole usable overload of the hook name: if another overload also accepts the match, the collision is between overloads rather than closings (settling the closings would still leave two usable overloads), so it is [AWT190](#awt190) instead. Only a *generic* hook is affected: a non-generic hook (its parameter typed as the marker or `object`) takes no type argument, so a match with several closings is fine for it, as is a match closing the marker several times *without* any hook (each closed form registers as its own collection member). ### AWT199 diff --git a/Docs/pages/registration/07-scanning.md b/Docs/pages/registration/07-scanning.md index 5704489..e7bbcd8 100644 --- a/Docs/pages/registration/07-scanning.md +++ b/Docs/pages/registration/07-scanning.md @@ -134,7 +134,7 @@ public static partial class App For `MainWindow : IView` the generator dispatches `WireView(mainWindow, viewModel)`, resolving the matching `IMainViewModel` from the graph. The type argument is visible to graph analysis, so an unregistered view model fails the build ([AWT101](../diagnostics#awt101)) rather than at runtime. A non-generic method works too (its parameters typed as the marker or `object`); the type argument only applies when the method is generic. -Because a *generic* hook takes its type argument from a closed marker form, it needs exactly one it can bind. A match offering several, by closing the marker more than once (`Dual : IView, IView`), leaves that argument ambiguous and is reported as [AWT198](../diagnostics#awt198): register such a type explicitly with the hook it needs, or split the family so only one closed form binds it. Only forms the hook could actually bind count, so a constraint (`where TViewModel : class`) that rules out all closings but one settles the choice. A non-generic hook takes no type argument, so a match with several closings is fine for it. +Because a *generic* hook takes its type argument from a closed marker form, it needs exactly one it can bind. A match offering several, by closing the marker more than once (`Dual : IView, IView`), leaves that argument ambiguous and is reported as [AWT198](../diagnostics#awt198): register such a type explicitly with the hook it needs, or split the family so only one closed form binds it. Only forms the hook could actually bind count, so a constraint (`where TViewModel : class`) that rules out all closings but one settles the choice. If the hook name is overloaded and another overload also accepts the match, the collision is between overloads rather than closings and is reported as [AWT190](../diagnostics#awt190) instead. A non-generic hook takes no type argument, so a match with several closings is fine for it. ## Overriding a scanned type diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs index 5b8c5c4..a09741f 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs @@ -292,8 +292,10 @@ private static InstanceModel BuildPrebuiltInstance( /// kept per hook slot on ImplInfo: exactly one bindable form (matching arity, satisfied constraints, /// a first parameter accepting the match; see ) dispatches the /// constructed method, none leaves it unusable like any other shape mismatch (falling through to AWT164), - /// and more than one is reported as AWT198 (a - /// non-generic hook, needing no arguments, is unaffected). + /// and more than one is reported as AWT198 when it + /// is the only usable overload (a non-generic hook, needing no arguments, is unaffected). A multi-form + /// generic overload beside another usable overload is an overload collision, AWT190, since settling the + /// closings would still leave two usable overloads. ///