Repository navigation
Feat/generator referenced assemblies - #4
Merged
Merged
Conversation
The generator only ever saw handlers declared in the compilation it was generating for. That is a property of the syntax provider: a handler in another project has no syntax tree in this compilation, so it is structurally invisible. In the usual Clean Architecture layout the handlers live in an Application project and the generator runs in the Web or API project, so every handler was invisible and AddGeneratedRoutya registered nothing at all. This was the single largest barrier to adopting the source generator in a multiple project solution. Handlers are now also read from the metadata of referenced assemblies, and fed through the same descriptor and emission path as local ones, so nested types, records, streaming, synchronous handlers and the accessibility rules all behave identically wherever the handler lives. Only assemblies that themselves reference Routya.Core are walked. That check is on assembly identity and happens before any type is enumerated, so the whole base class library is skipped without inspection and the cost stays proportional to the number of projects actually using Routya. Handlers that are not public in their own assembly are skipped with the new ROUTYA006 warning, naming the handler and the assembly. Generated registration lives in the consuming assembly, so an internal type elsewhere cannot be referenced by it. Reporting rather than skipping silently is the same choice made for ROUTYA005, and avoids recreating the silent-skip failure mode that was the most expensive defect to find in the 4.0.0 work. Also removes an early return that bailed out when the compilation contained no handlers of its own. A thin API project holding no handlers but referencing a project that does is precisely the case this change exists to support, and the guard would have defeated it. Adds six harness tests using genuinely separate compiled assemblies rather than one source string, since a single compilation cannot reproduce the problem. Three failed before this change. Verified end to end with a real two project build: an API project declaring no handlers dispatched both a request and a notification to handlers in a referenced assembly.
Reported from a real migration from Routya 1 to 4 with the source generator: handlers became Transient with no way to configure it. The generator hardcoded ServiceLifetime.Transient, under a comment claiming it would "look for lifetime attributes or conventions", which was never implemented. Worse than simply being fixed, the two dispatch paths disagreed: AddRoutya handlers default to Scoped AddGeneratedRoutya handlers were always Transient So moving a project from runtime dispatch to the generator silently changed every handler's lifetime, and nothing said so anywhere. AddGeneratedRoutya now takes an optional ServiceLifetime, and a new [RoutyaHandler(ServiceLifetime)] attribute overrides it for a single handler. Handlers without the attribute are emitted as ServiceDescriptor registrations carrying the parameter, so the choice is made at registration rather than fixed when the code is generated. Handlers with it keep a fixed registration that ignores the parameter. The default stays Transient. Changing it to match the runtime path would silently alter handler lifetimes for every existing 4.0.0 user on upgrade, which is exactly the failure being fixed here, so aligning the defaults is a 5.0.0 discussion rather than something to slip into a minor release. The difference is now documented in three places a reader might look: a README section contrasting both paths, the attribute's own documentation, and the generated AddGeneratedRoutya method's parameter comment. Adds six generator tests covering the parameter, the attribute, the two together, notification handlers, and an attributed handler in a referenced assembly. Adds six runtime tests in Routya.SourceGen.Test proving the lifetime actually takes effect rather than merely appearing in the generated text: Transient gives a new handler per resolution, Scoped one per scope and a new one across scopes, Singleton one for the application, and the attribute beats the parameter.
The previous commit documented that AddRoutya defaults to Scoped while AddGeneratedRoutya defaults to Transient, and treated that as an inconsistency to live with. Testing it showed the difference is structural rather than accidental, and the documentation now says why. Measured: Transient handler, resolved from the root provider works Scoped handler, resolved from the root provider InvalidOperationException Scoped handler, resolved from a scope works The cause is that the runtime dispatcher creates a DI scope per dispatch and the generated one does not. IRoutya can therefore resolve a Scoped handler wherever it was itself resolved from, because it makes its own scope. IGeneratedRoutya uses whatever provider it was given, so a Scoped handler only works when the consumer resolved IGeneratedRoutya inside a scope. Transient is therefore the correct default for the generated path, not a compatibility compromise: it is the only lifetime that works regardless of where the consumer resolves from. Aligning the defaults to Scoped, which was under consideration, would break dispatching from a singleton service, a hosted service that does not create its own scope, or a console application, and would do so at runtime rather than at build. The README now explains this, notes that ASP.NET Core resolves from the request scope so Scoped is safe there, and shows how to create a scope when dispatching outside a request. Adds three tests recording the constraints, including that a [RoutyaHandler(Singleton)] handler cannot depend on a Scoped service. That one is a captive dependency caught by scope validation in Development but not necessarily in Production, which is why the attribute documentation warns about it rather than relying on the container to catch it.
… sample
Neither feature on this branch had a sample. Both are now shown in
Routya.WebApi.SourceGen.Demo, and they belong together: a separate handlers
project is exactly the situation where handler lifetime has to be decided.
Adds Routya.WebApi.SourceGen.Demo.Application, a class library holding
handlers, which the Web project references. That library deliberately does
NOT reference Routya.SourceGenerators. Only the consuming project runs the
generator; these handlers are found by reading the library's metadata, which
is the whole point of the change and is stated in the project file so a
reader does not have to infer it.
The Web project now calls AddGeneratedRoutya(ServiceLifetime.Scoped) with a
comment explaining why Scoped is safe in ASP.NET Core, where endpoints
resolve from the request scope, and why the default is Transient for code
dispatching outside a request.
Three endpoints, chosen so the behaviour is observable rather than asserted:
GET /customers/{id} handler in the referenced assembly, returns
its own instance id
GET /status handler pinned Singleton by attribute,
overriding the Scoped above
POST /customers/{id}/registered notification handled in the referenced
assembly
Verified by running it:
/customers/1 instanceId 9aa237b0 then 22d7018b Scoped, new per request
/status instance 394fd660 both times Singleton, never changes
POST HTTP 202, and the Application assembly handler logged
So the referenced assembly discovery, the lifetime parameter and the
attribute override are all demonstrated working in a real ASP.NET Core
application rather than only in tests.
Reported from a consumer: the generated dispatcher carried only the
// <auto-generated/> header and no attributes, so coverlet's attribute based
exclusion did not recognise it.
The header is not the same thing. Roslyn reads it to skip analyzers on the
file, but tooling outside the compiler does not, and coverage tools key off
attributes instead. The result was that every consumer's coverage figure was
dragged down by generated dispatch code they never wrote and cannot
meaningfully test.
All three generated types are now attributed:
GeneratedRoutya GeneratedCode + ExcludeFromCodeCoverage
RoutyaGeneratedExtensions GeneratedCode + ExcludeFromCodeCoverage
IGeneratedRoutya GeneratedCode only, an interface has no code to
exclude from coverage
GeneratedCode names the tool and carries the generator's assembly version, so
a consumer can see which version produced the file. Both attributes are fully
qualified, consistent with the rest of the emitted code, so a consumer
declaring their own GeneratedCodeAttribute cannot break the build. There is a
test for exactly that.
Checked the rest of what the generator contributes to a consuming assembly:
it adds two files and three public types, and all three are now covered.
Nothing else is emitted.
The helper appends to the StringBuilder rather than composing lines itself,
because Environment.NewLine is banned inside analyzers by RS1035.
…ntainer Routya.Generated is a fixed, public namespace, so two assemblies in one reference chain cannot both define IGeneratedRoutya. The generator emitted into every project that referenced it and declared a handler, so in a layered solution the Application, API and test projects each emitted a copy and the build filled with CS0436 type conflicts. The compiler resolves such a conflict in favour of the source declaration, so a caller could silently bind to a dispatcher other than the one the application registers at startup. Generation is now gated on the compilation calling AddGeneratedRoutya. Detection is purely syntactic, which is what makes it self consistent: the method being called is itself generated, so it cannot be bound before the decision to generate has been taken. Projects that already call it need no change. The RoutyaGenerateDispatcher MSBuild property overrides the decision in either direction, shipped through both build and buildTransitive so it reaches consumers of the Routya meta package as well as direct consumers. Two diagnostics make the behaviour discoverable rather than silent. ROUTYA008 (Info) explains that a project with handlers generated nothing because it never composes a container, which is the normal shape for a library project. ROUTYA009 (Warning) reports two assemblies in one chain both generating, and names the property that resolves it. Verified on a three project probe, App to Api to Tests: 16 CS0436 warnings before, none after, with only the composition root generating and its dispatcher covering the handlers of all three assemblies. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three advisories, all in transitive dependencies of demo and test projects
rather than in anything Routya ships, resolved by bumping the direct reference
that pulls each one in. All stay on the same major version.
Microsoft.AspNetCore.OpenApi 10.0.0 to 10.0.12 in Routya.WebApi.Demo
brought Microsoft.OpenApi 2.0.0, GHSA-v5pm-xwqc-g5wc, high.
Now resolves 2.12.0.
Microsoft.Data.Sqlite 8.0.0 to 8.0.31 in Routya.SourceGen.DatabaseDemo
brought SQLitePCLRaw.lib.e_sqlite3 2.1.6, GHSA-2m69-gcr7-jv3q, high.
Now resolves 2.1.12.
xunit 2.5.3 to 2.9.3, and xunit.runner.visualstudio 2.5.3 to 2.8.2,
across all four test projects. xunit 2.5.3 depended on NETStandard.Library
1.6.1, which brought System.Net.Http 4.3.0, GHSA-7jgj-8wvc-jh57, and
System.Text.RegularExpressions 4.3.0, GHSA-cmhx-cq75-c4mj, both high.
dotnet list package --vulnerable --include-transitive now reports nothing
across every project in the repository. The solution build drops from seven
warnings to five, the remainder being pre-existing nullable field warnings in
Routya.Benchmark. All 150 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Version stamped in Routya.Core, Routya.SourceGenerators and Routya.Package, with the integration test's default RoutyaVersion brought up from a stale 3.1.0 to match. CHANGELOG gains a dated 4.1.0 section. The dispatcher ownership change moves out of Fixed into its own Changed heading with upgrade notes, because it can break one project shape and a minor version leans on those notes to carry it. The source generator README carried two claims this release makes false. It stated that handlers in referenced libraries are not discovered, which is now the headline feature, and that internal handlers are ignored, which has not been true since accessibility filtering was removed from discovery. Internal handlers are registered and dispatched normally; what an internal type cannot have is a typed member on the public IGeneratedRoutya interface, which is what ROUTYA005 reports. Both sections rewritten, and the dispatcher ownership rules and handler lifetimes documented there for the first time. Root README gains a 4.1.0 upgrade note above the existing 4.0.0 one, and its install snippets move to 4.1.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four demo projects exercise the source generator but the example tables listed only two, so Routya.SourceGen.DatabaseDemo and the Routya.WebApi.SourceGen.Demo.Application class library were invisible to anyone reading the packages on NuGet. Both are now listed in the generator and meta package READMEs, and the root README gains a console demos section covering all six console projects across the two dispatch paths. Descriptions were checked against the source rather than carried over. Routya.SourceGen.Demo was described as basic request and notification dispatch when it also carries an open generic logging pipeline behavior and a streaming example. The meta package tables are now split by dispatch path, since that package covers both. The Web API demo's endpoint table listed five of its eight routes. The three missing ones are the lifetime endpoints the prose immediately above the table already described, so the table contradicted its own section. One accuracy note added rather than silently fixed: the streaming example in Routya.SourceGen.Demo predates 4.0.0 and still uses IRequest<IAsyncEnumerable<T>> rather than the first class IStreamRequest<T> and CreateStream API. Both Web API demos use the current API, so the note points readers there. Every relative and GitHub link in the three READMEs verified against the directories on disk. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Routya.SourceGen.Demo still used IRequest<IAsyncEnumerable<T>> for its
streaming example, so the demo most people run first taught the shape 4.0.0
replaced. It now uses IStreamRequest<T>, IStreamRequestHandler and
CreateStream.
The demo also gains a StreamLoggingBehavior<TRequest, TResponse>, which is the
part that makes the difference legible rather than merely asserted. A request
behavior wrapped around a sequence returning handler completes before a single
item exists, whereas a stream behavior wraps the enumeration. The demo's output
now interleaves the handler producing a chunk, the behavior observing it with
an elapsed timing, and the consumer receiving it, so running it shows why the
two behavior interfaces are separate.
Every demo was run rather than only built. The four console demos exit zero.
The generator Web API demo was exercised across all eight of its endpoints, and
the two lifetime claims in the README were checked against it: the Scoped
handler behind GET /customers/{id} returns a different instance id on every
request, and the Singleton handler behind GET /status returns the same one.
The .NET Framework 4.8 demo builds.
Documentation updated to match, and the note added in the previous commit
warning readers away from this demo's streaming example is removed, since it no
longer applies. The streaming section of the root README now points at the demo
as a runnable reference.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🚀 Pull Request: Routya.Core
📋 Summary
Makes the source generator work in multiple project solutions, which it previously could not, and fixes the type conflicts that arose once it did.
Three things, in dependency order:
AddGeneratedRoutyaregistered nothing.AddGeneratedRoutya(ServiceLifetime)and a per handler[RoutyaHandler(ServiceLifetime)]attribute. It was hardcoded toTransientwith no way to change it.Routya.Generated.IGeneratedRoutya, and they collide.Released as 4.1.0. One behavioural change, documented under Upgrade notes in the changelog.
🔍 Related Issues (If Any)
None.
🛠️ Changes Made
Benchmarks are unchanged because the dispatch path is untouched. Every change here is in generation time behaviour, registration, or documentation.
✅ Checklist
The dispatcher conflict, in detail
Routya.Generatedis a fixed, public namespace, so two assemblies in one reference chain cannot both defineIGeneratedRoutya. Every project that referenced the generator and declared a handler emitted its own copy, so a layered solution producedCS0436at every level. The compiler resolves that conflict in favour of the source declaration, which is the part that matters: a caller could silently bind to a dispatcher other than the one the application registers at startup.This is a defect in released 4.0.0, not one introduced by this branch. Measured on a three project probe,
ApptoApitoTests, each declaring a handler:mainCS0436warningsTestsregisteredHow it is resolved
Generation is gated on the compilation calling
AddGeneratedRoutya. Detection is purely syntactic, which is what makes it self consistent: the method being called is itself generated, so it cannot be bound before the decision to generate has been taken.RoutyaGenerateDispatcheroverrides the decision in either direction. It ships in bothbuildandbuildTransitive, becausebuildalone would not reach anyone installing theRoutyameta package, which is how most consumers get the generator.Two diagnostics keep the behaviour discoverable rather than silent.
ROUTYA008(Info) explains that a project with handlers generated nothing because it composes no container, which is the normal shape for a library project.ROUTYA009(Warning) reports two assemblies in one chain both generating, and names the property that resolves it.Who this breaks
A project that used
IGeneratedRoutyabut did not callAddGeneratedRoutyain that same project previously got a dispatcher anyway and now gets nothing, so those injection sites stop compiling. That shape was already producingCS0436and could bind to the wrong dispatcher, so it was not working correctly before. Both remedies are in the changelog's Upgrade notes table.This is why 4.1.0 rather than 4.0.1, and the argument against 5.0.0 is that the affected shape was broken rather than supported, and one line of MSBuild restores the old result.
🧪 Manual Testing Notes
Reproduced before fixing. A three project probe outside the repository,
ApptoApitoTests, each referencing the generator and declaring a handler. The same probe was then built against releasedmainto establish whether the defect was pre-existing. It was.Verified after fixing. Zero warnings, with only the composition root generating and its dispatcher covering all three assemblies' handlers. The
ROUTYA009path and its documented remedy were both exercised, including confirming that theRoutyaGenerateDispatcherproperty actually reaches the compiler.Packaging verified.
dotnet packinspected to confirm bothbuild/andbuildTransitive/props land in the nupkg, and that the meta package pins both dependencies at 4.1.0.Every demo was run, not merely built. The four console demos exit zero. The generator Web API demo was exercised across all eight of its endpoints. The two lifetime claims in the README were checked against the running API: the
Scopedhandler behindGET /customers/{id}returned a different instance id on each of three requests, and theSingletonhandler behindGET /statusreturned the same one each time. The .NET Framework 4.8 demo builds.Tests. 150 passing across the three suites, 64 plus 32 plus 54. Solution builds with 0 errors and 5 warnings, all pre-existing nullable field warnings in
Routya.Benchmark.💬 Additional Context
Two problems found while checking documentation, both predating this work.
The generator README claimed internal handlers are ignored. They are not, and have not been since accessibility filtering was removed from discovery. Internal handlers are registered and dispatched normally; what an internal type cannot have is a typed member on the public
IGeneratedRoutya, which is whatROUTYA005reports.Routya.SourceGen.Demostill usedIRequest<IAsyncEnumerable<T>>for its streaming example, so the demo most people run first taught the shape 4.0.0 replaced. It is ported, and gains aStreamLoggingBehaviorso its output interleaves production, observation and consumption with elapsed timings. That makes the reason the two behavior interfaces are separate visible rather than merely asserted.Three known vulnerabilities cleared, all transitive dependencies of demo and test projects rather than anything Routya ships:
Microsoft.AspNetCore.OpenApito 10.0.12,Microsoft.Data.Sqliteto 8.0.31, andxunitto 2.9.3.dotnet list package --vulnerable --include-transitivenow reports nothing across every project.