feat: source generator for Brighter handler/mapper/transform registration#4138
feat: source generator for Brighter handler/mapper/transform registration#4138slang25 wants to merge 19 commits into
Conversation
…ransform registration Adds a Roslyn incremental source generator that emits a partial method body registering handlers, message mappers and transforms discovered in the current compilation, replacing runtime AutoFromAssemblies reflection scanning. Includes: - [BrighterRegistrations] marker attribute on a partial static method - [ExcludeFromBrighterRegistration] opt-out attribute - IBrighterBuilder.Transforms callback for explicit transform registration - HelloWorld sample wired up via AddFromThisAssembly() Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Extract IsRegistrationCandidate / ClassifyType / TryClassifyGenericInterface / IsTransformInterface from DiscoverRegistrations - Extract IsOpenGeneric helper from EmitHandlers conditional - Replace 7-arg MarkerSymbols constructor with object initializer in Resolve Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Following the Kathleen Dollard incremental-generator pattern: keep semantic model reads in one place, project to a Roslyn-free intermediate model, and write source from that model as a pure function. The writer becomes trivially unit-testable and the model is structurally equatable so the incremental pipeline can cache it. Structure: - Model/RegistrationModel.cs — pure-data records describing the emit - Model/EquatableArray.cs — value-equality wrapper for caching - SemanticModelReader.cs — single point that touches Roslyn symbols - RegistrationWriter.cs — pure model -> source text - MarkerSymbols.cs / Diagnostics.cs — extracted concerns - BrighterRegistrationsGenerator.cs — thin orchestrator/pipeline only - IsExternalInit.cs — polyfill for records on netstandard2.0 Tests (new tests/Paramore.Brighter.SourceGenerators.Tests project): - RegistrationWriterTests — 8 unit tests on the pure writer - BrighterRegistrationsGeneratorTests — 4 validation tests using Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing, asserting full generated source and BRGEN001 diagnostic. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…n-auto-assemblies # Conflicts: # Brighter.slnx
- TryBuildModel returns a BuildResult struct instead of two out params (5 args -> 3 args) - TryClassifyGenericInterface delegates mapper classification to a TryAddMapper helper, lowering cyclomatic complexity - Introduce a small Same(ISymbol?, ISymbol?) wrapper around SymbolEqualityComparer.Default.Equals to tidy the call sites Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Rework the generator so no ISymbol, Compilation, SemanticModel, or SyntaxNode ever escapes a transform. Every value flowing through the incremental graph is now a value-equatable record, which lets Roslyn skip transforms and the source-output step when an edit doesn't change the semantically relevant shape of the compilation. Pipeline: - ForAttributeWithMetadataName.transform projects IMethodSymbol -> MethodCandidate (record holding either a MethodTarget or a DiagnosticInfo). - CreateSyntaxProvider for `class ... : Base` transforms to EquatableArray<DiscoveredEntry> (zero, one, or many records per class). - The discovery batches are Collect()ed and Select()ed into a single flattened, sorted EquatableArray for stable, cache-friendly output. - methodCandidates.Combine(discovered) -> RegisterSourceOutput builds a RegistrationModel from pure data and emits via the unchanged writer. DiagnosticInfo + LocationInfo carry the (path, TextSpan, LinePositionSpan) needed to reconstruct a real Diagnostic at source-output time without holding non-cacheable Roslyn objects in the cache. Tests: - IncrementalCachingTests drives CSharpGeneratorDriver with trackIncrementalGeneratorSteps and asserts on IncrementalStepRunReason: trailing-comment edits and unrelated class additions yield only Cached / Unchanged outputs; adding a real handler yields Modified plus the new handler in the generated source. 15/15 tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ference A second pipeline synthesises an `internal static class BrighterAssemblyRegistrations` with an `AddFromThisAssembly()` extension on IBrighterBuilder, populated from the same DiscoveredEntry stream as the attribute-based path. Consumers no longer need to hand-write a partial class. Gating: - The generator only emits the auto class when the MSBuild property BrighterAutoRegistration=true is visible via AnalyzerConfigOptionsProvider. - The new build/Paramore.Brighter.SourceGenerators.props sets that property and declares it CompilerVisible. Because NuGet only applies build/ to *direct* PackageReferences, transitive consumers won't see the property and the auto class won't be generated for them. - Users opt out per project with <BrighterAutoRegistration>false</BrighterAutoRegistration>. Writer: - RegistrationModel/MethodTarget gain an IsPartial flag (default true). The writer omits `partial` on both the class and the method when IsPartial=false, so the auto class is a normal static class. Sample: - HelloWorld drops the hand-written BrighterRegistrations.cs and just calls builder.Services.AddBrighter().AddFromThisAssembly(). It opts in via <BrighterAutoRegistration>true</BrighterAutoRegistration> + CompilerVisibleProperty, because ProjectReference scenarios don't pick up the package's build/ props automatically. Tests: - AutoRegistrationTests verifies property=true emits the class with discovered handlers, property=false suppresses emission, and property-missing (the transitive-consumer scenario) also suppresses emission. 18/18 tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Review: Source generator for Brighter handler/mapper/transform registrationOverall this is a well-structured generator — the strict separation of 1. Breaking change to
|
Generator surface: - IBrighterBuilder.Transforms is now a BrighterBuilderExtensions extension method, not an interface member, so the original PR no longer makes a binary-breaking change to the public IBrighterBuilder contract. - Closed-generic handlers emit r.Register<TRequest, TImpl>() via the public IAm(Async)SubscriberRegistry interfaces, so the cast to the concrete ServiceCollectionSubscriberRegistry only appears when at least one open generic is present (and then exactly once). - Generated source calls Transforms statically via the fully-qualified BrighterBuilderExtensions, so it doesn't depend on the consumer adding a `using`. - Auto-generated AddFromThisAssembly is `internal` (was `public` on an internal class, which was noisy). Reader / model hardening: - ExcludeFromBrighterRegistrationAttribute is folded into MarkerSymbols so the symbol is resolved once per compilation instead of per class. - IsPrimaryDeclaration sorts DeclaringSyntaxReferences by (FilePath, Start) explicitly rather than relying on undocumented Roslyn ordering. - ReadClass returns a DiscoveryBatch carrying both entries and diagnostics, so a discovery-time warning (BRGEN005) can travel through the cached pipeline alongside the entries. - New BRGEN005 warning fires when a generic class implements a Brighter mapper or transform interface (previously silently dropped). - BuildHintName appends an FNV-1a hash of the original display string so types differing only in non-identifier characters (e.g. Foo.Bar vs Foo_Bar) can't collide. - EquatableArray<T>.Equals/GetHashCode use EqualityComparer<T>.Default, so the public type doesn't NRE if a future caller passes a null element. - DescriptorFor throws on an unknown diagnostic id instead of defaulting to MustBePartial, catching missed updates at test time. Tests (27 passing): - End-to-end tests added for BRGEN002 / BRGEN003 / BRGEN004 / BRGEN005. - End-to-end test for mapper + async mapper + transform discovery. - End-to-end test for partial-class dedup (a handler split across two syntax trees registers exactly once). - End-to-end test that IsClassifiable filters out abstract and private nested handlers. - AutoRegistrationTests asserts Empty diagnostics on the happy path. - RegistrationWriter unit tests updated for the new emit shape and a new case verifies the implementation cast is emitted exactly once when both closed and open-generic handlers are present. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Code Review: Source generator for Brighter handler/mapper/transform registrationSolid contribution overall — the architecture is exactly what you want from an incremental generator (Roslyn types confined to Correctness / behavioural concerns
Code quality
Tests
SecurityNo notable concerns. The generator reads only the user's compilation; emitted code uses fully-qualified PerformanceThe incremental pipeline is well-structured. The Nitpicks
Nice work overall. The "verified incrementality" via 🤖 Generated with Claude Code |
…n-auto-assemblies
Records the compile-time registration generator as an opt-in, additive alternative to assembly scanning. Ties the decision to #4159 (the unloaded-assembly blind spot it structurally fixes for first-party types) and #4160 / ADR 0061 (the runtime symptom it reduces reliance on). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Code Review — Source generator for handler/mapper/transform registrationOverall this is a high-quality, well-architected PR. The "read → intermediate model → write" split is followed faithfully: Feedback below, ordered by impact. 🔴 Default-on auto-registration collides with the documented manual
|
Replace the hand-rolled whitespace constants in RegistrationWriter with a CodeWriter that subclasses IndentedTextWriter and manages brace blocks (StartBlock/EndBlock), modelled on the ASP.NET Core source generators' CodeWriter. Pure structural change — generated output is byte-identical, as verified by the exact-snapshot generator tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review: source generator for handler/mapper/transform registrationOverall this is a high-quality, well-structured contribution. The Kathleen Dollard "read → model → write" split is followed cleanly, the model layer is genuinely Roslyn-free and value-equatable, and the incremental-caching tests verify the contract that matters (asserting on
A few things worth addressing, roughly in priority order. 1. Correctness — partial class whose base/interface list is on a non-primary file is silently droppedIn
These interact badly. Consider a
2. Limitation — only
|
Add a shared GeneratedSource helper (banner + tool name/version + the [System.CodeDom.Compiler.GeneratedCode] attribute), modelled on the ASP.NET Core source generators. The registration writer now emits the auto-generated banner and stamps [GeneratedCode] on the generated method; the post-init attributes file gets the same banner. Snapshot tests build their expected output from GeneratedSource so they track the production banner and the tool version dynamically. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Code Review — Source generator for handler/mapper/transform registrationThorough, well-structured PR. The reader→model→writer separation is exactly the right shape for an incremental generator, the value-equatable record pipeline is done properly, and the test suite (28 tests, including Correctness / behavior
Packaging gap
Minor / cleanups
Nits
SecurityNo concerns — compile-time only, all emitted type names originate from the compilation symbol table (fully-qualified, Test coverageStrong. The one thing I'd add is an automated end-to-end test that the generated registrations actually resolve and dispatch through a real Reviewed statically; I was unable to run 🤖 Generated with Claude Code |
Name the four pipeline stages with WithTrackingName and rewrite the caching tests to assert the model stages are Cached/Unchanged (never Modified) on an unrelated edit, with the post-Collect stages specifically Cached. This catches the classic non-incremental bug where a pipeline value loses value equality — verified by temporarily breaking EquatableArray equality and watching the tests go red. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Code Review — Source generator for handler/mapper/transform registrationThanks for this — it's a genuinely high-quality piece of work. The architecture follows the recommended incremental-generator pattern faithfully: Feedback grouped by severity — most is minor; a couple are worth a look before merge. Potential bug — partial class whose base list lives in a non-"primary" file
```csharp Consider a handler split as:
Packaging is described but not wired upThe ADR and PR description center on shipping an analyzer-only NuGet package ( Fine if packaging is intentionally deferred (the csproj comments say "prototype"), but worth stating explicitly and tracking the follow-up to add the Roslyn version pin / consumer compatibility
Default method name collides with the documented hand-written conventionThe auto-generated extension is Minor
Test coverageStrong.
Overall: solid design, clean code, well-tested. The partial-class discovery gap and the packaging-vs-intent mismatch are the two things I'd want resolved (or explicitly deferred) before this is considered done. 🤖 Generated with Claude Code |
- Discover handlers/mappers/transforms on any partial declaration, not just the 'primary' one: drop IsPrimaryDeclaration (dedup is already handled by RegistrationModel.From's Distinct), fixing the case where the base list lives on a non-primary partial file and the type was silently never registered. - Suppress the auto-generated BrighterAssemblyRegistrations class when the compilation hand-writes a [BrighterRegistrations] method, avoiding double registration and the ambiguous AddFromThisAssembly call. - Discover record (not just class) mappers and transforms. - Memoise MarkerSymbols.Resolve per-compilation via a weak-keyed table so the 8 metadata lookups run once per pass instead of per node. - Remove a redundant !type.IsGenericType clause. - Replace the RS2008 suppression with proper analyzer release tracking (AnalyzerReleases.Shipped/Unshipped.md for BRGEN001-005). - Document accessibility asymmetry, open-generic coupling, handler-inheritance double registration, and deferred follow-ups (packaging, Roslyn floor) in ADR 0062. Adds regression tests for the partial-class, auto/manual collision, and record mapper cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review: source generator for handler/mapper/transform registrationI read through the generator ( What's strong
Potential bugs / correctness
Design / UX observations (not blocking)
Minor / nits
Test coverageCoverage is strong (~1100 lines across writer unit tests, end-to-end generator tests for BRGEN001–005, partial-class dedup, auto/manual collision, record mappers, Overall: solid, well-tested, well-documented feature. The two correctness items (#1, #2) are edge cases and the rest are polish — none look like blockers for the core scenario. Reviewed by Claude (Opus 4.8). |
- Types nested in an open generic (e.g. Outer<T>.Handler) are no longer classified for registration: they crashed the generator (UnboundGenericName computed a negative count) or emitted code referencing unbound type params. They are now reported as BRGEN006 before classification. - De-duplicate discovery diagnostics: a generic type whose base list is split across partial declarations reached ReadClass once per file, producing duplicate BRGEN005s; the flattened diagnostics are now Distinct(). - Emit BRGEN007 (info) when auto-registration is suppressed by a manual [BrighterRegistrations] method, so the vanished auto class is explained. - Write the generated banner line-by-line so a file's line endings are consistent instead of mixing the banner's LF with Environment.NewLine. - Reduce ReadClass complexity (extract GetDiscoverableType) and split the RegisterAutoRegistration guard (CodeScene). Adds tests for the nested-open-generic (BRGEN006), split-partial diagnostic de-dup, and auto-suppression (BRGEN007) cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Code Review — Source generator for handler/mapper/transform registrationReviewed against A few items worth addressing before merge, ordered by importance. 1. Roslyn version — pin to the lowest supported, not the latest (important)
2. Packaging isn't wired up (please confirm intent)The PR description is framed entirely around "add the package to the top-level project", but 3. Hard cast for open-generic handlers (minor robustness)
Things I checked that are correct
Test coverageStrong — 33 tests spanning the pure writer, generator behaviour, auto-registration on/off/suppressed, and verified incrementality. Two suggestions:
Nothing here is blocking on correctness; #1 and #2 are the two I'd want resolved (or explicitly deferred) before this is consumed as a package. 🤖 Generated with Claude Code |
- RegistrationWriter: split WriteHandlers into closed/open-generic helpers and replace the type-keyword switch with conditional modifiers, lowering the module's mean cyclomatic complexity. - SemanticModelReader: extract a shared ClassifyInterface/GenericInterfaceKind so TryClassifyInterface and ImplementsAnyBrighterInterface no longer duplicate the interface-matching logic; ImplementsAnyBrighterInterface becomes a one-liner. Replace the AccessibilityModifier switch with a lookup. - RegisterAutoRegistration: replace the multi-condition guard with single- condition guards (no complex conditional). Pure structural change — generated output is byte-identical (snapshot tests unchanged); all 33 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Code Review — Source generator for handler/mapper/transform registration Thanks for this — it is a well-architected PR. The Kathleen Dollard read → model → write structure is followed faithfully: SemanticModelReader is the only place that touches Roslyn symbols, everything downstream is value-equatable records, and RegistrationWriter is a pure function that is unit-testable without a Compilation. The IncrementalCachingTests that assert on IncrementalStepRunReason (rather than just structural shape) are exactly the right way to prove incrementality, and the positive/negative controls there are excellent. Diagnostics with release tracking (AnalyzerReleases.*.md), the FNV-hashed hint names to avoid collisions, and the ConditionalWeakTable memoisation of MarkerSymbols are all solid touches. A few things worth a closer look. Potential bug — default message mapper is not registered on the handlers-only path
The generated path only emits a Notably the HelloWorld sample itself exercises this path (handler + NoOpTransformer, no mapper). It works there only because it does internal Diagnostics fire even when nothing is generated
Minor
Things I checked that look correct
Overall this is high quality. The default-mapper question is the one I would want resolved before merge; the rest are minor. Generated with Claude Code |
- Default-mapper parity: always emit builder.MapperRegistry(...) (even with no discovered mappers) so the default message mapper is registered via EnsureDefaultMessageMapperIsRegistered, matching AutoFromAssemblies — it was silently absent on a handlers-only assembly. - Gate discovery diagnostics (BRGEN005/006) on the generator actually being active (auto-registration on, or a manual method present), so a transitive analyzer reference that generates nothing doesn't warn about a consumer's generic / nested-in-open-generic types. - Open-generic handler registry cast now uses 'as ... ?? throw InvalidOperationException' with a clear message instead of a hard cast, symmetric with the Transforms extension. - CodeScene: replace TryClassifyInterface's two ref params with a tuple return (5 args -> 3) via a shared classifier; drop WriteMappers' early return. - Document the Roslyn-floor / RS2002 entanglement in ADR 0062 (the version pin must be done together with the deferred packaging work). All 34 tests pass; HelloWorld sample builds clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review: Source generator for handler/mapper/transform registrationI reviewed this against the repo's conventions (CLAUDE.md / Comments below are mostly forward-looking / nits — nothing I'd consider blocking. Code quality / correctness — looks solid
Things worth flagging before this ships (largely already acknowledged in ADR 0062)
Minor / nits
Test coverageStrong. The Nice work — this is a well-structured feature. 🤖 Generated with Claude Code |
- Pin Microsoft.CodeAnalysis.CSharp to 4.8.0 via VersionOverride (first .NET 8 Roslyn, the oldest in-support modern .NET) so the generator loads on that SDK and newer. 4.8.0's bundled analyzers don't trip the RS2002 that 4.3.1 did. - RegistrationModel now composes MethodTarget instead of duplicating its 12 fields (17 ctor args -> 6), removing the duplication and the Excess-Arguments finding. - DescriptorFor: switch -> dictionary lookup (cyclomatic 9 -> ~2). - IsClassifiable: split the 3-way || into single-condition guards (no complex conditional). - ClassifyEntries: extract TryReportNestedInOpenGeneric so it no longer carries a second block of nested conditionals (Bumpy Road). Pure structural change — generated output byte-identical; all 34 tests pass; generator and HelloWorld sample build clean against Roslyn 4.8.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Code Review — Source generator for handler/mapper/transform registrationReviewed the full diff (generator, DI changes, sample, tests). This is genuinely high-quality work — the architecture, incrementality discipline, and test depth are all well above the bar for a Roslyn generator. Comments below, grouped by severity. Nothing here is a blocker; the medium item is the only one I'd ask be addressed (even if only with a diagnostic) before merge. What's strong
Medium — manual
|
Undo two changes made purely to satisfy CodeScene thresholds where the original code was clearer: - AccessibilityModifier: back to a switch expression (a closed enum -> string map) instead of a static Dictionary + TryGetValue. - IsClassifiable: back to a single 'IsAbstract || IsImplicitClass || IsAnonymousType' guard instead of three separate ifs. These reintroduce the 'Complex Conditional' (IsClassifiable) and contribute to 'Overall Code Complexity' (SemanticModelReader) findings, which are false positives on this kind of code and should be suppressed in CodeScene rather than worked around. The genuine improvements from the complexity pass (RegistrationModel composing MethodTarget, the shared ClassifyInterface dedup) are kept. Output byte-identical; all 34 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Gates Failed
Enforce advisory code health rules
(2 files with Excess Number of Function Arguments, Complex Method, Complex Conditional)
Our agent can fix these. Install it.
Gates Passed
3 Quality Gates Passed
Reason for failure
| Enforce advisory code health rules | Violations | Code Health Impact | |
|---|---|---|---|
| SemanticModelReader.cs | 2 advisory rules | 9.39 | Suppress |
| RegistrationModel.cs | 1 advisory rule | 9.69 | Suppress |
Quality Gate Profile: Clean Code Collective
Install CodeScene MCP: safeguard and uplift AI-generated code. Catch issues early with our IDE extension and CLI tool.
| private static string AccessibilityModifier(Accessibility accessibility) => accessibility switch | ||
| { | ||
| Accessibility.Public => "public", | ||
| Accessibility.Internal => "internal", | ||
| Accessibility.Private => "private", | ||
| Accessibility.Protected => "protected", | ||
| Accessibility.ProtectedOrInternal => "protected internal", | ||
| Accessibility.ProtectedAndInternal => "private protected", | ||
| _ => "internal" | ||
| }; |
There was a problem hiding this comment.
❌ New issue: Complex Method
AccessibilityModifier has a cyclomatic complexity of 9, threshold = 9
| { | ||
| if (type.TypeKind != TypeKind.Class) | ||
| return false; | ||
| if (type.IsAbstract || type.IsImplicitClass || type.IsAnonymousType) |
There was a problem hiding this comment.
❌ New issue: Complex Conditional
IsClassifiable has 1 complex conditionals with 2 branches, threshold = 2
Code Review — Source generator for handler/mapper/transform registrationReviewed the full diff (29 files). High-quality, well-architected contribution. Strengths first, then issues by severity. Strengths
Issues1. (Major — but acknowledged) The NuGet distribution path described in the PR doesn't actually work yet. 2. (Minor bug / unguarded gap) A 3. (Minor) 4. (Design question) The auto-generated 5. (Nits)
VerdictArchitecturally excellent and well-tested. The only true follow-up blocker for the advertised feature is #1 (packaging), which the ADR already calls out. Items #2–#4 are worth addressing or documenting before this is positioned as the recommended replacement for Note: |
Description
Adds
Paramore.Brighter.SourceGenerators: a Roslyn incremental source generator that emits handler / message-mapper / transform registrations at compile time, as an alternative to runtimeAutoFromAssembliesreflection scanning. The generator follows the recommended "read → intermediate model → write" structure and runs as a properly incremental pipeline (verified via tests onIncrementalStepRunReason).Consumers can either:
internal static class BrighterAssemblyRegistrationsis auto-generated with anAddFromThisAssembly()extension onIBrighterBuilder. The opt-in flows via abuild/props file so it applies only to direct PackageReferences, not transitive ones. Override per-project with<BrighterAutoRegistration>false</BrighterAutoRegistration>.static partialmethod marked with[BrighterRegistrations]and the generator fills in the body.[ExcludeFromBrighterRegistration]opts a single type out either way. A newIBrighterBuilder.Transforms(...)callback onServiceCollectionBrighterBuilderis added so transforms can be registered explicitly (symmetric withHandlers/MapperRegistry). The HelloWorld sample exercises both: the auto-generated path plus aNoOpTransformerfor transform discovery.Related Issues
Type of Change
Checklist
Additional Notes
Replaces #4127 (which was opened from my fork).
Architecture follows the Kathleen Dollard incremental-generator pattern:
SemanticModelReaderis the only place that touches Roslyn symbols and projects everything to Roslyn-free records;RegistrationWriteris a pureRegistrationModel → stringfunction and is exhaustively unit-tested without aCompilation. Diagnostics are carried through the pipeline asDiagnosticInfo+LocationInfo(value-equatable) and rebuilt at source-output time.Pipeline incrementality is verified, not just structural:
IncrementalCachingTestsdrivesCSharpGeneratorDriverwithtrackIncrementalGeneratorSteps: trueand asserts onIncrementalStepRunReason— trailing-comment edits and unrelated class additions yield onlyCached/Unchangedoutputs; adding a real handler yields exactly oneModifiedsource output containing the new handler.