From 3919a9c3c623452fd3c2f9a27a6dbe668240b879 Mon Sep 17 00:00:00 2001 From: Felix Winterhalter Date: Tue, 9 Jun 2026 10:51:09 +0200 Subject: [PATCH] fix(sourcegen): dedupe overridden required members in evolver construction BuildAggregateConstructorExpression walked the inheritance chain and collected every required member by name with no dedup, so an `override required` (or `new`-shadowed required) property was added once for the base declaration and once for the derived override. The emitted object initializer then read `new T { Prop = default!, Prop = default! }`, failing to compile with CS1912 (duplicate member initialization) and breaking the whole consuming assembly's build. Dedupe by name with a HashSet; the walk runs most-derived -> base so the first sighting wins. Closes #428 Co-Authored-By: Claude --- .../AggregateEvolverGeneratorTests.cs | 84 +++++++++++++++++++ .../EvolverCodeEmitter.cs | 13 ++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/src/JasperFx.Events.SourceGenerator.Tests/AggregateEvolverGeneratorTests.cs b/src/JasperFx.Events.SourceGenerator.Tests/AggregateEvolverGeneratorTests.cs index 9f2e110..543a45f 100644 --- a/src/JasperFx.Events.SourceGenerator.Tests/AggregateEvolverGeneratorTests.cs +++ b/src/JasperFx.Events.SourceGenerator.Tests/AggregateEvolverGeneratorTests.cs @@ -213,6 +213,90 @@ public void Register(Marten.Events.Projections.ProjectionOptions opts) allGenerated.ShouldNotContain("DiagnostiekActiviteit_StringEvolver"); } + [Fact] + public void required_member_overridden_from_base_is_not_initialized_twice() + { + // Regression: when an aggregate overrides a base `virtual required` property, + // the inheritance walk in BuildAggregateConstructorExpression collected the + // member once for the base declaration AND once for the override, emitting + // `new T { DisplayName = default!, DisplayName = default! }` -> CS1912 + // "Duplicate initialization of member 'DisplayName'". + var source = @" +using System; +using System.Threading; +using System.Threading.Tasks; +using JasperFx.Events; +using JasperFx.Events.Aggregation; +using JasperFx.Events.Daemon; +using JasperFx.Events.Projections; + +namespace Test +{ + +public class DisplayItemBase +{ + public virtual required string DisplayName { get; set; } +} + +public class InheritingDisplayItem : DisplayItemBase +{ + public string Id { get; set; } = """"; + public override required string DisplayName { get; set; } +} + +public class DisplayItemCreated +{ + public string Id { get; set; } = """"; + public string DisplayName { get; set; } = """"; +} + +public class DisplayItemTouched +{ + public string Id { get; set; } = """"; +} + +public class StubOperations : IStorageOperations +{ + public bool EnableSideEffectsOnInlineProjections => false; + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + public Task> FetchProjectionStorageAsync( + string tenantId, + CancellationToken cancellationToken) => throw new NotSupportedException(); + + public ValueTask GetOrStartMessageSink() => throw new NotSupportedException(); +} + +public abstract class SingleStreamProjection : JasperFxSingleStreamProjectionBase + where TDoc : notnull where TId : notnull +{ + protected SingleStreamProjection() { } +} + +public partial class InheritingDisplayItemProjection : SingleStreamProjection +{ + public static InheritingDisplayItem Create(DisplayItemCreated e) => new() { Id = e.Id, DisplayName = e.DisplayName }; + + // Apply-only event forces the generator's rebuild-from-null snapshot branch, + // which is where the duplicated initializer was emitted. + public void Apply(DisplayItemTouched e, InheritingDisplayItem a) { } +} +} +"; + var diagnostics = CompileWithGenerator(source); + + diagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => d.GetMessage()) + .ShouldBeEmpty(); + + var (_, generatedSources) = RunGenerator(source); + var allGenerated = string.Join("\n", generatedSources); + + allGenerated.ShouldNotContain("DisplayName = default!, DisplayName = default!"); + } + [Fact] public void generates_evolver_for_self_aggregating_type() { diff --git a/src/JasperFx.Events.SourceGenerator/EvolverCodeEmitter.cs b/src/JasperFx.Events.SourceGenerator/EvolverCodeEmitter.cs index 95f57fa..38608e5 100644 --- a/src/JasperFx.Events.SourceGenerator/EvolverCodeEmitter.cs +++ b/src/JasperFx.Events.SourceGenerator/EvolverCodeEmitter.cs @@ -734,6 +734,11 @@ private static string BuildAggregateConstructorExpression(INamedTypeSymbol type) { var fqn = Fqn(type); var requiredMembers = new List(); + // Dedup by name: an `override required` (or `new`-shadowed required) member + // appears in both the derived type's and the base type's GetMembers(), which + // would otherwise emit `new T { Prop = default!, Prop = default! }` -> CS1912. + // The walk runs most-derived -> base, so the first sighting wins. + var seen = new HashSet(StringComparer.Ordinal); for (INamedTypeSymbol? current = type; current is not null && current.SpecialType != SpecialType.System_Object; current = current.BaseType) @@ -741,9 +746,13 @@ private static string BuildAggregateConstructorExpression(INamedTypeSymbol type) foreach (var member in current.GetMembers()) { if (member is IPropertySymbol prop && prop.IsRequired) - requiredMembers.Add(prop.Name); + { + if (seen.Add(prop.Name)) requiredMembers.Add(prop.Name); + } else if (member is IFieldSymbol field && field.IsRequired) - requiredMembers.Add(field.Name); + { + if (seen.Add(field.Name)) requiredMembers.Add(field.Name); + } } }