From 42bb8f6227c109e05c39fa451718fbcbf833393d Mon Sep 17 00:00:00 2001 From: Ian Johnson Date: Sun, 16 Aug 2026 06:28:39 -0400 Subject: [PATCH 1/7] Gate the async interception tests instead of racing a timer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both tests assert that nothing has logged an exit between the task being handed back and being awaited, and manufactured that window with `await Task.Delay(20)`. The timer and its continuation are independent of the test thread, so any stall longer than 20ms lets the whole chain finish before the assertion reads the log — the failure is the log arriving complete rather than partial. A 60ms stall reproduces it 3/3. The work now awaits a gate the test releases, so the assertion is about a suspension rather than a stopwatch. It survives the same 60ms stall, and it is a stronger claim than before: the old shape would also have passed on a lucky margin, with the work at 19ms and the assertion at 5ms. The runtime gate defaults to Task.CompletedTask so a test with no interest in it cannot deadlock, and the generator one hangs off the Recorder the harness already owns, which lives in the generated assembly and so cannot leak between compilations. InterceptorGenerationTests had the identical shape and had simply not lost the coin flip yet; fixing only the test that failed would have left it live. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CMV4J1eVscS5EBMUhhWc2x --- .../InterceptorGenerationTests.cs | 18 +++++++++++++++++- .../RuntimeTests/InvocationPipelineTests.cs | 14 +++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/tests/DependencyModules.Tests/GeneratorTests/InterceptorGenerationTests.cs b/tests/DependencyModules.Tests/GeneratorTests/InterceptorGenerationTests.cs index a2169fc..6431b36 100644 --- a/tests/DependencyModules.Tests/GeneratorTests/InterceptorGenerationTests.cs +++ b/tests/DependencyModules.Tests/GeneratorTests/InterceptorGenerationTests.cs @@ -48,12 +48,15 @@ public void VoidMethod_RunsThroughThePipeline() { public async Task AsyncMethod_ExitsAfterTheWorkCompletes() { var generated = GeneratedAssembly.Create(Source( "System.Threading.Tasks.Task Compute(int a);", - "public async System.Threading.Tasks.Task Compute(int a) { await System.Threading.Tasks.Task.Delay(20); return a * 2; }")); + "public async System.Threading.Tasks.Task Compute(int a) { await Recorder.Gate.Task; return a * 2; }")); var task = (Task)Invoke(generated.ResolveRequired("IWork"), "Compute", 21)!; + // The work is suspended at a gate nothing has released, so this cannot have happened yet. Assert.DoesNotContain("exit Compute", Log(generated)); + Gate(generated).SetResult(); + var result = await task; Assert.Equal(42, result); @@ -807,6 +810,13 @@ public partial class TestModule; private static IReadOnlyList Log(GeneratedAssembly generated) => (IReadOnlyList)generated.Type("Recorder").GetField("Entries")!.GetValue(null)!; + /// + /// The gate belongs to the generated assembly, so each compilation gets its own and no test can + /// release another's. + /// + private static TaskCompletionSource Gate(GeneratedAssembly generated) => + (TaskCompletionSource)generated.Type("Recorder").GetField("Gate")!.GetValue(null)!; + private static object? Invoke(object target, string method, params object?[] arguments) => target.GetType().GetMethod(method)!.Invoke(target, arguments); @@ -821,6 +831,12 @@ namespace TestNamespace; public static class Recorder { public static readonly List Entries = new(); + + // Released by the test. An implementation awaiting this is suspended until the test says + // otherwise, which is what "the work has not finished yet" needs in order to be a fact + // rather than a race against a timer. + public static readonly TaskCompletionSource Gate = + new(TaskCreationOptions.RunContinuationsAsynchronously); } """; diff --git a/tests/DependencyModules.Tests/RuntimeTests/InvocationPipelineTests.cs b/tests/DependencyModules.Tests/RuntimeTests/InvocationPipelineTests.cs index 77f8013..2eace70 100644 --- a/tests/DependencyModules.Tests/RuntimeTests/InvocationPipelineTests.cs +++ b/tests/DependencyModules.Tests/RuntimeTests/InvocationPipelineTests.cs @@ -157,11 +157,16 @@ public void AnException_PropagatesThroughThePipeline() { [Fact] public async Task AsyncMember_ExitsWhenTheWorkFinishesRatherThanWhenTheTaskIsHandedBack() { var fixture = new Fixture(); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + fixture.Implementation.ComputeGate = gate.Task; var task = fixture.Service.ComputeAsync(21); + // The work is suspended at a gate nothing has released, so this cannot have happened yet. Assert.DoesNotContain(fixture.Log, entry => entry.Contains("exit")); + gate.SetResult(); + var result = await task; Assert.Equal(42, result); @@ -243,8 +248,15 @@ public void Record(string entry) { Calls.Add($"Record({entry})"); } + /// + /// Held open by the test rather than by a timer. A test asserting that the work has not + /// finished yet is asserting about a suspension, and a sleep only makes that likely. + /// Defaults to already-completed so a test that does not care cannot deadlock on it. + /// + public Task ComputeGate { get; set; } = Task.CompletedTask; + public async Task ComputeAsync(int value) { - await Task.Delay(20); + await ComputeGate; Calls.Add($"ComputeAsync({value})"); From c3dad52b218568f86e216d9697e9198e434ff1bb Mon Sep 17 00:00:00 2001 From: Ian Johnson Date: Sun, 16 Aug 2026 06:28:52 -0400 Subject: [PATCH 2/7] Open a nullable context in the generated registrations file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A registered service type carries whatever nullable annotation its declaration used, so `class GetBookHandler : IHandler` emits `typeof(...Book?)`. Roslyn requires generated code to open a nullable context explicitly however the consuming project is configured, and the registrations file was the one generated file that never did — the module, attribute and interceptor writers all already call EnableNullable. The result was CS8669 on a find-by-id handler, which is about as ordinary a shape as exists: a warning the consumer cannot fix from their own source without dropping the annotation from their own domain signatures, and a hard build failure under TreatWarningsAsErrors. It reproduced on both the attribute and the convention path, because the convention path emits through the same writer — which is also why one call fixes both files. Nullability is not stripped from the emitted typeof. It is inert there, since `typeof(Book?)` and `typeof(Book)` are one runtime type, and removing it would mean touching type modelling that decoration and interception rely on: ConstructorArgumentWriter reads nullability to choose GetService over GetRequiredService, and the interceptor wrappers need the annotations to keep implementing the interfaces they wrap. Probing a decorator declared against `IStore` over a registration of `IStore` confirmed the two still match, so the annotation is cosmetic rather than a silent miss. The nine snapshots re-approve with two lines each and no other change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CMV4J1eVscS5EBMUhhWc2x --- .../DependencyFileWriter.cs | 8 +++ .../GeneratedCodeRobustnessTests.cs | 64 +++++++++++++++++++ ...s.GenericServiceRegistrations.verified.txt | 2 + ...Tests.KeyedAndAsRegistrations.verified.txt | 2 + ...ModuleWithAllServiceLifetimes.verified.txt | 2 + ...ructorParametersAndProperties.verified.txt | 2 + ...WithCoverageExclusionDisabled.verified.txt | 2 + ...duleWithEnvironmentConditions.verified.txt | 2 + ...ionSnapshotTests.RecordModule.verified.txt | 2 + ...ests.RegistrationTypeVariants.verified.txt | 2 + ...ionSnapshotTests.SimpleModule.verified.txt | 2 + 11 files changed, 90 insertions(+) diff --git a/src/DependencyModules.SourceGenerator.Impl/DependencyFileWriter.cs b/src/DependencyModules.SourceGenerator.Impl/DependencyFileWriter.cs index 1bf3fb4..cc5b72f 100644 --- a/src/DependencyModules.SourceGenerator.Impl/DependencyFileWriter.cs +++ b/src/DependencyModules.SourceGenerator.Impl/DependencyFileWriter.cs @@ -69,6 +69,14 @@ private void GenerateClass(ModuleEntryPointModel entryPointModel, classDefinition.Modifiers |= ComponentModifier.Partial; + // A registered service type carries whatever nullable annotation its declaration used, so + // `class GetBookHandler : IRequestHandler` emits typeof(...Book?). Roslyn + // requires generated code to open a nullable context explicitly whatever the project sets, + // and without one that annotation is CS8669 — a warning the consumer cannot fix from their + // own source, and a build break under TreatWarningsAsErrors. The module and attribute + // writers already do this; the registrations file is where the annotations actually land. + classDefinition.EnableNullable(); + if (configurationModel.ExcludeGeneratedCodeFromCoverage && !_coverageAttributeOnMethod) { classDefinition.AddAttribute( TypeDefinition.Get("System.Diagnostics.CodeAnalysis", "ExcludeFromCodeCoverage")); diff --git a/tests/DependencyModules.Tests/GeneratorTests/GeneratedCodeRobustnessTests.cs b/tests/DependencyModules.Tests/GeneratorTests/GeneratedCodeRobustnessTests.cs index bb50c69..d3e3398 100644 --- a/tests/DependencyModules.Tests/GeneratorTests/GeneratedCodeRobustnessTests.cs +++ b/tests/DependencyModules.Tests/GeneratorTests/GeneratedCodeRobustnessTests.cs @@ -91,6 +91,70 @@ public partial class TestModule; string.Join(Environment.NewLine, warnings.Select(w => $" {w.Id} {w.GetMessage()}"))); } + /// + /// Regression test: a registered service type keeps whatever nullable annotation its declaration + /// used, so IHandler<Query, Result?> emits typeof(…Result?). Roslyn requires + /// generated code to open a nullable context explicitly however the consuming project is + /// configured, and the registrations file was the one generated file that never did — making a + /// find-by-id handler, the most ordinary shape there is, CS8669 and a build break under + /// TreatWarningsAsErrors that the consumer could not fix from their own source. + /// + /// GeneratedCode_ProducesNoWarnings above covers the same ground but registers a service whose + /// type arguments carry no annotation, which is why it stayed green throughout. + /// + [Theory] + [InlineData("attribute", "[SingletonService]")] + [InlineData("convention", "")] + public void GeneratedCode_ProducesNoWarnings_ForANullableTypeArgument(string _, string attribute) { + var result = GeneratorTestHarness.Run( + $$""" + using DependencyModules.Runtime.Attributes; + using DependencyModules.Runtime.Conventions; + + namespace TestNamespace; + + public class Book; + + public interface IHandler { + TResult Handle(TQuery query); + } + + public record GetBook(string Isbn); + + {{attribute}} + public class GetBookHandler : IHandler { + public Book? Handle(GetBook query) => null; + } + + [DependencyModule] + public partial class TestModule : IConventionModule { + void IConventionModule.Conventions(IConventionDefinitions conventions) { + conventions.RegisterAll(typeof(IHandler<,>)).AsScoped(); + } + } + """); + + result.AssertNoErrors(); + + AssertNoWarningsFromGeneratedCode(result); + } + + private static void AssertNoWarningsFromGeneratedCode(GeneratorResult result) { + var generatedTreePaths = result.Compilation.SyntaxTrees + .Where(tree => tree.FilePath.EndsWith(".g.cs", StringComparison.Ordinal)) + .Select(tree => tree.FilePath) + .ToHashSet(StringComparer.Ordinal); + + var warnings = result.CompilationDiagnostics + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Warning) + .Where(diagnostic => generatedTreePaths.Contains(diagnostic.Location.SourceTree?.FilePath ?? "")) + .ToArray(); + + Assert.True(warnings.Length == 0, + "Generated code produced warnings:" + Environment.NewLine + + string.Join(Environment.NewLine, warnings.Select(w => $" {w.Id} {w.GetMessage()}"))); + } + [Fact] public void GeneratedCode_QualifiesReferencesToTheRuntime() { var result = GeneratorTestHarness.Run( diff --git a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.GenericServiceRegistrations.verified.txt b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.GenericServiceRegistrations.verified.txt index 76a40db..0d67ae6 100644 --- a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.GenericServiceRegistrations.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.GenericServiceRegistrations.verified.txt @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; namespace TestNamespace { + #nullable enable [ExcludeFromCodeCoverage] public partial class TestModule { @@ -23,6 +24,7 @@ namespace TestNamespace ); } } + #nullable disable } // ---- TestModule.Module.g.cs ---- diff --git a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.KeyedAndAsRegistrations.verified.txt b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.KeyedAndAsRegistrations.verified.txt index 08a36cd..d1c736e 100644 --- a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.KeyedAndAsRegistrations.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.KeyedAndAsRegistrations.verified.txt @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; namespace TestNamespace { + #nullable enable [ExcludeFromCodeCoverage] public partial class TestModule { @@ -24,6 +25,7 @@ namespace TestNamespace ); } } + #nullable disable } // ---- TestModule.Module.g.cs ---- diff --git a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithAllServiceLifetimes.verified.txt b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithAllServiceLifetimes.verified.txt index 2b459f6..8f5d2b5 100644 --- a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithAllServiceLifetimes.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithAllServiceLifetimes.verified.txt @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; namespace TestNamespace { + #nullable enable [ExcludeFromCodeCoverage] public partial class TestModule { @@ -27,6 +28,7 @@ namespace TestNamespace ); } } + #nullable disable } // ---- TestModule.Module.g.cs ---- diff --git a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithConstructorParametersAndProperties.verified.txt b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithConstructorParametersAndProperties.verified.txt index 23838a3..611fd2f 100644 --- a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithConstructorParametersAndProperties.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithConstructorParametersAndProperties.verified.txt @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; namespace TestNamespace { + #nullable enable [ExcludeFromCodeCoverage] public partial class TestModule { @@ -19,6 +20,7 @@ namespace TestNamespace ); } } + #nullable disable } // ---- TestModule.Module.g.cs ---- diff --git a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithCoverageExclusionDisabled.verified.txt b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithCoverageExclusionDisabled.verified.txt index 5df12c9..43e545c 100644 --- a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithCoverageExclusionDisabled.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithCoverageExclusionDisabled.verified.txt @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; namespace TestNamespace { + #nullable enable public partial class TestModule { [DynamicDependency(nameof(ModuleDependencies))] @@ -18,6 +19,7 @@ namespace TestNamespace ); } } + #nullable disable } // ---- TestModule.Module.g.cs ---- diff --git a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithEnvironmentConditions.verified.txt b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithEnvironmentConditions.verified.txt index ea3a3fe..f2c2cdc 100644 --- a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithEnvironmentConditions.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.ModuleWithEnvironmentConditions.verified.txt @@ -6,6 +6,7 @@ using System.Diagnostics.CodeAnalysis; namespace TestNamespace { + #nullable enable [ExcludeFromCodeCoverage] public partial class TestModule { @@ -38,6 +39,7 @@ namespace TestNamespace } } } + #nullable disable } // ---- TestModule.Module.g.cs ---- diff --git a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.RecordModule.verified.txt b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.RecordModule.verified.txt index 6c9448d..4c1f3fe 100644 --- a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.RecordModule.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.RecordModule.verified.txt @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; namespace TestNamespace { + #nullable enable [ExcludeFromCodeCoverage] public partial record class TestModule { @@ -19,6 +20,7 @@ namespace TestNamespace ); } } + #nullable disable } // ---- TestModule.Module.g.cs ---- diff --git a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.RegistrationTypeVariants.verified.txt b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.RegistrationTypeVariants.verified.txt index 8d135a3..f9ccb30 100644 --- a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.RegistrationTypeVariants.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.RegistrationTypeVariants.verified.txt @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; namespace TestNamespace { + #nullable enable [ExcludeFromCodeCoverage] public partial class TestModule { @@ -29,6 +30,7 @@ namespace TestNamespace ); } } + #nullable disable } // ---- TestModule.Module.g.cs ---- diff --git a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.SimpleModule.verified.txt b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.SimpleModule.verified.txt index 9bfcacc..59ecd15 100644 --- a/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.SimpleModule.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/ModuleGenerationSnapshotTests.SimpleModule.verified.txt @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; namespace TestNamespace { + #nullable enable [ExcludeFromCodeCoverage] public partial class TestModule { @@ -19,6 +20,7 @@ namespace TestNamespace ); } } + #nullable disable } // ---- TestModule.Module.g.cs ---- From afd6cc68865b70220b1643769b671d9e76dab634 Mon Sep 17 00:00:00 2001 From: Ian Johnson Date: Sun, 16 Aug 2026 06:29:47 -0400 Subject: [PATCH 3/7] Restrict [InjectValues] to parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was the only one of the three testing attributes without an AttributeUsage — MockAttribute is pinned to parameters and TestExportAttribute to methods — so writing it on a test method compiled, was never read, and then failed inside ActivatorUtilities with "Multiple constructors accepting all given argument types have been found in type 'System.String'", which names neither the parameter nor the real mistake. It is now CS0592 at the attribute. The remarks also say what the values are, since the guide's table listing this against "String parameters" describes the one case it cannot do: they are the parameter type's constructor arguments, combined with what the container supplies. A parameter that should simply be a value wants [InlineData]. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CMV4J1eVscS5EBMUhhWc2x --- .../Attributes/InjectValuesAttribute.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/DependencyModules.Testing/Attributes/InjectValuesAttribute.cs b/src/DependencyModules.Testing/Attributes/InjectValuesAttribute.cs index 43ff8d5..c2d9f74 100644 --- a/src/DependencyModules.Testing/Attributes/InjectValuesAttribute.cs +++ b/src/DependencyModules.Testing/Attributes/InjectValuesAttribute.cs @@ -14,6 +14,19 @@ namespace DependencyModules.Testing.Attributes; /// This attribute can be applied to method parameters and is resolved when /// the method is invoked, enabling runtime value injection. /// +/// +/// The values are the parameter type's constructor arguments, not the parameter's value. +/// They are combined with whatever the container supplies, so +/// [InjectValues("wholesale")] OrderRequest constructs an +/// OrderRequest(IValidator<PlaceOrder>, string) with the validator resolved and the +/// string supplied. A parameter that should simply be a value wants a data row — +/// [InlineData] or [TestCase] — which composes with [ModuleTest]; asking for a +/// bare string here fails, because System.String has no matching constructor. +/// +/// Restricted to parameters. Applied anywhere else it is never read, and the test then fails inside +/// ActivatorUtilities naming the parameter's type rather than the misplaced attribute. +/// +[AttributeUsage(AttributeTargets.Parameter)] public class InjectValuesAttribute(params object[] value) : Attribute, IInjectValueAttribute { /// From b4c37cd74709aa0b35a1459ded94b0830823d187 Mon Sep 17 00:00:00 2001 From: Ian Johnson Date: Sun, 16 Aug 2026 06:35:09 -0400 Subject: [PATCH 4/7] Lead the README with the problem rather than the mechanism MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It opened by naming the implementation — "a C# source generator package that uses attributes to create dependency injection registration modules" — which says what the thing is before the reader knows why they want one. The single most persuasive artefact in the document, a generated registration that is plainly ordinary C#, sat at the very bottom below four hundred lines of reference. Now: the hook, a link to the documentation site, the attribute and the code it generates side by side, and a table answering the question every reader of a .NET DI library arrives with, which is why not Scrutor. The reference material that the site covers in depth is condensed to a lookup table that links out, so the README is a pitch and an index rather than a second copy of the docs. Two corrections to code that did not compile. The quick start now carries the `using DependencyModules.Runtime.Attributes;` its services need and the `using YourRootNamespace;` that top-level statements need to name the generated ApplicationModule — the latter being a real papercut, since the module takes the project's root namespace while top-level statements sit in the global one. integ-tests/ConsoleTestProject has always had that using; the README omitted it and sent every reader into a CS0246 that names a type they never wrote. Every snippet in this file was compiled and run before committing, which is how both omissions were found. Sample links are absolute, since this file also ships as the NuGet package readme where relative paths do not resolve. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CMV4J1eVscS5EBMUhhWc2x --- README.md | 508 ++++++++++++++++++++---------------------------------- 1 file changed, 185 insertions(+), 323 deletions(-) diff --git a/README.md b/README.md index 9033ec0..b39526c 100644 --- a/README.md +++ b/README.md @@ -5,309 +5,261 @@ [![coverage](https://raw.githubusercontent.com/ipjohnson/DependencyModules/badges/coverage.svg)](https://github.com/ipjohnson/DependencyModules/actions/workflows/build-package.yaml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE.txt) -DependencyModules is a C# source generator package that uses attributes to create -dependency injection registration modules. These modules can then be used to populate -an IServiceCollection instance. +**Your DI registrations, written as attributes and compiled into your assembly.** +No reflection, no assembly scanning, no startup cost — and Native AOT works, because +there is nothing left to trim away. -Registration code is generated at compile time, so there is no reflection or assembly -scanning at run time. +📖 **[Documentation](https://ipjohnson.github.io/DependencyModules/)** · +[Getting started](https://ipjohnson.github.io/DependencyModules/guide/getting-started) · +[Conventions](https://ipjohnson.github.io/DependencyModules/guide/conventions) · +[Decorators](https://ipjohnson.github.io/DependencyModules/guide/decorators) · +[Testing](https://ipjohnson.github.io/DependencyModules/guide/testing) · +[AOT](https://ipjohnson.github.io/DependencyModules/guide/aot) -## Installation +## The whole trick -```shell -dotnet add package DependencyModules.Runtime -dotnet add package DependencyModules.SourceGenerator -``` - -Requires .NET 8.0 or later. The packages ship both `net8.0` and `net10.0` assemblies, so a project on -either LTS release gets one built against its own framework. See [CHANGELOG.md](CHANGELOG.md) for -release notes. - -## Service Attributes - -* `[DependencyModule]` - used to attribute class that will become dependency module (must be partial) -* `[SingletonService]` - registers service as `AddSingleton` -* `[ScopedService]` - registers service as `AddScoped` -* `[TransientService]` - registers service as `AddTransient` -* `[CrossWireService]` - registers implementation and interfaces with the same lifetime +You mark a class: ```csharp -// Registration example -[DependencyModule] -public partial class ApplicationModule; - -// registers SomeClass implementation for ISomeService [SingletonService] -public class SomeClass : ISomeService -{ - public string SomeProp => "SomeString"; -} - -// registers OtherService implementation -[TransientService] -public class OtherService -{ - public OtherService(ISomeService service) - { - SomeProp = service.SomeProp; - } - public string SomeProp { get; } -} +public class SmtpEmailSender : IEmailSender; ``` -Note: `[DependencyModule]` is not required for [Top-level](https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/program-structure/top-level-statements) statement applications. - -Note: a `[DependencyModule]` class must be declared directly in a namespace, not nested inside -another type. A nested module generates a separate, detached class rather than completing the -partial declaration, so its registrations never run. Services registered with -`[SingletonService]` and friends may be nested freely. -## Container Instantiation - -* `AddModule` - method adds root module to service collection -* `AddModules` - add a list of modules to the service collection +At build time the generator writes the registration you would have written yourself: ```csharp -// AddModule and AddModules are extension methods in the DependencyModules.Runtime namespace -using DependencyModules.Runtime; +// ApplicationModule.Dependencies.g.cs +services.AddSingleton( + typeof(global::MyApp.IEmailSender), + typeof(global::MyApp.SmtpEmailSender) +); +``` -var serviceCollection = new ServiceCollection(); +That is the entire mechanism. The output is ordinary C# that you can read, grep, set a +breakpoint in, and check into a review. Nothing inspects your assembly at run time, so +there is no startup scan to pay for and nothing for the trimmer to guess about. -serviceCollection.AddModule(); -// or -serviceCollection.AddModules(new ApplicationModule(), ...); +## Why not a runtime scanner? -var provider = serviceCollection.BuildServiceProvider(); +If you have used Scrutor, Autofac modules, or hand-written `AddScoped` lists, this is what +changes: -var service = provider.GetService(); -``` +| | Runtime scanning | DependencyModules | +|---|---|---| +| When registration is decided | First request to the container | `dotnet build` | +| A convention that matches nothing | Silent | [`DM0005`](https://ipjohnson.github.io/DependencyModules/reference/diagnostics) at build | +| A service that cannot be constructed | `InvalidOperationException`, eventually | [`DM0002`](https://ipjohnson.github.io/DependencyModules/reference/diagnostics) at build | +| Trimming / Native AOT | Types disappear; scanner finds nothing | Literal `typeof()`, so the trimmer keeps them | +| Startup cost | Proportional to assembly size | None | +| What actually got registered | Debugger, at run time | A file you can open | -Note: to avoid duplicate modules it's recommended to only call AddModule(s) once in an application and never inside a Module. -## Factories +The interesting half is the third row. A trimmer removes `CreateOrderHandler` because +nothing statically references it — a scanner that would have found it by reflection does +not count. Emitting `typeof(CreateOrderHandler)` into your assembly is a static reference, +which is why this approach and Native AOT get along. -Sometimes it's not possible to construct all types through normal registration. -Factories can be registered with a module using the registration attributes. +## Install -```csharp -public class SomeClass : ISomeInterface { - public SomeClass(IDep one, IDepTwo two, DateTime dateTime) { ... } - - [SingletonService] - public static ISomeInterface Factory(IDep one, IDepTwo two) { - return new SomeClass(one, two, DateTime.Now()); - } -} +```shell +dotnet add package DependencyModules.Runtime +dotnet add package DependencyModules.SourceGenerator ``` -## Module Re-use -DependencyModules creates an `Attribute` class that can be used to apply sub dependencies. +Requires .NET 8.0 or later. The packages ship `net8.0` and `net10.0` assemblies, so a +project on either LTS gets one built against its own framework. Console applications also +want `Microsoft.Extensions.DependencyInjection`. + +## Quick start + +Mark the services, declare a module, load it once: ```csharp -// Modules can be re-used with the generated attributes -[DependencyModule] -[ApplicationModule] -public partial class AnotherModule; -``` +// Services.cs +using DependencyModules.Runtime.Attributes; -## Parameters +namespace MyApp; -Sometimes you want to provide extra registration for your module. -This can be achieved by adding a constructor to your module or optional properties. -Note these parameters and properties will be correspondingly implemented in the module attribute. +[SingletonService] +public class SmtpEmailSender : IEmailSender; + +[ScopedService] +public class OrderRepository : IOrderRepository; +``` ```csharp -[DependencyModule] -public partial class SomeModule(bool someFlag) : IServiceCollectionConfiguration -{ - public string OptionalString { get; set; } = ""; - - public void ConfigureServices(IServiceCollection services) - { - if (someFlag) - { - // custom registration - } - } -} +// Program.cs +using MyApp; // the generated module lives in your root namespace +using DependencyModules.Runtime; +using Microsoft.Extensions.DependencyInjection; -[DependencyModule] -[SomeModule(true, OptionalString = "otherString")] -public partial class SomeOtherModule; +var services = new ServiceCollection(); +services.AddModule(); + +var provider = services.BuildServiceProvider(); ``` -## Module Features -Because module configuration happens before the dependency injection container is instantiated it's impossible to use the container for configuration. -To support configuration discovery before registration, the feature interface can be -implemented in modules and be passed to a handler at registration time. Features are applied before services and decorators. +`ApplicationModule` is generated for you in a project whose entry point is a top-level +`Program.cs`. Anywhere else — a class library, or a project that wants more than one module — +declare your own: ```csharp -// feature interface -public interface IFeature { } - -[DependencyModule] -public partial class ModuleImplementation : ISomeFeature -{ -} - [DependencyModule] -[ModuleImplementation] -public partial class FeatureHandlerModule : IDependencyModuleFeature -{ - public void HandleFeature(IServiceCollection collection, IEnumerable features) - { - // invoked with service collection and one instance of the ModuleImplementation class - } -} +public partial class ApplicationModule; ``` -## Managing duplicate registration +A module must be `partial`, and must be declared directly in a namespace rather than nested +inside another type. Services marked with `[SingletonService]` and friends may be nested freely. + +> **Coming from top-level statements?** The generated module takes your project's +> `RootNamespace`, and top-level statements sit in the global namespace — so `Program.cs` +> needs `using YourRootNamespace;` before it can name `ApplicationModule`. -By default a module will only be loaded once, assuming attributes are used or the modules are specified in the same `AddModules` call. Separate calls to `AddModule` will result in modules being loaded multiple times. If a module uses parameters it can be useful to load a module more than once. That can be accomplished by overriding the `Equals` and `GetHashcode` methods to allow for multiple loads. +## Registering forty things without writing forty attributes + +Declare the rule once. It is matched by the compiler, against the types that exist at build +time: ```csharp -// CustomModule will be loaded as long as someString is unique. -// Duplicate modules with the same someString value will be ignored [DependencyModule] -public partial class CustomModule(string someString) : IServiceCollectionConfiguration -{ - public void ConfigureServices(IServiceCollection services) - { - // custom logic - } - - // Re-exposed as a member: a primary constructor parameter is captured, not a property, so - // module.someString would not compile. - private string Key => someString; - - public override bool Equals(object? obj) - { - if (obj is CustomModule module) - { - return someString.Equals(module.Key); +public partial class HandlerModule : IConventionModule { + void IConventionModule.Conventions(IConventionDefinitions conventions) { + conventions.RegisterAll(typeof(IRequestHandler<,>)).AsScoped(); + + conventions.RegisterAll(typeof(IValidator<>)) + .IncludeBaseClasses() + .AlsoAsSelf() + .AsScoped(); } - - return false; - } - - public override int GetHashCode() - { - return someString.GetHashCode(); - } } ``` -Services will be registered using an `Add` method by default. This can be overridden with the `Using` property on individual service or at the `DependencyModule` level. Note: the following are valid registration types Add, Try, TryEnumerable, Replace. +Every handler in the project is registered against the closed interface it implements. Add a +handler tomorrow and it joins; delete one and the registration goes with it. A convention that +stops matching anything is a build warning rather than a runtime surprise. -```csharp -[SingletonService(Using = RegistrationType.Try)] -public class SomeService; +The body of `Conventions` is never executed — it is read from source at compile time, which is +why only the documented calls can appear in it. See the +[conventions guide](https://ipjohnson.github.io/DependencyModules/guide/conventions). -[DependencyModule(Using = RegistrationType.Try)] -public partial class SomeModule; -``` +## Composing modules -## Realm - -By default, all dependencies are registered in all modules within the same assembly. -The realm allows the developer to scope down the registration within a given module. +A module generates an attribute of the same name, so modules compose by attribute: ```csharp -// register only dependencies specifically marked for this realm -[DependencyModule(OnlyRealm = true)] -public partial class AnotherModule; - -[SingletonService(Realm = typeof(AnotherModule))] -public class SomeDep : ISomeInterface { } +[DependencyModule] +[DomainModule] +[InfrastructureModule(useInMemory: true, ConnectionName = "primary")] +public partial class ApiModule; ``` -## Keyed Registration +Constructor parameters and settable properties on a module are mirrored onto its generated +attribute, so a module can be configured by whoever composes it. For anything the attributes +cannot express, implement `IServiceCollectionConfiguration` and write the registrations by hand. + +## Decorators and interception -Registration attributes have a `Key` property that allows for specifying the key at registration time. +Wrap a service without touching it or its callers. The first constructor parameter is the +wrapped instance; the rest resolve normally: ```csharp -[SingletonService(Key = "SomeKey")] -public class KeyService : IKeyService { } +[Decorator(Order = 2000)] +public class CachingRepository(IRepository inner, IMemoryCache cache) : IRepository; + +[Decorator(Order = 1000)] +public class TracingRepository(IRepository inner, ILogger log) : IRepository; -// yields this registration line -services.AddKeyedSingleton(typeof(IKeyService), "SomeKey", typeof(KeyService)); +// resolves as CachingRepository(TracingRepository(SqlRepository)) ``` -## As Registration +Lower orders sit closer to the implementation. Ordering is global across every module in an +`AddModule(s)` call, so an application's decorators can wrap those a library contributed — +by convention framework code uses 0–999 and application code starts at 1000. -Sometimes it's useful to register a type with a specific type vs. letting auto-registration pick a type. -The `As` property allows you to control the service type for the registration. +For cross-cutting behaviour across every member of a service, `[Intercept]` generates a typed +wrapper rather than a dynamic proxy. See +[decorators and interception](https://ipjohnson.github.io/DependencyModules/guide/decorators). -```csharp -[SingletonService(As = typeof(ISomeOtherInterface))] -public class KeyService : IKeyService, ISomeOtherInterface { } +## Testing -// yields this registration line -services.AddSingleton(typeof(KeyService)); -``` +Tests receive their dependencies as method parameters, against the real registration graph: -## Try, Replace, TryEnumerable +```csharp +[assembly: ApplicationModule] +[assembly: NSubstituteSupport] -By default registrations are done using a standard `Add___` method. -It can be useful to change the registration to `Try`, `Replace`, and `TryEnumerable` with the `Using` property. +public class OrderTests { + [ModuleTest] + public async Task PlaceOrder_PricesThroughTheChannel( + IRequestHandler handler, + [Mock] IBookRepository books) { -```csharp -[SingletonService(Using = RegistrationType.Try)] -public class KeyService : IKeyService { } + books.Find("isbn-1", Arg.Any()) + .Returns(new Book("isbn-1", 20m)); -// yields this registration line -services.TryAddSingleton(typeof(IKeyService), typeof(KeyService)); + var order = await handler.Handle(new PlaceOrder("isbn-1", 10), default); + + Assert.Equal(140m, order.Total); + } +} ``` -## Autogenerated Modules +```shell +dotnet add package DependencyModules.xUnit # or DependencyModules.NUnit +dotnet add package DependencyModules.NSubstitute # or .Moq, or .FakeItEasy +``` -To simplify registration for [Top-Level](https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/program-structure/top-level-statements) statement applications, an `ApplicationModule` will be autogenerated for file named Program.cs. +Each test gets its own provider, so singletons cannot leak between them. See the +[testing guide](https://ipjohnson.github.io/DependencyModules/guide/testing). -```csharp -[assembly: SomeOtherModule] +## Native AOT -var serviceCollection = new ServiceCollection(); +Verified end to end: a console application using conventions, keyed registrations, decorators, +a static factory and an intercepted open generic publishes to a **2.2 MB** self-contained +binary with **zero IL trim or AOT warnings**, behaving identically to the JIT build. -// load SomeOtherModule as well as all registrations in the current project -serviceCollection.AddModule(); -``` +The one limitation is not this library's to fix: the container cannot close an open generic +over a value type without dynamic code, so `IRepository` resolves and `IRepository` +throws. Setting `PublishAot` makes that fail in an ordinary `dotnet run` rather than only after +publishing. See the [AOT guide](https://ipjohnson.github.io/DependencyModules/guide/aot). -## Unit testing & Mocking +## Feature reference -DependencyModules provides an xUnit extension to make testing much easier. -It handles the population and construction of a service provider using specified modules. +| | | +|---|---| +| `[SingletonService]` `[ScopedService]` `[TransientService]` | Register with the matching lifetime | +| `[CrossWireService]` | One instance shared across the implementation and its interfaces | +| `As = typeof(IFoo)` | Choose the service type explicitly | +| `Key = "primary"` | Keyed registration | +| `Using = RegistrationType.Try` | `Add`, `Try`, `TryEnumerable` or `Replace` | +| `Realm = typeof(SomeModule)` | Restrict a registration to one module | +| `[IfEnvironment("Development")]` | Register only in named environments | +| `[Decorator]` `[Decorate]` `[Intercept]` | Wrap a service, or one you do not own | +| A `static` method carrying a service attribute | Factory, for types the container cannot build | -```shell -dotnet add package DependencyModules.xUnit -dotnet add package DependencyModules.NSubstitute -``` +Full details for each, with the rules and the edge cases, are in the +[documentation](https://ipjohnson.github.io/DependencyModules/). -Mocking is supplied by a separate package, so use whichever library you already have — -`DependencyModules.NSubstitute`, `DependencyModules.Moq` or `DependencyModules.FakeItEasy` — and -apply its `[NSubstituteSupport]`, `[MoqSupport]` or `[FakeItEasySupport]` attribute. +## Samples -```csharp -// applies module & nsubstitute support to all tests. -// test attributes can be applied at the assembly, class, and test method level -[assembly: MyModule] -[assembly: NSubstituteSupport] +The [`integ-tests/`](https://github.com/ipjohnson/DependencyModules/tree/main/integ-tests) +directory is a working sample gallery, built and tested on every commit: -public class OtherServiceTests -{ - [ModuleTest] - public void SomeTest(OtherService test, [Mock]ISomeService service) - { - service.SomeProp.Returns("some mock value"); - Assert.Equals("some mock value", test.SomeProp); - } -} -``` +| Sample | Shows | +|---|---| +| [`SutProject`](https://github.com/ipjohnson/DependencyModules/tree/main/integ-tests/SutProject) | Every registration shape, in one project | +| [`SutProject.Tests`](https://github.com/ipjohnson/DependencyModules/tree/main/integ-tests/SutProject.Tests) | Conventions, realms, keyed services, cross-wiring, factories, features, and all three mocking libraries | +| [`ConsoleTestProject`](https://github.com/ipjohnson/DependencyModules/tree/main/integ-tests/ConsoleTestProject) | Top-level statements and the generated `ApplicationModule` | +| [`web/WebApiApp`](https://github.com/ipjohnson/DependencyModules/tree/main/integ-tests/web/WebApiApp) | An ASP.NET Core host, with its own test project | ## Reporting a problem -If services are not being registered as you expect, these three steps produce almost everything +If services are not registered as you expect, these three steps produce almost everything needed to diagnose it: -1. **Look at the generated code.** Set `true` - and read the files under `obj/`. The registrations the generator produced are the ground truth. +1. **Read the generated code.** Set `true` + and look under `obj/`. The registrations the generator produced are the ground truth. + (Point `CompilerGeneratedFilesOutputPath` inside `obj/` — a folder in the project directory + gets compiled as ordinary source on the next build.) 2. **Turn on the generator log**, which records the configuration in effect, every module and service discovered, and anything skipped along with the reason: ```xml @@ -315,104 +267,14 @@ needed to diagnose it: $(MSBuildProjectDirectory)/dmlogs ``` -3. **Check for `DM####` warnings** in the build output. The generator reports these for mistakes it - can detect, such as a service type that cannot be constructed or a module missing `partial`. - -Please include the log and the generated file in any [issue](https://github.com/ipjohnson/DependencyModules/issues). - -## Implementation +3. **Check for `DM####` warnings** in the build output. The generator reports these for mistakes + it can detect — see the + [diagnostics reference](https://ipjohnson.github.io/DependencyModules/reference/diagnostics). -Behind the scenes the library generates registration code that can be used with any `IServiceCollection` compatible DI container. +Please include the log and the generated file in any +[issue](https://github.com/ipjohnson/DependencyModules/issues). -Example generated code for [SutModule.cs](integ-tests/SutProject/SutModule.cs) -```csharp - // SutModule.Dependencies.g.cs - public partial class SutModule - { - [DynamicDependency(nameof(ModuleDependencies))] - private static int moduleField = global::DependencyModules.Runtime.Helpers.DependencyRegistry.Add(ModuleDependencies); - - private static void ModuleDependencies(global::Microsoft.Extensions.DependencyInjection.IServiceCollection services) - { - services.AddTransient( - typeof(global::SutProject.IDependencyOne), - typeof(global::SutProject.DependencyOne) - ); - services.AddSingleton( - typeof(global::SutProject.IGenericInterface<>), - typeof(global::SutProject.GenericClass<>) - ); - services.AddKeyedTransient( - typeof(global::SutProject.KeyedService), - Constants.StringValue, - typeof(global::SutProject.KeyedService) - ); - services.AddScoped( - typeof(global::SutProject.IScopedService), - typeof(global::SutProject.ScopedService) - ); - services.AddSingleton( - typeof(global::SutProject.ISingletonService), - typeof(global::SutProject.SingletonService) - ); - services.AddSingleton( - typeof(global::SutProject.IGenericInterface), - typeof(global::SutProject.StringGeneric) - ); - } - } +## License - // SutModule.Modules.g.cs -namespace SutProject -{ - #nullable enable - public partial class SutModule : global::DependencyModules.Runtime.Interfaces.IDependencyModule - { - - static SutModule() - { - } - - public void PopulateServiceCollection(global::Microsoft.Extensions.DependencyInjection.IServiceCollection services) - { - global::DependencyModules.Runtime.Helpers.DependencyRegistry.LoadModules(services, this); - } - - [Browsable(false)] - void global::DependencyModules.Runtime.Interfaces.IDependencyModule.InternalApplyServices(global::Microsoft.Extensions.DependencyInjection.IServiceCollection services) - { - global::DependencyModules.Runtime.Helpers.DependencyRegistry.ApplyServices(services); - } - - [Browsable(false)] - global::System.Collections.Generic.IEnumerable global::DependencyModules.Runtime.Interfaces.IDependencyModule.InternalGetModules() - { - return global::DependencyModules.Runtime.Helpers.DependencyRegistry.GetModules(); - } - - public override bool Equals(object? obj) - { - return obj is SutModule; - } - - public override int GetHashCode() - { - return HashCode.Combine(base.GetHashCode()); - } - } - #nullable disable - - [global::System.AttributeUsage(global::System.AttributeTargets.Class | global::System.AttributeTargets.Assembly | global::System.AttributeTargets.Method | global::System.AttributeTargets.Parameter, AllowMultiple = true)] - #nullable enable - public partial class SutModuleAttribute : global::System.Attribute, global::DependencyModules.Runtime.Interfaces.IDependencyModuleProvider - { - - public global::DependencyModules.Runtime.Interfaces.IDependencyModule GetModule() - { - var newModule = new global::SutProject.SutModule(); - return newModule; - } - } - #nullable disable -} -``` +MIT. See [LICENSE.txt](https://github.com/ipjohnson/DependencyModules/blob/main/LICENSE.txt) +and [CHANGELOG.md](https://github.com/ipjohnson/DependencyModules/blob/main/CHANGELOG.md). From 6edbc40edaae69e95cbb0a0d79707109627edcc9 Mon Sep 17 00:00:00 2001 From: Ian Johnson Date: Sun, 16 Aug 2026 06:36:26 -0400 Subject: [PATCH 5/7] Correct the docs where they showed code that does not compile Three fixes, all found by building the examples rather than reading them. [InjectValues] was introduced as being for "a string, an id, a record combining both", and the comparison table said "the parameter is data, not a service". A bare string is the one thing it cannot do: the values are the parameter type's constructor arguments, so asking for a string tries to construct System.String from a string and fails with "A suitable constructor for type 'System.String' could not be located". The prose below it was already right; only the framing promised something else. Data rows are what a parameter that simply is a value wants, and [InlineData] composing with [ModuleTest] is now shown, since nothing said so. The testing bootstrap and the top-level statements example both referenced a module without importing its namespace. A module generates its attribute in its own namespace and an assembly attribute has no namespace context, so the first fails on a type the reader never wrote; the generated ApplicationModule takes the project's RootNamespace while top-level statements sit in the global one, so the second fails on the module itself. Both now carry the using, and say why it is load-bearing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CMV4J1eVscS5EBMUhhWc2x --- website/guide/modules.md | 10 ++++++++++ website/guide/testing.md | 30 +++++++++++++++++++++++++++--- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/website/guide/modules.md b/website/guide/modules.md index 3451320..07d77b7 100644 --- a/website/guide/modules.md +++ b/website/guide/modules.md @@ -88,6 +88,9 @@ For applications using [top-level statements](https://learn.microsoft.com/en-us/ an `ApplicationModule` is generated for you from `Program.cs`: ```csharp +using MyApp; // the generated module takes your RootNamespace +using DependencyModules.Runtime; + [assembly: SomeOtherModule] // compose other modules at the assembly level var services = new ServiceCollection(); @@ -96,6 +99,13 @@ var services = new ServiceCollection(); services.AddModule(); ``` +::: warning The first `using` is not optional +The generated module takes the project's `RootNamespace`, and top-level statements sit in the +global namespace — so `Program.cs` cannot see `ApplicationModule` until it imports that namespace. +Leave it out and the build fails with `CS0246: The type or namespace name 'ApplicationModule' could +not be found`, which does not hint at the cause. +::: + This is why the ASP.NET sample in this repository never declares a module — the web project's `Program.cs` gets one automatically, and the test project composes it by name. diff --git a/website/guide/testing.md b/website/guide/testing.md index 5a192c2..33284ba 100644 --- a/website/guide/testing.md +++ b/website/guide/testing.md @@ -101,11 +101,18 @@ every test needs in one file at the assembly level: ```csharp // Bootstrap.cs using DependencyModules.NSubstitute; +using MyApp.Tests; // the namespace the module is declared in [assembly: ApplicationModule] [assembly: NSubstituteSupport] // or [MoqSupport] / [FakeItEasySupport] ``` +That second `using` is easy to miss. A module generates its attribute in the module's own +namespace, and an assembly-level attribute has no namespace context to inherit — so without it the +build fails with `CS0246: The type or namespace name 'ApplicationModuleAttribute' could not be +found`, naming a type you never wrote. Importing the namespace or writing the attribute qualified, +`[assembly: MyApp.Tests.ApplicationModule]`, both work. + Every test in the project now gets `ApplicationModule` without saying so: ```csharp @@ -210,8 +217,8 @@ whatever order the two are declared in — see [ordering](/guide/testing-mocking ## When the parameter is not a service at all -Sometimes a test parameter is data — a string, an id, a record combining both. `[InjectValues]` -supplies the parts the container cannot: +Sometimes a test parameter is a type the container cannot build on its own, because part of it is +data rather than a service. `[InjectValues]` supplies the parts the container cannot: ```csharp public record InjectModel(IDependencyOne DependencyOne, string StringValue); @@ -226,13 +233,30 @@ public void InjectTestValue([InjectValues("Hello World!")] InjectModel model) { The values are matched against the constructor parameters the container **cannot** supply, so you list only what it could not work out for itself. +They are the parameter type's *constructor arguments*, not the parameter's own value — so a +parameter that should simply **be** a value wants a data row instead. `[InlineData]` and NUnit's +`[TestCase]` both compose with `[ModuleTest]`, and the container fills whatever the row does not: + +```csharp +[ModuleTest] +[InlineData("978-0132350884")] +[InlineData("978-0201616224")] +public async Task GetBook_FindsEachIsbn(string isbn, IRequestHandler handler) { + // isbn came from the row, handler from the container +} +``` + +Asking for a bare `string` through `[InjectValues]` fails with *"A suitable constructor for type +'System.String' could not be located"*, because that is exactly what it tried to do. + ## Choosing between the three | | Reach for it when | |---|---| | [`[Mock]`](/guide/testing-mocking) | you want to assert on the interaction — what was called, with what | | `[TestExport]` | you want a real object with different behaviour, constructed by the container | -| `[InjectValues]` | the parameter is data, not a service | +| `[InjectValues]` | the parameter is a type the container cannot finish building, because part of it is data | +| `[InlineData]` / `[TestCase]` | the parameter simply **is** a value — one test per row | ## What is worth testing From 9fbb177419552c40d8f9622583c5f6aeda3d7aaf Mon Sep 17 00:00:00 2001 From: Ian Johnson Date: Sun, 16 Aug 2026 06:40:09 -0400 Subject: [PATCH 6/7] Record this round in the changelog and re-approve the public API snapshot The API snapshot moves because AttributeUsage is part of the public surface, which is the test doing its job on a deliberate change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CMV4J1eVscS5EBMUhhWc2x --- CHANGELOG.md | 69 +++++++++++++++++-- .../PublicApiTests.TestingApi.verified.txt | 1 + 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 742e695..9f877d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,20 +5,27 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.0.0-rc9330] - 2026-08-14 +## [1.0.0-rc9330] - 2026-08-16 Everything since `1.0.0-rc9230`. The theme is registrations that were silently not happening: an attribute the generator declined to recognise, a `Replace` that depended on how a class was named, a mock that replaced the wrong slot, and three shapes it refused without saying so. One of those refusals turned out to be unnecessary, and generic services can now be intercepted. +The other half came from building five applications against the released package and writing down +everything that got in the way — which turned up a generated file that could break a consumer's build +on an ordinary signature, an attribute that did nothing where it was written, and several documented +examples that did not compile. + Still a release candidate. `DecoratorExpansion.Expand` changed shape, but only on the generator extension points, which are documented as unversioned. -**Upgrade note:** three new warnings. A project building with `TreatWarningsAsErrors` may go red on -work that was previously green and quietly not doing anything — which is the point of them, but it is -a build break rather than a nudge. `NoWarn` takes them per-project; note that `.editorconfig` does not -(see below). +**Upgrade note:** three new warnings, and one thing that will stop compiling. A project building with +`TreatWarningsAsErrors` may go red on work that was previously green and quietly not doing anything — +which is the point of them, but it is a build break rather than a nudge. `NoWarn` takes them +per-project; note that `.editorconfig` does not (see below). Separately, `[InjectValues]` is now +restricted to parameters, so a usage anywhere else is `CS0592` where it used to compile and be +ignored. Both are cases where the build going red is the fix arriving, not a regression. ### Added @@ -143,8 +150,36 @@ a build break rather than a nudge. `NoWarn` takes them per-project; note that `. non-generic decorator named against an open generic service produced CS7003 and CS1503 the same way. Both are now `DM0014` and `DM0013`. +- **A nullable type argument in a service type broke the build, and the consumer could not fix it.** + A registered service type carries whatever nullable annotation its declaration used, so + `class GetBookHandler : IHandler` emits `typeof(…Book?)`. Roslyn requires generated + code to open a nullable context explicitly however the consuming project is configured, and the + registrations file was the one generated file that never did — the module, attribute and interceptor + writers all already called `EnableNullable`. + + The result was `CS8669` on a find-by-id handler, which is about as ordinary a shape as exists: a + warning nobody could silence from their own source without dropping the annotation from their own + domain signatures, and a hard failure under `TreatWarningsAsErrors`. It reproduced on the attribute + and the convention path alike, because the convention path emits through the same writer — which is + also why one call fixes both files. + + The annotation is still emitted. It is inert inside a `typeof`, since `typeof(Book?)` and + `typeof(Book)` are one runtime type, and removing it would mean changing type modelling that + decoration and interception depend on: `ConstructorArgumentWriter` reads nullability to choose + `GetService` over `GetRequiredService`, and an interceptor wrapper needs the annotations to keep + implementing what it wraps. A decorator declared against `IStore` still matches a + registration of `IStore`, so the difference is cosmetic rather than a silent miss. + ### Changed +- **Breaking, and deliberately so: `[InjectValues]` is restricted to parameters.** It was the only + one of the three testing attributes without an `AttributeUsage` — `[Mock]` is pinned to parameters + and `[TestExport]` to methods — so writing it on a test method compiled, was never read, and then + failed inside `ActivatorUtilities` with *"Multiple constructors accepting all given argument types + have been found in type 'System.String'"*, naming neither the parameter nor the mistake. It is now + `CS0592` at the attribute itself. Code that was silently doing nothing will stop compiling, which + is the point. + - **`CSharpAuthor` 1.1.1010**, for `AddConstraint`. A `where` clause was previously assembled as a string and assigned to `WhereStatement`, which put C#'s ordering rules — one primary constraint first, `new()` last — in this generator. Two places needed them once a class could carry constraints @@ -192,6 +227,30 @@ a build break rather than a nudge. `NoWarn` takes them per-project; note that `. `DecoratorHelper`; that guard is unreachable from generated code, because the expansion drops the decorator before anything is emitted, so the guide now points at `DM0013`. +- **The README leads with the problem rather than the mechanism.** It opened by naming the + implementation, which says what the thing is before the reader knows why they want one, and the + most persuasive artefact in it — a generated registration that is plainly ordinary C# — sat at the + bottom below four hundred lines of reference. It now opens with the hook, a link to the + documentation site, the attribute beside the code it generates, and a table answering the question + every reader of a .NET DI library arrives with, which is why not Scrutor. The reference the site + covers in depth is a lookup table that links out, and the samples in `integ-tests/` are pointed at + rather than left to be discovered. + +- **Three documented examples did not compile, all found by building them.** The README quick start + omitted `using DependencyModules.Runtime.Attributes;`. The README and the modules guide both showed + top-level statements naming `ApplicationModule` without importing the root namespace it is generated + into — `integ-tests/ConsoleTestProject` has always carried that `using`; the docs omitted it and + sent the reader into a `CS0246` naming a type they never wrote. The testing bootstrap referenced a + module without importing its namespace, which fails the same way on the generated attribute. + +- **`[InjectValues]` was documented as being for the one thing it cannot do.** It was introduced as + taking "a string, an id, a record combining both", and the comparison table said "the parameter is + data, not a service". The values are the parameter type's *constructor arguments*, so asking for a + bare `string` tries to construct `System.String` from a string and fails. The prose beneath it was + already correct; only the framing promised otherwise. Data rows are what a parameter that simply + *is* a value wants, and `[InlineData]` composing with `[ModuleTest]` is now shown, since nothing + said so. + - **Breaking, for anyone building on the generator extension points:** `DecoratorExpansion.Expand` takes an additional `out IReadOnlyList` parameter, carrying the decorators it refused so the caller can report them. These are the extension points diff --git a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.TestingApi.verified.txt b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.TestingApi.verified.txt index 9028398..24253f7 100644 --- a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.TestingApi.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.TestingApi.verified.txt @@ -1,5 +1,6 @@ namespace DependencyModules.Testing.Attributes { + [System.AttributeUsage(System.AttributeTargets.Parameter)] public class InjectValuesAttribute : System.Attribute, DependencyModules.Testing.Attributes.Interfaces.IInjectValueAttribute { public InjectValuesAttribute(params object[] value) { } From e618ed43db37c47b940cd0504a7d18aca8b8cf6c Mon Sep 17 00:00:00 2001 From: Ian Johnson Date: Sun, 16 Aug 2026 06:50:10 -0400 Subject: [PATCH 7/7] Report DM0016 for a module attribute whose namespace is not imported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module generates its attribute in the module's own namespace, and an assembly-level attribute has no namespace context to inherit — a `using` written inside a namespace declaration cannot reach it, because assembly attributes precede every namespace in the file. So `[assembly: ApplicationModule]` without the import fails with CS0246 naming `ApplicationModuleAttribute`: a type the developer never wrote, generated into a namespace the error does not mention, by a generator whose output they have probably never opened. Every part of that message points away from the fix, which is one line. Alone among these diagnostics it is read from syntax rather than the semantic model, and has to be: the attribute is written by the generator that is running, so it does not exist in the compilation being examined and nothing about it resolves. Every assembly-level module attribute looks unresolved from here, which is why the question is "is there a module by this name, and could this file see it" rather than "did it bind". That makes the false positives the part worth testing, and they are what most of the tests cover. It stays quiet for an attribute matching no module in the compilation, a module in the global namespace with nothing to import, a usage already written qualified, and a namespace supplied by a `global using` in any file — the last being why the check reads every compilation unit rather than only the one the attribute sits in. A `using` alias is deliberately not accepted, since it imports one name rather than a namespace and does not bring the attribute into scope under the name written. Registered from SourceGenerator rather than the base class, so a framework generator loaded alongside this one does not report the same usage twice. Confirmed against the trial project the papercut was found in: it reports on the right line, names both fixes, and disappears when either is applied. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CMV4J1eVscS5EBMUhhWc2x --- CHANGELOG.md | 19 +- Directory.Build.props | 2 +- .../AnalyzerReleases.Unshipped.md | 1 + .../DependencyModuleDiagnostics.cs | 29 +++ .../AssemblyModuleAttributeDiagnostics.cs | 193 ++++++++++++++++++ .../SourceGenerator.cs | 6 + ...AssemblyModuleAttributeDiagnosticsTests.cs | 124 +++++++++++ ...icApiTests.SourceGeneratorApi.verified.txt | 1 + website/reference/diagnostics.md | 34 +++ 9 files changed, 406 insertions(+), 3 deletions(-) create mode 100644 src/DependencyModules.SourceGenerator/AssemblyModuleAttributeDiagnostics.cs create mode 100644 tests/DependencyModules.Tests/GeneratorTests/AssemblyModuleAttributeDiagnosticsTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f877d1..b315afe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.0.0-rc9330] - 2026-08-16 +## [1.0.0-rc9340] - 2026-08-16 Everything since `1.0.0-rc9230`. The theme is registrations that were silently not happening: an attribute the generator declined to recognise, a `Replace` that depended on how a class was named, a @@ -20,7 +20,7 @@ examples that did not compile. Still a release candidate. `DecoratorExpansion.Expand` changed shape, but only on the generator extension points, which are documented as unversioned. -**Upgrade note:** three new warnings, and one thing that will stop compiling. A project building with +**Upgrade note:** four new warnings, and one thing that will stop compiling. A project building with `TreatWarningsAsErrors` may go red on work that was previously green and quietly not doing anything — which is the point of them, but it is a build break rather than a nudge. `NoWarn` takes them per-project; note that `.editorconfig` does not (see below). Separately, `[InjectValues]` is now @@ -95,6 +95,21 @@ ignored. Both are cases where the build going red is the fix arriving, not a reg `Unable to create a generic service … because 'System.Int32' is a ValueType`. A plain `[SingletonService]` on a generic class behaves identically, which the AOT guide now says. +- **`DM0016`, for an assembly-level module attribute whose namespace nothing imports.** A module + generates its attribute in the module's own namespace, and an assembly attribute has no namespace + context to inherit — a `using` inside a namespace declaration cannot reach it, because assembly + attributes precede every namespace in the file. So `[assembly: ApplicationModule]` without the + import fails with `CS0246` naming `ApplicationModuleAttribute`: a type the developer never wrote, + generated into a namespace the error does not mention. Every part of that message points away from + the one-line fix, and both the testing guide and this README used to show the shape without it. + + Alone among these it is read from syntax rather than from the semantic model, and has to be — the + attribute is written by the generator that is running, so it does not exist in the compilation + being examined and nothing about it resolves. The question it can answer is "is there a module by + this name, and could this file see it", which is why it stays quiet for an attribute matching no + module in the compilation, a module in the global namespace, a usage already written qualified, and + a namespace a `global using` supplies from any file. + ### Fixed - **An attribute the generator declined to recognise, depending on how it was spelled.** Attribute diff --git a/Directory.Build.props b/Directory.Build.props index 98a1848..1210bb8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -11,7 +11,7 @@ The release candidate label. Assembly and file versions carry no prerelease part, so they stay put until the version they do carry changes. --> - rc9330 + rc9340 1.0.0.0 1.0.0.0 diff --git a/src/DependencyModules.SourceGenerator.Impl/AnalyzerReleases.Unshipped.md b/src/DependencyModules.SourceGenerator.Impl/AnalyzerReleases.Unshipped.md index e62eb69..74d2e29 100644 --- a/src/DependencyModules.SourceGenerator.Impl/AnalyzerReleases.Unshipped.md +++ b/src/DependencyModules.SourceGenerator.Impl/AnalyzerReleases.Unshipped.md @@ -20,3 +20,4 @@ DM0012 | DependencyModules | Warning | An environment condition names nothing to DM0013 | DependencyModules | Warning | A service registered as an open generic cannot be decorated. DM0014 | DependencyModules | Warning | A generic type cannot be cross-wired. DM0015 | DependencyModules | Warning | An interceptor does not apply to every member it was applied to. +DM0016 | DependencyModules | Warning | An assembly-level module attribute's namespace is not imported. diff --git a/src/DependencyModules.SourceGenerator.Impl/DependencyModuleDiagnostics.cs b/src/DependencyModules.SourceGenerator.Impl/DependencyModuleDiagnostics.cs index a003b2b..5505421 100644 --- a/src/DependencyModules.SourceGenerator.Impl/DependencyModuleDiagnostics.cs +++ b/src/DependencyModules.SourceGenerator.Impl/DependencyModuleDiagnostics.cs @@ -309,4 +309,33 @@ public static class DependencyModuleDiagnostics { defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true); + /// + /// Raised for an assembly-level module attribute whose namespace the file does not import. + /// + /// + /// A module generates its attribute in the module's own namespace, and an assembly-level + /// attribute has no namespace context to inherit — a using inside a namespace declaration + /// cannot apply to it, because assembly attributes precede every namespace in the file. + /// + /// So [assembly: ApplicationModule] in a file that does not import the module's namespace + /// fails with CS0246 naming ApplicationModuleAttribute — a type the developer never + /// wrote, generated into a namespace the error does not mention, by a generator whose output they + /// have probably never looked at. Every part of that error points away from the fix. + /// + /// This cannot be decided by asking the compiler, because the attribute does not exist yet while + /// the generator that writes it is running. It is read from syntax instead: an assembly attribute + /// whose name matches a module this compilation declares, written unqualified, in a file that + /// imports neither that namespace nor anything global using supplies. + /// + public static readonly DiagnosticDescriptor ModuleAttributeNamespaceNotImported = new( + id: "DM0016", + title: "Assembly-level module attribute needs its namespace imported", + messageFormat: + "'{0}' is declared in '{1}', and an assembly-level attribute has no namespace context, so " + + "this does not compile. Add 'using {1};' to this file, or write it qualified as " + + "'[assembly: {1}.{0}]'.", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + } diff --git a/src/DependencyModules.SourceGenerator/AssemblyModuleAttributeDiagnostics.cs b/src/DependencyModules.SourceGenerator/AssemblyModuleAttributeDiagnostics.cs new file mode 100644 index 0000000..6b0c742 --- /dev/null +++ b/src/DependencyModules.SourceGenerator/AssemblyModuleAttributeDiagnostics.cs @@ -0,0 +1,193 @@ +using System.Collections.Immutable; +using DependencyModules.SourceGenerator.Impl; +using DependencyModules.SourceGenerator.Impl.Models; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace DependencyModules.SourceGenerator; + +/// +/// Reports — DM0016. +/// +/// +/// +/// Read from syntax rather than from the semantic model, which is not optional here: the attribute +/// this checks for is generated by the very generator that is running, so it does not exist in the +/// compilation being examined and nothing about it can be resolved. Every assembly-level module +/// attribute looks unresolved from here, which is why the check is "is there a module by that name, +/// and could this file see it" rather than "did it bind". +/// +/// +/// It stays quiet unless it can name the fix. An attribute that matches no module in this +/// compilation is somebody else's; a module in the global namespace needs no import; a qualified +/// usage already says where to look. +/// +/// +internal static class AssemblyModuleAttributeDiagnostics { + + /// + /// One compilation unit's assembly attributes and the namespaces in scope for them. + /// + internal sealed class UnitModel : IEquatable { + + public UnitModel( + ImmutableArray usages, + ImmutableArray fileUsings, + ImmutableArray globalUsings) { + Usages = usages; + FileUsings = fileUsings; + GlobalUsings = globalUsings; + } + + public ImmutableArray Usages { get; } + + /// Namespaces this file imports, which apply only to this file. + public ImmutableArray FileUsings { get; } + + /// Namespaces this file imports for the whole compilation. + public ImmutableArray GlobalUsings { get; } + + public bool Equals(UnitModel? other) => + other != null && + Usages.SequenceEqual(other.Usages) && + FileUsings.SequenceEqual(other.FileUsings) && + GlobalUsings.SequenceEqual(other.GlobalUsings); + + public override bool Equals(object? obj) => Equals(obj as UnitModel); + + public override int GetHashCode() => Usages.Length * 397 ^ FileUsings.Length; + } + + internal sealed class Usage : IEquatable { + + public Usage(string name, Location location) { + Name = name; + Location = location; + } + + /// The attribute name exactly as written, without any qualification. + public string Name { get; } + + public Location Location { get; } + + public bool Equals(Usage? other) => + other != null && Name == other.Name && Location.Equals(other.Location); + + public override bool Equals(object? obj) => Equals(obj as Usage); + + public override int GetHashCode() => Name.GetHashCode(); + } + + internal static IncrementalValueProvider> Collect( + IncrementalGeneratorInitializationContext context) => + context.SyntaxProvider.CreateSyntaxProvider( + static (node, _) => node is CompilationUnitSyntax, + static (syntaxContext, cancellation) => Read(syntaxContext, cancellation)) + .Where(static model => !model.Usages.IsEmpty || !model.GlobalUsings.IsEmpty) + .Collect(); + + private static UnitModel Read(GeneratorSyntaxContext context, CancellationToken cancellation) { + cancellation.ThrowIfCancellationRequested(); + + var unit = (CompilationUnitSyntax)context.Node; + + var usages = ImmutableArray.CreateBuilder(); + var fileUsings = ImmutableArray.CreateBuilder(); + var globalUsings = ImmutableArray.CreateBuilder(); + + foreach (var usingDirective in unit.Usings) { + // An alias imports one name rather than a namespace, so it cannot bring a module + // attribute into scope under the name this checks for. + if (usingDirective.Alias != null || usingDirective.Name == null) { + continue; + } + + var target = usingDirective.GlobalKeyword.IsKind(SyntaxKind.GlobalKeyword) + ? globalUsings + : fileUsings; + + target.Add(usingDirective.Name.ToString()); + } + + foreach (var attributeList in unit.AttributeLists) { + if (!attributeList.Target?.Identifier.IsKind(SyntaxKind.AssemblyKeyword) ?? true) { + continue; + } + + foreach (var attribute in attributeList.Attributes) { + // A qualified usage already names where the attribute lives. + if (attribute.Name is not SimpleNameSyntax simpleName) { + continue; + } + + usages.Add(new Usage(simpleName.Identifier.Text, attribute.GetLocation())); + } + } + + return new UnitModel(usages.ToImmutable(), fileUsings.ToImmutable(), globalUsings.ToImmutable()); + } + + internal static void Report( + SourceProductionContext context, + (ImmutableArray<(ModuleEntryPointModel Left, DependencyModuleConfigurationModel Right)> Modules, + ImmutableArray Units) input) { + + if (input.Units.IsDefaultOrEmpty || input.Modules.IsDefaultOrEmpty) { + return; + } + + // A module in the global namespace needs no import, and an auto-generated ApplicationModule + // is never written by hand at the assembly level, so neither can produce this mistake. + var modulesByName = new Dictionary(StringComparer.Ordinal); + + foreach (var (module, _) in input.Modules) { + var moduleNamespace = module.EntryPointType.Namespace; + + if (string.IsNullOrEmpty(moduleNamespace) || + module.ModuleFeatures.HasFlag(ModuleEntryPointFeatures.AutoGenerateModule)) { + continue; + } + + modulesByName[module.EntryPointType.Name] = moduleNamespace; + } + + if (modulesByName.Count == 0) { + return; + } + + var globalUsings = new HashSet(StringComparer.Ordinal); + + foreach (var unit in input.Units) { + foreach (var globalUsing in unit.GlobalUsings) { + globalUsings.Add(globalUsing); + } + } + + foreach (var unit in input.Units) { + foreach (var usage in unit.Usages) { + context.CancellationToken.ThrowIfCancellationRequested(); + + // Written as [assembly: Foo] or [assembly: FooAttribute]; both name module Foo. + if (!modulesByName.TryGetValue(usage.Name, out var moduleNamespace) && + !(usage.Name.EndsWith("Attribute", StringComparison.Ordinal) && + modulesByName.TryGetValue( + usage.Name.Substring(0, usage.Name.Length - "Attribute".Length), + out moduleNamespace))) { + continue; + } + + if (unit.FileUsings.Contains(moduleNamespace) || globalUsings.Contains(moduleNamespace)) { + continue; + } + + context.ReportDiagnostic( + Diagnostic.Create( + DependencyModuleDiagnostics.ModuleAttributeNamespaceNotImported, + usage.Location, + usage.Name, + moduleNamespace)); + } + } + } +} diff --git a/src/DependencyModules.SourceGenerator/SourceGenerator.cs b/src/DependencyModules.SourceGenerator/SourceGenerator.cs index 4f146e8..eba2b1f 100644 --- a/src/DependencyModules.SourceGenerator/SourceGenerator.cs +++ b/src/DependencyModules.SourceGenerator/SourceGenerator.cs @@ -27,5 +27,11 @@ protected override void SetupRootGenerator(IncrementalGeneratorInitializationCon IncrementalValueProvider> valuesProvider) { context.RegisterSourceOutput(valuesProvider, new DependencyModuleWriter(true).GenerateSource); + + // DM0016. Registered here rather than on the base class so that a framework generator loaded + // alongside this one does not report the same usage twice. + context.RegisterSourceOutput( + valuesProvider.Combine(AssemblyModuleAttributeDiagnostics.Collect(context)), + AssemblyModuleAttributeDiagnostics.Report); } } \ No newline at end of file diff --git a/tests/DependencyModules.Tests/GeneratorTests/AssemblyModuleAttributeDiagnosticsTests.cs b/tests/DependencyModules.Tests/GeneratorTests/AssemblyModuleAttributeDiagnosticsTests.cs new file mode 100644 index 0000000..90a548a --- /dev/null +++ b/tests/DependencyModules.Tests/GeneratorTests/AssemblyModuleAttributeDiagnosticsTests.cs @@ -0,0 +1,124 @@ +using DependencyModules.Tests.Infrastructure; +using Xunit; + +namespace DependencyModules.Tests.GeneratorTests; + +/// +/// DM0016. A module generates its attribute in the module's own namespace, and an assembly-level +/// attribute has no namespace context to inherit, so [assembly: ApplicationModule] without a +/// using fails with CS0246 naming a type the developer never wrote. +/// +/// The check is syntactic and cannot be otherwise: the attribute is written by the generator that is +/// running, so it does not exist in the compilation being examined and nothing about it resolves. +/// That makes the false positives the interesting cases, and most of these tests are one. +/// +public class AssemblyModuleAttributeDiagnosticsTests { + + private const string ModuleInNamespace = + """ + namespace MyApp.Composition; + + [DependencyModules.Runtime.Attributes.DependencyModule] + public partial class ApplicationModule; + """; + + [Fact] + public void MissingUsing_IsReported() { + var result = Run("[assembly: ApplicationModule]"); + + var diagnostic = Assert.Single(result.GeneratorDiagnostics, d => d.Id == "DM0016"); + + Assert.Contains("MyApp.Composition", diagnostic.GetMessage()); + Assert.Contains("using MyApp.Composition;", diagnostic.GetMessage()); + } + + /// The suffixed spelling names the same module. + [Fact] + public void MissingUsing_IsReported_ForTheAttributeSuffixedSpelling() { + var result = Run("[assembly: ApplicationModuleAttribute]"); + + Assert.Single(result.GeneratorDiagnostics, d => d.Id == "DM0016"); + } + + [Fact] + public void TheUsingBeingPresent_IsSilent() { + var result = Run( + """ + using MyApp.Composition; + + [assembly: ApplicationModule] + """); + + Assert.DoesNotContain(result.GeneratorDiagnostics, d => d.Id == "DM0016"); + } + + /// + /// A global using in any file supplies the namespace everywhere, so reading only the file the + /// attribute sits in would report a build that is already correct. + /// + [Fact] + public void AGlobalUsingInAnotherFile_IsSilent() { + var result = GeneratorTestHarness.Run( + new Dictionary { + ["Module.cs"] = ModuleInNamespace, + ["GlobalUsings.cs"] = "global using MyApp.Composition;", + ["Bootstrap.cs"] = "[assembly: ApplicationModule]" + }); + + Assert.DoesNotContain(result.GeneratorDiagnostics, d => d.Id == "DM0016"); + } + + [Fact] + public void AQualifiedUsage_IsSilent() { + var result = Run("[assembly: MyApp.Composition.ApplicationModule]"); + + Assert.DoesNotContain(result.GeneratorDiagnostics, d => d.Id == "DM0016"); + } + + /// An attribute this compilation declares no module for belongs to somebody else. + [Fact] + public void AnUnrelatedAssemblyAttribute_IsSilent() { + var result = Run("[assembly: System.Reflection.AssemblyMetadata(\"key\", \"value\")]"); + + Assert.DoesNotContain(result.GeneratorDiagnostics, d => d.Id == "DM0016"); + } + + /// A module in the global namespace has no namespace to import. + [Fact] + public void AModuleInTheGlobalNamespace_IsSilent() { + var result = GeneratorTestHarness.Run( + new Dictionary { + ["Module.cs"] = + """ + [DependencyModules.Runtime.Attributes.DependencyModule] + public partial class ApplicationModule; + """, + ["Bootstrap.cs"] = "[assembly: ApplicationModule]" + }); + + Assert.DoesNotContain(result.GeneratorDiagnostics, d => d.Id == "DM0016"); + } + + /// + /// A using alias imports one name rather than a namespace, so it does not bring the attribute + /// into scope under the name written here and the report still stands. + /// + [Fact] + public void AUsingAlias_DoesNotCountAsTheImport() { + var result = Run( + """ + using Composition = MyApp.Composition; + + [assembly: ApplicationModule] + """); + + Assert.Single(result.GeneratorDiagnostics, d => d.Id == "DM0016"); + } + + private static GeneratorResult Run(string bootstrap) => + GeneratorTestHarness.Run( + new Dictionary { + ["Module.cs"] = ModuleInNamespace, + ["Bootstrap.cs"] = bootstrap + }); +} diff --git a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.SourceGeneratorApi.verified.txt b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.SourceGeneratorApi.verified.txt index 5052420..ccfaa07 100644 --- a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.SourceGeneratorApi.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.SourceGeneratorApi.verified.txt @@ -1090,6 +1090,7 @@ namespace DependencyModules.SourceGenerator.Impl public static readonly Microsoft.CodeAnalysis.DiagnosticDescriptor ExposedByConvention; public static readonly Microsoft.CodeAnalysis.DiagnosticDescriptor GeneratorFailure; public static readonly Microsoft.CodeAnalysis.DiagnosticDescriptor InterceptorCannotServeMembers; + public static readonly Microsoft.CodeAnalysis.DiagnosticDescriptor ModuleAttributeNamespaceNotImported; public static readonly Microsoft.CodeAnalysis.DiagnosticDescriptor ModuleMustBePartial; public static readonly Microsoft.CodeAnalysis.DiagnosticDescriptor OpenGenericCannotBeDecorated; public static readonly Microsoft.CodeAnalysis.DiagnosticDescriptor RegisteredConditionally; diff --git a/website/reference/diagnostics.md b/website/reference/diagnostics.md index 10c056a..77668c9 100644 --- a/website/reference/diagnostics.md +++ b/website/reference/diagnostics.md @@ -38,6 +38,7 @@ means they appear in the IDE and never in `dotnet build` at any verbosity. The r | [DM0013](#dm0013) | Warning | A service registered as an open generic cannot be decorated | | [DM0014](#dm0014) | Warning | A generic type cannot be cross-wired | | [DM0015](#dm0015) | Warning | An interceptor does not apply to every member | +| [DM0016](#dm0016) | Warning | An assembly-level module attribute's namespace is not imported | ## DM0001 {#dm0001} @@ -214,3 +215,36 @@ Implement the missing interface on the interceptor, or apply it to a service wit Reported once per interceptor and member shape, so a wide interface produces one line rather than one per member. See [Interception](/guide/interception). + +## DM0016 {#dm0016} + +**An assembly-level module attribute's namespace is not imported.** + +A module generates its attribute in the module's own namespace, and an assembly-level attribute has +no namespace context to inherit — a `using` written inside a namespace declaration cannot apply to +it, because assembly attributes precede every namespace in the file. + +```csharp +// Bootstrap.cs +using DependencyModules.NSubstitute; + +[assembly: ApplicationModule] // DM0016 — nothing brings MyApp.Composition into scope +[assembly: NSubstituteSupport] +``` + +Left alone this is `CS0246: The type or namespace name 'ApplicationModuleAttribute' could not be +found` — a type you never wrote, generated into a namespace the error does not name. Every part of +that message points away from the fix, which is one line: + +```csharp +using MyApp.Composition; // or write it as [assembly: MyApp.Composition.ApplicationModule] +``` + +Unlike the other diagnostics here this one is read from syntax rather than from the compiler's view +of your code, and it has to be: the attribute is written by the generator that is running, so it does +not exist in the compilation being examined and nothing about it can be resolved. The check is +therefore "is there a module by this name, and could this file see it" — which is why it stays quiet +for an attribute matching no module in the compilation, a module in the global namespace, a usage +already written qualified, and a namespace supplied by a `global using` in any file. + +See [Testing](/guide/testing#stop-repeating-the-module-list) and [Modules](/guide/modules).