Skip to content

feat: source generator for Brighter handler/mapper/transform registration#4138

Draft
slang25 wants to merge 8 commits into
masterfrom
slang25/source-gen-auto-assemblies
Draft

feat: source generator for Brighter handler/mapper/transform registration#4138
slang25 wants to merge 8 commits into
masterfrom
slang25/source-gen-auto-assemblies

Conversation

@slang25
Copy link
Copy Markdown
Contributor

@slang25 slang25 commented May 18, 2026

Description

Adds Paramore.Brighter.SourceGenerators: a Roslyn incremental source generator that emits handler / message-mapper / transform registrations at compile time, as an alternative to runtime AutoFromAssemblies reflection scanning. The generator follows the recommended "read → intermediate model → write" structure and runs as a properly incremental pipeline (verified via tests on IncrementalStepRunReason).

Consumers can either:

  • Add the package to the top-level project — an internal static class BrighterAssemblyRegistrations is auto-generated with an AddFromThisAssembly() extension on IBrighterBuilder. The opt-in flows via a build/ props file so it applies only to direct PackageReferences, not transitive ones. Override per-project with <BrighterAutoRegistration>false</BrighterAutoRegistration>.
  • Or hand-write a static partial method marked with [BrighterRegistrations] and the generator fills in the body.

[ExcludeFromBrighterRegistration] opts a single type out either way. A new IBrighterBuilder.Transforms(...) callback on ServiceCollectionBrighterBuilder is added so transforms can be registered explicitly (symmetric with Handlers / MapperRegistry). The HelloWorld sample exercises both: the auto-generated path plus a NoOpTransformer for transform discovery.

Related Issues

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the Contributing Guide
  • I have checked the documentation for relevant guidance
  • I have added/updated XML documentation for any public API changes
  • I have added/updated tests as appropriate
  • My changes follow the existing code style and conventions

Additional Notes

Replaces #4127 (which was opened from my fork).

Architecture follows the Kathleen Dollard incremental-generator pattern: SemanticModelReader is the only place that touches Roslyn symbols and projects everything to Roslyn-free records; RegistrationWriter is a pure RegistrationModel → string function and is exhaustively unit-tested without a Compilation. Diagnostics are carried through the pipeline as DiagnosticInfo + LocationInfo (value-equatable) and rebuilt at source-output time.

Pipeline incrementality is verified, not just structural: IncrementalCachingTests drives CSharpGeneratorDriver with trackIncrementalGeneratorSteps: true and asserts on IncrementalStepRunReason — trailing-comment edits and unrelated class additions yield only Cached / Unchanged outputs; adding a real handler yields exactly one Modified source output containing the new handler.

slang25 and others added 7 commits May 11, 2026 15:45
…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>
codescene-delta-analysis[bot]

This comment was marked as outdated.

@claude
Copy link
Copy Markdown

claude Bot commented May 18, 2026

Review: Source generator for Brighter handler/mapper/transform registration

Overall this is a well-structured generator — the strict separation of SemanticModelReader (Roslyn-touching) from the value-equatable model and the pure RegistrationWriter is exactly the recommended Kathleen Dollard pattern, and the verified incremental tests (IncrementalStepRunReason assertions) are a nice step above the usual "structural" caching claims. Below is feedback grouped roughly by priority.

1. Breaking change to IBrighterBuilder interface (likely needs flagging)

IBrighterBuilder.cs adds a new abstract member:

IBrighterBuilder Transforms(Action<ServiceCollectionTransformerRegistry> registerTransforms);

Adding a non-default member to a public interface is binary- and source-breaking for any external implementer of IBrighterBuilder. The PR is labelled "non-breaking change which adds functionality" but this isn't accurate by strict semver rules. Options:

  • Make it a default interface method (C# 8 / netstandard2.1) — Brighter already targets recent runtimes so this should work.
  • Or document this explicitly as a breaking change in release notes.

It's also worth asking whether the new method is needed on the interface at all, since the generated code only ever uses it on the concrete ServiceCollectionBrighterBuilder flow. An extension method that internally casts (or that's exposed on the concrete type) would avoid the breakage entirely.

2. Generated code couples to a specific IBrighterBuilder implementation

RegistrationWriter.cs:111:

sb.AppendLine("                var registry = (global::Paramore.Brighter.Extensions.DependencyInjection.ServiceCollectionSubscriberRegistry)r;");

The Handlers(Action<IAmASubscriberRegistry>) callback receives an IAmASubscriberRegistry, but the generator unconditionally casts to the concrete ServiceCollectionSubscriberRegistry so it can call EnsureHandlerIsRegistered. If anyone implements an alternative IBrighterBuilder (or the registry interface), generated code will throw InvalidCastException at registration time — a very surprising failure mode at runtime when the generator otherwise looks "framework-aware".

Consider either (a) lifting EnsureHandlerIsRegistered onto IAmASubscriberRegistry, (b) splitting open-generic registration into a separate callback method, or (c) at minimum, documenting this coupling.

3. Test coverage gaps

The generator-internal logic has solid RegistrationWriter unit tests, but the end-to-end behaviour is under-covered:

  • Diagnostics BRGEN002 (non-static), BRGEN003 (wrong return type), BRGEN004 (wrong signature) — no end-to-end tests. Only BRGEN001 is exercised.
  • Mappers / async mappers / transforms — RegistrationWriterTests covers the emitter, but there are no CSharpSourceGeneratorTest runs that feed real IAmAMessageMapper<> / IAmAMessageMapperAsync<> / IAmAMessageTransform types through the full pipeline. Given the discovery logic in ClassifyEntries / TryClassifyInterface is non-trivial, an end-to-end test for each kind would buy a lot of safety.
  • IsPrimaryDeclaration — partial-class deduplication is logic-heavy and untested. A user with partial class Handler { ... } split across two files would otherwise be a regression risk.
  • Abstract / nested / non-public-enclosed types — the IsClassifiable and IsReachableFromGeneratedCode filters have no tests.
  • IAmAMessageTransform discovered on a generic class — silently skipped (see point 5 below); no test asserts or documents this.

Per the project's CLAUDE.md TDD policy, these gaps would normally need test-first coverage before merging.

4. BuildHintName collision risk

SemanticModelReader.cs:224-231 sanitises the type's display string by replacing every non-[A-Za-z0-9_] character with _:

foreach (var ch in raw)
    sanitized.Append(char.IsLetterOrDigit(ch) || ch == '_' ? ch : '_');

Two distinct types — e.g. Foo.Bar and Foo_Bar (yes, contrived, but possible in generated code or auto-naming conventions) — collapse to the same hint name and collide. Mitigations: include arity, append a short stable hash (e.g. FNV-1a of the original display string), or prefix with the assembly name.

5. Asymmetry: generic mappers and generic transforms are silently dropped

In SemanticModelReader.TryClassifyInterface (line 173, 175) and ClassifyEntries (line 147), mappers and transforms emit nothing when type.IsGenericType. Handlers, however, take the open-generic branch in MakeHandlerEntry and call EnsureHandlerIsRegistered. This is inconsistent and the user gets no warning when their MyMapper<T> : IAmAMessageMapper<MyEvent> is silently ignored. Either emit a diagnostic ("not supported") or document the limitation.

6. EquatableArray<T>.Equals will NRE on null items

Model/EquatableArray.cs:55:

if (!_items[i].Equals(other._items[i])) return false;

If T is a reference type and any element is null, this throws. The constraint where T : IEquatable<T> doesn't preclude nulls. Today no caller passes null, but the type is public and intended to be reused — defensive EqualityComparer<T>.Default.Equals(...) would harden this for free.

7. IsPrimaryDeclaration relies on undocumented Roslyn ordering

SemanticModelReader.cs:197-204:

// "Roslyn orders DeclaringSyntaxReferences deterministically."
var primary = refs[0];
return primary.SyntaxTree == cls.SyntaxTree && primary.Span == cls.Span;

This is observed behaviour, not a contract — but it's fine in practice. Consider ordering explicitly (refs.OrderBy(r => r.SyntaxTree.FilePath, StringComparer.Ordinal).ThenBy(r => r.Span.Start).First()) so the dedup is self-evidently stable.

8. Minor / nits

  • HasExcludeAttribute does an independent GetTypeByMetadataName lookup on every classifiable class (SemanticModelReader.cs:208). MarkerSymbols already resolves marker symbols once — fold this attribute symbol into MarkerSymbols so it's resolved once per compilation.
  • BrighterRegistrationsGenerator.DescriptorFor swallows unknown ids by defaulting to MustBePartial (line 203). This will produce a misleading diagnostic if a new id is ever added to Diagnostics.cs and DescriptorFor isn't updated. throw new InvalidOperationException (or Debug.Fail) on unknown ids would catch this at test time.
  • CompilationProvider re-runs on every compilation change (BrighterRegistrationsGenerator.cs:147). This is the standard pattern when you need a compilation-level check, and the projection to bool keeps downstream cached — just confirming the team understands the trade-off.
  • AutoRegistrationTests doesn't assert "no diagnostics". A regression that adds a spurious diagnostic to the auto path would be missed.
  • The PR description claims internal static class BrighterAssemblyRegistrations but the test (AutoRegistrationTests.PropertyTrue_GeneratesBrighterAssemblyRegistrations) only checks Contains("internal static class BrighterAssemblyRegistrations") — fine; just noting AddFromThisAssembly itself is emitted as public static, not internal. Confirm that's intentional given the containing class is internal (a public member of an internal class is effectively internal — but it's slightly noisy).

What's particularly good

  • Pipeline incrementality is verified with real IncrementalStepRunReason assertions — this is rare and excellent.
  • The MethodTarget / RegistrationModel / DiscoveredEntry value-records cleanly separate Roslyn from emit logic, making the writer trivially unit-testable.
  • Diagnostics are carried as DiagnosticInfo + LocationInfo and rehydrated at source-output time — exactly the right approach for cacheable pipelines.
  • Shipping the BrighterAutoRegistration opt-in via build/ (not buildTransitive/) is a thoughtful choice that prevents the auto-class from spraying into libraries that pick the package up transitively.
  • [ExcludeFromBrighterRegistration] gives users a fine-grained escape hatch.

Suggested actions

In rough priority order:

  1. Resolve the IBrighterBuilder.Transforms interface-addition story (default impl, concrete-only, or breaking-change note).
  2. Decide on the ServiceCollectionSubscriberRegistry coupling — either widen the interface or document it loudly.
  3. Add end-to-end tests for BRGEN002/003/004, mapper/async-mapper/transform discovery, and the partial-class dedup.
  4. Harden EquatableArray.Equals and BuildHintName.
  5. Decide whether generic mappers/transforms should diagnose or stay silent.

Happy to dig further on any of these.

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>
Copy link
Copy Markdown

@codescene-delta-analysis codescene-delta-analysis Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gates Failed
New code is healthy (1 new file with code health below 9.00)
Enforce critical code health rules (2 files with Bumpy Road Ahead)
Enforce advisory code health rules (3 files with Excess Number of Function Arguments, Complex Method, Complex Conditional, Overall Code Complexity)

Gates Passed
1 Quality Gates Passed

See analysis details in CodeScene

Reason for failure
New code is healthy Violations Code Health Impact
SemanticModelReader.cs 5 rules 7.96 Suppress
Enforce critical code health rules Violations Code Health Impact
SemanticModelReader.cs 1 critical rule 7.96 Suppress
RegistrationWriter.cs 1 critical rule 9.49 Suppress
Enforce advisory code health rules Violations Code Health Impact
SemanticModelReader.cs 4 advisory rules 7.96 Suppress
RegistrationWriter.cs 1 advisory rule 9.49 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.

Comment on lines +99 to +146
private static void WriteHandlers(
StringBuilder sb,
string paramName,
EquatableArray<HandlerEntry> entries,
bool isAsync)
{
if (entries.Count == 0)
return;

var callbackMethod = isAsync ? "AsyncHandlers" : "Handlers";
var registerMethod = isAsync ? "RegisterAsync" : "Register";
var hasOpenGeneric = false;
foreach (var entry in entries)
{
if (entry.IsOpenGeneric) { hasOpenGeneric = true; break; }
}

sb.Append(" ").Append(paramName).Append('.').Append(callbackMethod).AppendLine("(r =>");
sb.AppendLine(" {");

// For closed-generic handlers, use the strongly-typed Register<TRequest, TImpl>() method
// available on the public interface — no implementation cast needed.
foreach (var entry in entries)
{
if (entry.IsOpenGeneric) continue;
sb.Append(" r.").Append(registerMethod).Append('<')
.Append(entry.RequestTypeFullyQualified).Append(", ")
.Append(entry.HandlerTypeFullyQualified)
.AppendLine(">();");
}

// Open-generic handlers need EnsureHandlerIsRegistered, which only exists on the DI
// extension's concrete ServiceCollectionSubscriberRegistry. Emit the cast only when at
// least one open generic is present, so the common case stays interface-only.
if (hasOpenGeneric)
{
sb.AppendLine(" var registry = (global::Paramore.Brighter.Extensions.DependencyInjection.ServiceCollectionSubscriberRegistry)r;");
foreach (var entry in entries)
{
if (!entry.IsOpenGeneric) continue;
sb.Append(" registry.EnsureHandlerIsRegistered(typeof(")
.Append(entry.HandlerTypeFullyQualified)
.AppendLine("));");
}
}

sb.AppendLine(" });");
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ New issue: Complex Method
WriteHandlers has a cyclomatic complexity of 13, threshold = 9

Suppress

Comment on lines +99 to +146
private static void WriteHandlers(
StringBuilder sb,
string paramName,
EquatableArray<HandlerEntry> entries,
bool isAsync)
{
if (entries.Count == 0)
return;

var callbackMethod = isAsync ? "AsyncHandlers" : "Handlers";
var registerMethod = isAsync ? "RegisterAsync" : "Register";
var hasOpenGeneric = false;
foreach (var entry in entries)
{
if (entry.IsOpenGeneric) { hasOpenGeneric = true; break; }
}

sb.Append(" ").Append(paramName).Append('.').Append(callbackMethod).AppendLine("(r =>");
sb.AppendLine(" {");

// For closed-generic handlers, use the strongly-typed Register<TRequest, TImpl>() method
// available on the public interface — no implementation cast needed.
foreach (var entry in entries)
{
if (entry.IsOpenGeneric) continue;
sb.Append(" r.").Append(registerMethod).Append('<')
.Append(entry.RequestTypeFullyQualified).Append(", ")
.Append(entry.HandlerTypeFullyQualified)
.AppendLine(">();");
}

// Open-generic handlers need EnsureHandlerIsRegistered, which only exists on the DI
// extension's concrete ServiceCollectionSubscriberRegistry. Emit the cast only when at
// least one open generic is present, so the common case stays interface-only.
if (hasOpenGeneric)
{
sb.AppendLine(" var registry = (global::Paramore.Brighter.Extensions.DependencyInjection.ServiceCollectionSubscriberRegistry)r;");
foreach (var entry in entries)
{
if (!entry.IsOpenGeneric) continue;
sb.Append(" registry.EnsureHandlerIsRegistered(typeof(")
.Append(entry.HandlerTypeFullyQualified)
.AppendLine("));");
}
}

sb.AppendLine(" });");
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ New issue: Bumpy Road Ahead
WriteHandlers has 3 blocks with nested conditional logic. Any nesting of 2 or deeper is considered. Threshold is 2 blocks per function

Suppress

Comment on lines +176 to +214
private static DiscoveredEntry? TryClassifyInterface(
INamedTypeSymbol type,
INamedTypeSymbol iface,
MarkerSymbols markers,
ref bool seenTransform,
ref bool unsupportedGenericMapperOrTransform)
{
if (Same(iface, markers.MessageTransform) || Same(iface, markers.MessageTransformAsync))
{
if (type.IsGenericType)
unsupportedGenericMapperOrTransform = true;
else
seenTransform = true;
return null;
}

if (!iface.IsGenericType || iface.TypeArguments.Length != 1)
return null;

var def = iface.OriginalDefinition;
var requestType = iface.TypeArguments[0];

if (Same(def, markers.HandleRequests))
return MakeHandlerEntry(DiscoveredKind.SyncHandler, type, requestType);
if (Same(def, markers.HandleRequestsAsync))
return MakeHandlerEntry(DiscoveredKind.AsyncHandler, type, requestType);
if (Same(def, markers.MessageMapper))
{
if (type.IsGenericType) { unsupportedGenericMapperOrTransform = true; return null; }
return new DiscoveredEntry(DiscoveredKind.Mapper, FullyQualified(requestType), FullyQualified(type), IsOpenGeneric: false);
}
if (Same(def, markers.MessageMapperAsync))
{
if (type.IsGenericType) { unsupportedGenericMapperOrTransform = true; return null; }
return new DiscoveredEntry(DiscoveredKind.AsyncMapper, FullyQualified(requestType), FullyQualified(type), IsOpenGeneric: false);
}

return null;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ New issue: Complex Method
TryClassifyInterface has a cyclomatic complexity of 12, threshold = 9

Suppress

Comment on lines +70 to +103
public static DiscoveryBatch ReadClass(GeneratorSyntaxContext ctx, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();

if (ctx.Node is not ClassDeclarationSyntax cls)
return DiscoveryBatch.Empty;

if (ctx.SemanticModel.GetDeclaredSymbol(cls, cancellationToken) is not INamedTypeSymbol type)
return DiscoveryBatch.Empty;

if (!IsClassifiable(type))
return DiscoveryBatch.Empty;

// Only emit from the "primary" partial declaration so partial classes don't get
// discovered N times. Order explicitly so the choice is self-evidently stable.
if (!IsPrimaryDeclaration(type, cls))
return DiscoveryBatch.Empty;

var markers = MarkerSymbols.Resolve(ctx.SemanticModel.Compilation);
if (!markers.IsValid)
return DiscoveryBatch.Empty;

if (markers.ExcludeAttribute is not null && HasAttribute(type, markers.ExcludeAttribute))
return DiscoveryBatch.Empty;

var entries = new List<DiscoveredEntry>();
var diagnostics = new List<DiagnosticInfo>();
ClassifyEntries(type, markers, entries, diagnostics);
if (entries.Count == 0 && diagnostics.Count == 0)
return DiscoveryBatch.Empty;
return new DiscoveryBatch(
new EquatableArray<DiscoveredEntry>(entries),
new EquatableArray<DiagnosticInfo>(diagnostics));
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ New issue: Complex Method
ReadClass has a cyclomatic complexity of 10, threshold = 9

Suppress

Comment on lines +176 to +214
private static DiscoveredEntry? TryClassifyInterface(
INamedTypeSymbol type,
INamedTypeSymbol iface,
MarkerSymbols markers,
ref bool seenTransform,
ref bool unsupportedGenericMapperOrTransform)
{
if (Same(iface, markers.MessageTransform) || Same(iface, markers.MessageTransformAsync))
{
if (type.IsGenericType)
unsupportedGenericMapperOrTransform = true;
else
seenTransform = true;
return null;
}

if (!iface.IsGenericType || iface.TypeArguments.Length != 1)
return null;

var def = iface.OriginalDefinition;
var requestType = iface.TypeArguments[0];

if (Same(def, markers.HandleRequests))
return MakeHandlerEntry(DiscoveredKind.SyncHandler, type, requestType);
if (Same(def, markers.HandleRequestsAsync))
return MakeHandlerEntry(DiscoveredKind.AsyncHandler, type, requestType);
if (Same(def, markers.MessageMapper))
{
if (type.IsGenericType) { unsupportedGenericMapperOrTransform = true; return null; }
return new DiscoveredEntry(DiscoveredKind.Mapper, FullyQualified(requestType), FullyQualified(type), IsOpenGeneric: false);
}
if (Same(def, markers.MessageMapperAsync))
{
if (type.IsGenericType) { unsupportedGenericMapperOrTransform = true; return null; }
return new DiscoveredEntry(DiscoveredKind.AsyncMapper, FullyQualified(requestType), FullyQualified(type), IsOpenGeneric: false);
}

return null;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ New issue: Bumpy Road Ahead
TryClassifyInterface has 3 blocks with nested conditional logic. Any nesting of 2 or deeper is considered. Threshold is 2 blocks per function

Suppress

@@ -0,0 +1,308 @@
#region Licence
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ New issue: Overall Code Complexity
This module has a mean cyclomatic complexity of 4.22 across 18 functions. The mean complexity threshold is 4

Suppress

Comment on lines +176 to +214
private static DiscoveredEntry? TryClassifyInterface(
INamedTypeSymbol type,
INamedTypeSymbol iface,
MarkerSymbols markers,
ref bool seenTransform,
ref bool unsupportedGenericMapperOrTransform)
{
if (Same(iface, markers.MessageTransform) || Same(iface, markers.MessageTransformAsync))
{
if (type.IsGenericType)
unsupportedGenericMapperOrTransform = true;
else
seenTransform = true;
return null;
}

if (!iface.IsGenericType || iface.TypeArguments.Length != 1)
return null;

var def = iface.OriginalDefinition;
var requestType = iface.TypeArguments[0];

if (Same(def, markers.HandleRequests))
return MakeHandlerEntry(DiscoveredKind.SyncHandler, type, requestType);
if (Same(def, markers.HandleRequestsAsync))
return MakeHandlerEntry(DiscoveredKind.AsyncHandler, type, requestType);
if (Same(def, markers.MessageMapper))
{
if (type.IsGenericType) { unsupportedGenericMapperOrTransform = true; return null; }
return new DiscoveredEntry(DiscoveredKind.Mapper, FullyQualified(requestType), FullyQualified(type), IsOpenGeneric: false);
}
if (Same(def, markers.MessageMapperAsync))
{
if (type.IsGenericType) { unsupportedGenericMapperOrTransform = true; return null; }
return new DiscoveredEntry(DiscoveredKind.AsyncMapper, FullyQualified(requestType), FullyQualified(type), IsOpenGeneric: false);
}

return null;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ New issue: Excess Number of Function Arguments
TryClassifyInterface has 5 arguments, max arguments = 4

Suppress

@claude
Copy link
Copy Markdown

claude Bot commented May 18, 2026

Code Review: Source generator for Brighter handler/mapper/transform registration

Solid contribution overall — the architecture is exactly what you want from an incremental generator (Roslyn types confined to SemanticModelReader, pure-data records flow through the pipeline, RegistrationWriter is unit-testable without a Compilation). The verified incremental caching tests via IncrementalStepRunReason are a nice touch and rare to see. Below are the issues worth addressing before this leaves draft.

Correctness / behavioural concerns

  1. Ambiguous-method risk with AddFromThisAssembly — both the auto-generated BrighterAssemblyRegistrations.AddFromThisAssembly(this IBrighterBuilder) (in Paramore.Brighter.Extensions.DependencyInjection) and a user-declared [BrighterRegistrations]-attributed extension method with the same conventional name can co-exist. If a consumer has both namespaces in scope, builder.AddFromThisAssembly() becomes ambiguous and won't compile. Worth either: (a) detecting the user-defined method during auto-pipeline gating and skipping auto-class emit, or (b) calling this out in the post-init attribute XML doc so the failure mode is discoverable.

  2. Open-generic handler cast can throw InvalidCastException at runtimeRegistrationWriter.WriteHandlers (RegistrationWriter.cs:135) emits an unconditional cast (global::Paramore.Brighter.Extensions.DependencyInjection.ServiceCollectionSubscriberRegistry)r;. The newly added BrighterBuilderExtensions.Transforms explicitly handles the "not the concrete builder" case with a clear InvalidOperationException. Consider doing the same here — emit if (r is not ServiceCollectionSubscriberRegistry registry) throw new InvalidOperationException(...) so misuse fails with a useful message rather than a bare InvalidCastException.

  3. AutoFromAssemblies is not a drop-in replacement. The PR description (and the sample swap in samples/CommandProcessor/HelloWorld/Program.cs) reads as if AddFromThisAssembly() and AutoFromAssemblies() are equivalents, but they aren't: AutoFromAssemblies accepts/scans referenced assemblies, while the generator only sees the current compilation. Users with multi-project handler organisations who migrate will silently lose registrations from referenced libraries. Worth documenting prominently — and consider whether the per-assembly approach (one [BrighterRegistrations] method per assembly) is the migration pattern you want to recommend.

  4. AccessibilityModifier silently falls back to "internal" for Accessibility.NotApplicable and any other unhandled values (SemanticModelReader.cs:298). For a generator that emits source the user will read, a hidden default could be confusing if a method legitimately has unusual visibility. Either throw on unknown accessibilities (the validator should have caught it earlier) or fall back to private to fail more loudly.

  5. IsClassifiable filters out abstract classes but not types containing abstract handler logic that gets inherited. AllInterfaces on a concrete subclass picks up IHandleRequests<T> correctly, so this is right — but AbstractAndPrivateNestedHandlers_AreFilteredOut is the only test that exercises this. Worth a positive test where Concrete : AbstractBase<Cmd> is registered while the abstract base is not, just to lock the behaviour in.

Code quality

  1. MarkerSymbols.cs uses private set mutability for what are effectively init-only properties. Since you already polyfill IsExternalInit, switching to { get; init; } would make the intent clearer (and prevent accidental re-assignment within the assembly).

  2. MarkerSymbols.Resolve is invoked per syntax-node in the discovery transform (SemanticModelReader.cs:88 inside ReadClass, called for every class with a base list). Each call does ~8 GetTypeByMetadataName lookups. It's fast but not free, and runs on every ReadClass invocation across every incremental edit. Compilation.GetTypeByMetadataName is internally cached, so this is probably acceptable, but worth profiling on a large solution before declaring victory.

  3. Diagnostics and MarkerSymbols are public in a source-generator assembly that has IsPackable=false. They're only externally consumed by the test project. internal + InternalsVisibleTo("Paramore.Brighter.SourceGenerators.Tests") would be more honest about the surface area, but public is a defensible shortcut.

  4. IsExternalInit.cs is missing the MIT licence header that every other file in the PR carries. Easy fix.

  5. DescriptorFor switch (BrighterRegistrationsGenerator.cs:213) throws on unknown IDs, which is right, but the throw will manifest as a generator crash with no source line attribution. Since the IDs are constants under the generator's control, consider a Debug.Assert instead — or keep the throw and accept that any unknown id is genuinely a programming error.

Tests

  1. Coverage is good — end-to-end Roslyn testing, pure writer unit tests, incremental caching via IncrementalStepRunReason, and all four validation diagnostics. Two gaps worth filling:

    • Open-generic handler emit is tested in RegistrationWriterTests.OpenGenericHandler_EmitsEnsureHandlerIsRegistered but only at the writer level. No end-to-end test drives an actual public class PolicyHandler<T> : RequestHandler<T> where T : class, IRequest through the full generator. Worth adding so the IsOpenGeneric/UnboundGenericName logic in SemanticModelReader is covered by an end-to-end test.
    • No test for a class implementing both IAmAMessageTransform and IAmAMessageTransformAsync — the reader code in ClassifyEntries adds a single Transform entry in that case, but the behaviour isn't pinned.
  2. The diagnostic span tests (BrighterRegistrationsGeneratorTests.NonStaticMethod_ReportsBRGEN002 etc.) use absolute column positions like .WithSpan(9, 37, 9, 56). These are brittle to source edits but standard for analyzer testing. Just be aware.

Security

No notable concerns. The generator reads only the user's compilation; emitted code uses fully-qualified global:: prefixes and typeof(...) references with no string interpolation of user data into executable forms.

Performance

The incremental pipeline is well-structured. The discoveryBatches.Collect().Select(FlattenAndSort) step is the one place where the entire discovery set is re-sorted on any change, which is unavoidable for stable emit order. Once the array equality check downstream sees no change, the source-output is cached. The Where(static batch => !batch.IsEmpty) filter before Collect() is the right defensive move to keep unrelated class additions from flapping the discovery vector.

Nitpicks

  • The DiagnosticInfo record has a LocationInfo? Location property that shadows the Roslyn Location type when reading the code — Diagnostic.Create(... info.Location?.ToLocation() ...). Renaming the property to LocationInfo or Site would make the conversion read more naturally.
  • The post-init source declares both BrighterRegistrationsAttribute and ExcludeFromBrighterRegistrationAttribute as internal sealed. That's correct, but every consumer assembly will now get its own copy. With multiple Brighter-using assemblies in one process this is harmless (different identities, no runtime conflict), but worth noting in case future work wants to share them via the core Paramore.Brighter assembly.

Nice work overall. The "verified incrementality" via IncrementalStepRunReason and the clean reader/writer split are the kind of details I'd love to see more often in generator PRs.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant