From d1d11f2545b246c8eb898af617f4661ba49396c0 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 21 Aug 2026 13:27:05 +0200 Subject: [PATCH 1/3] fix(analyzers): EVTC001 false positive on object payloads, harden type lookups Fixes three defects in the EVTC001 event usage analyzer: - System.Object is no longer treated as a concrete event type, so runtime-resolved payloads (StreamEvent.Payload, IMessageConsumeContext.Message) passed to State.When() no longer trigger a false positive (#535) - The functional service builder type lookups used metadata names without the arity suffix (CommandHandlerBuilder vs CommandHandlerBuilder`2), so they never resolved and symbol comparison silently fell back to string matching; call sites also see constructed generics, so the comparison now uses OriginalDefinition - Act/ActAsync lambda arguments surface as IDelegateCreationOperation, which the analyzer did not match, so handler bodies were never analyzed and unannotated events created in ActAsync handlers were missed All string-name fallbacks are deleted. Metadata names now live in WellKnownTypeNames as the single source of truth, resolved through GetTypesByMetadataName to tolerate ambiguous references, and pinned by tests that resolve every name against the real Eventuous assemblies so a type rename fails CI in the same change. Closes #535 Co-Authored-By: Claude Fable 5 --- .../Eventuous.Shared.Generators/Constants.cs | 8 +- .../EventUsageAnalyzer.cs | 186 +++++++----------- .../WellKnownTypeNames.cs | 37 ++++ .../Analyzed.cs | 44 +++++ .../Analyzer_Ev001_Tests.cs | 59 +++++- 5 files changed, 210 insertions(+), 124 deletions(-) create mode 100644 src/Core/gen/Eventuous.Shared.Generators/WellKnownTypeNames.cs diff --git a/src/Core/gen/Eventuous.Shared.Generators/Constants.cs b/src/Core/gen/Eventuous.Shared.Generators/Constants.cs index 2c8db1577..57be39663 100644 --- a/src/Core/gen/Eventuous.Shared.Generators/Constants.cs +++ b/src/Core/gen/Eventuous.Shared.Generators/Constants.cs @@ -4,11 +4,9 @@ namespace Eventuous.Shared.Generators; /// -/// Constants used for type and member lookups. -/// These are primarily used for symbol resolution via Compilation.GetTypeByMetadataName() -/// and as fallback when symbol-based comparison is not available. -/// The generators now prefer symbol-based comparisons using SymbolEqualityComparer, -/// which are refactoring-safe and won't break when types are renamed. +/// Constants used for type and member lookups via Compilation.GetTypeByMetadataName(). +/// The full list of metadata names the analyzers depend on lives in , +/// which is pinned by tests against the real Eventuous assemblies. /// internal static class Constants { /// Base namespace for Eventuous types. diff --git a/src/Core/gen/Eventuous.Shared.Generators/EventUsageAnalyzer.cs b/src/Core/gen/Eventuous.Shared.Generators/EventUsageAnalyzer.cs index 069f17d4a..ae49b369b 100644 --- a/src/Core/gen/Eventuous.Shared.Generators/EventUsageAnalyzer.cs +++ b/src/Core/gen/Eventuous.Shared.Generators/EventUsageAnalyzer.cs @@ -6,7 +6,6 @@ using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Operations; using Microsoft.CodeAnalysis.CSharp.Syntax; -using static Eventuous.Shared.Generators.Constants; // ReSharper disable CognitiveComplexity @@ -44,23 +43,46 @@ public override void Initialize(AnalysisContext context) { /// /// Cache of well-known type symbols resolved from the compilation. - /// This makes the analyzer refactoring-safe by using symbol comparison instead of string matching. + /// Symbol comparison against these is the only matching mechanism; if a symbol doesn't resolve, + /// the corresponding check simply doesn't apply. The metadata names in + /// are pinned by tests against the real Eventuous assemblies. /// sealed class KnownTypeSymbols(Compilation compilation) { - public INamedTypeSymbol? EventTypeAttribute { get; } = compilation.GetTypeByMetadataName(EventTypeAttrFqcn); - public INamedTypeSymbol? TypeMapper { get; } = compilation.GetTypeByMetadataName($"{BaseNamespace}.TypeMapper"); - public INamedTypeSymbol? Aggregate { get; } = compilation.GetTypeByMetadataName($"{BaseNamespace}.Aggregate`1"); - public INamedTypeSymbol? State { get; } = compilation.GetTypeByMetadataName($"{BaseNamespace}.State`1"); - public INamedTypeSymbol? CommandHandlerBuilder { get; } = compilation.GetTypeByMetadataName($"{BaseNamespace}.CommandHandlerBuilder"); - public INamedTypeSymbol? IDefineExecution { get; } = compilation.GetTypeByMetadataName($"{BaseNamespace}.IDefineExecution"); - public INamedTypeSymbol? ICommandHandlerBuilder { get; } = compilation.GetTypeByMetadataName($"{BaseNamespace}.ICommandHandlerBuilder"); - public INamedTypeSymbol? IDefineStoreOrExecution { get; } = compilation.GetTypeByMetadataName($"{BaseNamespace}.IDefineStoreOrExecution"); - public INamedTypeSymbol? BaseEventHandler { get; } = compilation.GetTypeByMetadataName("Eventuous.Subscriptions.BaseEventHandler"); + public INamedTypeSymbol? EventTypeAttribute { get; } = GetBestTypeByMetadataName(compilation, WellKnownTypeNames.EventTypeAttribute); + public INamedTypeSymbol? TypeMapper { get; } = GetBestTypeByMetadataName(compilation, WellKnownTypeNames.TypeMapper); + public INamedTypeSymbol? Aggregate { get; } = GetBestTypeByMetadataName(compilation, WellKnownTypeNames.Aggregate); + public INamedTypeSymbol? State { get; } = GetBestTypeByMetadataName(compilation, WellKnownTypeNames.State); + public INamedTypeSymbol? CommandHandlerBuilder { get; } = GetBestTypeByMetadataName(compilation, WellKnownTypeNames.CommandHandlerBuilder); + public INamedTypeSymbol? IDefineExecution { get; } = GetBestTypeByMetadataName(compilation, WellKnownTypeNames.IDefineExecution); + public INamedTypeSymbol? ICommandHandlerBuilder { get; } = GetBestTypeByMetadataName(compilation, WellKnownTypeNames.ICommandHandlerBuilder); + public INamedTypeSymbol? IDefineStoreOrExecution { get; } = GetBestTypeByMetadataName(compilation, WellKnownTypeNames.IDefineStoreOrExecution); + public INamedTypeSymbol? BaseEventHandler { get; } = GetBestTypeByMetadataName(compilation, WellKnownTypeNames.BaseEventHandler); + + // GetTypeByMetadataName returns null not only when the type is missing but also when more than + // one referenced assembly defines it; in the ambiguous case pick the single accessible candidate + static INamedTypeSymbol? GetBestTypeByMetadataName(Compilation compilation, string metadataName) { + var type = compilation.GetTypeByMetadataName(metadataName); + + if (type != null) return type; + + INamedTypeSymbol? best = null; + + foreach (var candidate in compilation.GetTypesByMetadataName(metadataName)) { + if (candidate.DeclaredAccessibility != Accessibility.Public + && !SymbolEqualityComparer.Default.Equals(candidate.ContainingAssembly, compilation.Assembly)) continue; + + if (best != null) return null; + + best = candidate; + } + + return best; + } } static ImmutableHashSet GetExplicitRegistrations(OperationAnalysisContext ctx, KnownTypeSymbols knownTypes) { var model = ctx.Operation.SemanticModel; - if (model == null) return ImmutableHashSet.Empty; + if (model == null || knownTypes.TypeMapper == null) return ImmutableHashSet.Empty; var root = ctx.Operation.Syntax.SyntaxTree.GetRoot(); var set = ImmutableHashSet.CreateBuilder(SymbolEqualityComparer.Default); @@ -68,17 +90,8 @@ static ImmutableHashSet GetExplicitRegistrations(OperationAnalysisC if (model.GetOperation(invSyntax) is not IInvocationOperation op) continue; var m = op.TargetMethod; - // Use symbol comparison when available, fall back to string comparison if (m.Name != "AddType") continue; - var ct = m.ContainingType; - if (ct == null) continue; - - // Prefer symbol comparison (refactoring-safe) - var isTypeMapper = knownTypes.TypeMapper != null - ? SymbolEqualityComparer.Default.Equals(ct, knownTypes.TypeMapper) - : ct.Name == "TypeMapper" && ct.ContainingNamespace?.ToDisplayString() == BaseNamespace; - - if (!isTypeMapper) continue; + if (!SymbolEqualityComparer.Default.Equals(m.ContainingType, knownTypes.TypeMapper)) continue; if (m.TypeArguments.Length == 1) { set.Add(m.TypeArguments[0]); @@ -159,20 +172,18 @@ static void AnalyzeInvocation(OperationAnalysisContext ctx, KnownTypeSymbols kno // Heuristic: only consider the overloads that accept a delegate and are defined in CommandHandlerBuilder interfaces/classes if (!IsFunctionalServiceAct(method, knownTypes)) return; + // If the argument is a lambda, analyze its body for created event instances. + // Lambdas passed as delegate arguments surface as IDelegateCreationOperation, possibly wrapped in a conversion. foreach (var value in inv.Arguments.Select(arg => arg.Value)) { - switch (value) { - case null: - continue; - // If the argument is a lambda, analyze its body for created event instances - case IAnonymousFunctionOperation lambda: - AnalyzeDelegateBodyForEventCreations(ctx, lambda.Body, knownTypes); - - break; - case IConversionOperation { Operand: IAnonymousFunctionOperation lambdaConv }: - AnalyzeDelegateBodyForEventCreations(ctx, lambdaConv.Body, knownTypes); - - break; - } + var lambda = value switch { + IAnonymousFunctionOperation anon => anon, + IDelegateCreationOperation { Target: IAnonymousFunctionOperation anon } => anon, + IConversionOperation { Operand: IAnonymousFunctionOperation anon } => anon, + IConversionOperation { Operand: IDelegateCreationOperation { Target: IAnonymousFunctionOperation anon } } => anon, + _ => null + }; + + if (lambda != null) AnalyzeDelegateBodyForEventCreations(ctx, lambda.Body, knownTypes); } } } @@ -248,105 +259,44 @@ static bool IsIEnumerableOfObject(INamedTypeSymbol type) { return false; } - static bool IsAggregate(INamedTypeSymbol? type, KnownTypeSymbols knownTypes) { - if (type == null) return false; + // Walk base types to check if the type derives from Eventuous.Aggregate<> + static bool IsAggregate(INamedTypeSymbol? type, KnownTypeSymbols knownTypes) => DerivesFrom(type, knownTypes.Aggregate); - // Walk base types to check if it derives from Eventuous.Aggregate<> - for (var t = type; t != null; t = t.BaseType) { - // Prefer symbol comparison (refactoring-safe) - if (knownTypes.Aggregate != null) { - if (SymbolEqualityComparer.Default.Equals(t.OriginalDefinition, knownTypes.Aggregate)) { - return true; - } - } - else { - // Fallback to string comparison - if (t is { Name: "Aggregate", Arity: 1 } && t.ContainingNamespace.ToDisplayString() == BaseNamespace) { - return true; - } - } - } + // Walk base types to check if the type derives from Eventuous.State<> + static bool IsState(INamedTypeSymbol? type, KnownTypeSymbols knownTypes) => DerivesFrom(type, knownTypes.State); - return false; - } + static bool IsEventHandler(INamedTypeSymbol? type, KnownTypeSymbols knownTypes) => DerivesFrom(type, knownTypes.BaseEventHandler); - static bool IsState(INamedTypeSymbol? type, KnownTypeSymbols knownTypes) { - if (type == null) return false; + static bool DerivesFrom(INamedTypeSymbol? type, INamedTypeSymbol? baseDefinition) { + if (baseDefinition == null) return false; - // Walk base types to check if it derives from Eventuous.State<> for (var t = type; t != null; t = t.BaseType) { - // Prefer symbol comparison (refactoring-safe) - if (knownTypes.State != null) { - if (SymbolEqualityComparer.Default.Equals(t.OriginalDefinition, knownTypes.State)) { - return true; - } - } - else { - // Fallback to string comparison - if (t is { Name: "State", Arity: 1 } && t.ContainingNamespace.ToDisplayString() == BaseNamespace) { - return true; - } - } - } - - return false; - } - - static bool IsEventHandler(INamedTypeSymbol? type, KnownTypeSymbols knownTypes) { - if (type == null) return false; - - for (var t = type; t != null; t = t.BaseType) { - if (knownTypes.BaseEventHandler != null) { - if (SymbolEqualityComparer.Default.Equals(t.OriginalDefinition, knownTypes.BaseEventHandler)) { - return true; - } - } - else { - if (t is { Name: "BaseEventHandler", Arity: 0 } && t.ContainingNamespace?.ToDisplayString() == "Eventuous.Subscriptions") { - return true; - } - } + if (SymbolEqualityComparer.Default.Equals(t.OriginalDefinition, baseDefinition)) return true; } return false; } static bool IsFunctionalServiceAct(IMethodSymbol method, KnownTypeSymbols knownTypes) { - // We only care about the Act methods from CommandHandlerBuilder and the related interfaces in Eventuous namespace + // We only care about the Act methods from CommandHandlerBuilder and the related interfaces in Eventuous namespace. + // The containing type at the call site is a constructed generic, so compare its original definition. if (method.Name is not ("Act" or "ActAsync")) return false; - var containing = method.ContainingType; - - if (containing == null) return false; + var definition = method.ContainingType?.OriginalDefinition; - // Prefer symbol comparison (refactoring-safe) - if (knownTypes.CommandHandlerBuilder != null || knownTypes.IDefineExecution != null || - knownTypes.ICommandHandlerBuilder != null || knownTypes.IDefineStoreOrExecution != null) { - return SymbolEqualityComparer.Default.Equals(containing, knownTypes.CommandHandlerBuilder) || - SymbolEqualityComparer.Default.Equals(containing, knownTypes.IDefineExecution) || - SymbolEqualityComparer.Default.Equals(containing, knownTypes.ICommandHandlerBuilder) || - SymbolEqualityComparer.Default.Equals(containing, knownTypes.IDefineStoreOrExecution); - } - - // Fallback to string comparison - var ns = containing.ContainingNamespace?.ToDisplayString(); - if (ns != BaseNamespace) return false; + if (definition == null) return false; - return containing.Name is "CommandHandlerBuilder" or "IDefineExecution" or "ICommandHandlerBuilder" or "IDefineStoreOrExecution"; + return SymbolEqualityComparer.Default.Equals(definition, knownTypes.CommandHandlerBuilder) + || SymbolEqualityComparer.Default.Equals(definition, knownTypes.IDefineExecution) + || SymbolEqualityComparer.Default.Equals(definition, knownTypes.ICommandHandlerBuilder) + || SymbolEqualityComparer.Default.Equals(definition, knownTypes.IDefineStoreOrExecution); } - static bool IsConcreteEvent(ITypeSymbol type) => type.TypeKind is TypeKind.Class or TypeKind.Struct; + // System.Object is excluded: an object-typed value (e.g. StreamEvent.Payload or IMessageConsumeContext.Message) + // carries a runtime-resolved event type, so there is nothing to annotate at the call site + static bool IsConcreteEvent(ITypeSymbol type) => type.SpecialType is not SpecialType.System_Object && type.TypeKind is TypeKind.Class or TypeKind.Struct; - static bool HasEventTypeAttribute(ITypeSymbol type, KnownTypeSymbols knownTypes) { - // Prefer symbol comparison (refactoring-safe) - if (knownTypes.EventTypeAttribute != null) { - return type.GetAttributes().Any(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, knownTypes.EventTypeAttribute)); - } - - // Fallback to string comparison - return (from attrClass in type.GetAttributes().Select(a => a.AttributeClass).OfType() - let name = attrClass.ToDisplayString() - where name == EventTypeAttrFqcn || attrClass.Name is EventTypeAttribute - select attrClass).Any(); - } + static bool HasEventTypeAttribute(ITypeSymbol type, KnownTypeSymbols knownTypes) + => knownTypes.EventTypeAttribute != null + && type.GetAttributes().Any(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, knownTypes.EventTypeAttribute)); } diff --git a/src/Core/gen/Eventuous.Shared.Generators/WellKnownTypeNames.cs b/src/Core/gen/Eventuous.Shared.Generators/WellKnownTypeNames.cs new file mode 100644 index 000000000..83b80e058 --- /dev/null +++ b/src/Core/gen/Eventuous.Shared.Generators/WellKnownTypeNames.cs @@ -0,0 +1,37 @@ +// Copyright (C) Eventuous HQ OÜ. All rights reserved +// Licensed under the Apache License, Version 2.0. + +using System.Collections.Immutable; + +namespace Eventuous.Shared.Generators; + +/// +/// Fully qualified metadata names of the Eventuous types the analyzers resolve via +/// . +/// The analyzer cannot reference the runtime assemblies (it targets netstandard2.0, they don't), +/// so these strings are the single source of truth. Every name is pinned by a test that resolves +/// it against the current Eventuous assemblies, so renaming a type fails CI in the same change. +/// +public static class WellKnownTypeNames { + public const string EventTypeAttribute = Constants.EventTypeAttrFqcn; + public const string TypeMapper = $"{Constants.BaseNamespace}.TypeMapper"; + public const string Aggregate = $"{Constants.BaseNamespace}.Aggregate`1"; + public const string State = $"{Constants.BaseNamespace}.State`1"; + public const string CommandHandlerBuilder = $"{Constants.BaseNamespace}.CommandHandlerBuilder`2"; + public const string IDefineExecution = $"{Constants.BaseNamespace}.IDefineExecution`2"; + public const string ICommandHandlerBuilder = $"{Constants.BaseNamespace}.ICommandHandlerBuilder`2"; + public const string IDefineStoreOrExecution = $"{Constants.BaseNamespace}.IDefineStoreOrExecution`2"; + public const string BaseEventHandler = "Eventuous.Subscriptions.BaseEventHandler"; + + public static readonly ImmutableArray All = [ + EventTypeAttribute, + TypeMapper, + Aggregate, + State, + CommandHandlerBuilder, + IDefineExecution, + ICommandHandlerBuilder, + IDefineStoreOrExecution, + BaseEventHandler + ]; +} diff --git a/src/Core/test/Eventuous.Tests.Shared.Analyzers/Analyzed.cs b/src/Core/test/Eventuous.Tests.Shared.Analyzers/Analyzed.cs index 70ace3618..51f4652a6 100644 --- a/src/Core/test/Eventuous.Tests.Shared.Analyzers/Analyzed.cs +++ b/src/Core/test/Eventuous.Tests.Shared.Analyzers/Analyzed.cs @@ -1,6 +1,9 @@ // Copyright (C) Eventuous HQ OÜ. All rights reserved // Licensed under the Apache License, Version 2.0. +using System; +using System.Collections.Generic; +using System.Threading.Tasks; using JetBrains.Annotations; namespace Eventuous.Tests.Shared.Analyzers; @@ -8,6 +11,7 @@ namespace Eventuous.Tests.Shared.Analyzers; file record TestState : State { public TestState() { On((state, _) => state); + On((state, _) => state); } } @@ -24,7 +28,47 @@ public TestEventHandler() { public void Process() => Apply(new Events.RoomBooked("1", DateTime.Now, DateTime.Now.AddDays(1), 100)); } +[UsedImplicitly] +file class TestFunctionalService : CommandService { + public TestFunctionalService(IEventStore store) : base(store) { + On() + .InState(ExpectedState.Existing) + .GetStream(cmd => new StreamName($"Booking-{cmd.BookingId}")) + .ActAsync((state, events, cmd, ct) => Task.FromResult>(new object[] { new Events.BookingCancelled(cmd.BookingId) })); + } +} + +file record CancelBooking(string BookingId); + +file static class TypeRegistration { + // Events.RoomRegistered has no [EventType] but is registered explicitly, so it must not be flagged + [UsedImplicitly] + public static void Register() => TypeMap.Instance.AddType("V1.RoomRegistered"); +} + +[UsedImplicitly] +file class StateReplay { + // Event replay pattern from issue #535: payloads are deserialized by the type map, + // so they are statically typed as object at the call site and must not trigger EVTC001 + [UsedImplicitly] + public TestState Replay(object?[] payloads) { + var state = new TestState(); + + foreach (var payload in payloads) { + if (payload != null) state = state.When(payload); + } + + return state; + } +} + file static class Events { [PublicAPI] public record RoomBooked(string RoomId, DateTime CheckIn, DateTime CheckOut, decimal Price); + + [PublicAPI] + public record BookingCancelled(string BookingId); + + [PublicAPI] + public record RoomRegistered(string RoomId); } diff --git a/src/Core/test/Eventuous.Tests.Shared.Analyzers/Analyzer_Ev001_Tests.cs b/src/Core/test/Eventuous.Tests.Shared.Analyzers/Analyzer_Ev001_Tests.cs index 7ada3bdc1..381cbab48 100644 --- a/src/Core/test/Eventuous.Tests.Shared.Analyzers/Analyzer_Ev001_Tests.cs +++ b/src/Core/test/Eventuous.Tests.Shared.Analyzers/Analyzer_Ev001_Tests.cs @@ -33,6 +33,61 @@ public async Task Should_warn_for_unannotated_events_in_state_and_aggregate() { await Assert.That(ev001.Any(d => d.GetMessage().Contains("RoomBooked"))).IsTrue(); } + [Test] + public async Task Should_not_warn_for_object_typed_payload_passed_to_when() { + // Issue #535: replaying deserialized payloads (statically typed as object) through + // State.When() must not produce a false positive for 'object' + var source = LoadAnalyzedSource(); + + var compilation = CreateCompilation(source); + var analyzer = new EventUsageAnalyzer(); + + var diagnostics = await GetAnalyzerDiagnosticsAsync(compilation, analyzer); + + var objectDiagnostics = diagnostics.Where(d => d.GetMessage().Contains("'object'")).ToArray(); + + await Assert.That(objectDiagnostics.Length).IsEqualTo(0); + } + + [Test] + public async Task Should_warn_for_unannotated_event_created_in_functional_act() { + // Pins the functional-service Act/ActAsync path: if the builder type lookups go stale, + // this diagnostic disappears and the test fails + var source = LoadAnalyzedSource(); + + var compilation = CreateCompilation(source); + var analyzer = new EventUsageAnalyzer(); + + var diagnostics = await GetAnalyzerDiagnosticsAsync(compilation, analyzer); + + await Assert.That(diagnostics.Any(d => d.GetMessage().Contains("BookingCancelled"))).IsTrue(); + } + + [Test] + public async Task Should_not_warn_for_event_registered_via_type_map() { + // Pins the TypeMapper.AddType suppression: if the TypeMapper lookup goes stale, + // an unexpected diagnostic appears and the test fails + var source = LoadAnalyzedSource(); + + var compilation = CreateCompilation(source); + var analyzer = new EventUsageAnalyzer(); + + var diagnostics = await GetAnalyzerDiagnosticsAsync(compilation, analyzer); + + await Assert.That(diagnostics.Any(d => d.GetMessage().Contains("RoomRegistered"))).IsFalse(); + } + + [Test] + public async Task Should_resolve_all_well_known_type_names() { + // Every metadata name the analyzer relies on must resolve against the current assemblies; + // a type rename that misses the analyzer fails here + var compilation = CreateCompilation("// intentionally empty"); + + var unresolved = WellKnownTypeNames.All.Where(name => compilation.GetTypeByMetadataName(name) == null).ToArray(); + + await Assert.That(unresolved).IsEmpty(); + } + static async Task GetAnalyzerDiagnosticsAsync(Compilation compilation, EventUsageAnalyzer analyzer) { var withAnalyzers = compilation.WithAnalyzers([analyzer]); var diagnostics = await withAnalyzers.GetAnalyzerDiagnosticsAsync().ConfigureAwait(false); @@ -55,7 +110,9 @@ static CSharpCompilation CreateCompilation(string source) { MetadataReference.CreateFromFile(typeof(State<>).Assembly.Location), MetadataReference.CreateFromFile(typeof(Aggregate<>).Assembly.Location), MetadataReference.CreateFromFile(typeof(EventTypeAttribute).Assembly.Location), - MetadataReference.CreateFromFile(typeof(Eventuous.Subscriptions.EventHandler).Assembly.Location) + MetadataReference.CreateFromFile(typeof(Eventuous.Subscriptions.EventHandler).Assembly.Location), + MetadataReference.CreateFromFile(typeof(ExpectedState).Assembly.Location), + MetadataReference.CreateFromFile(typeof(IEventStore).Assembly.Location) }; // Add runtime assemblies to resolve core types (DateTime, ValueTask, etc.) From 88c22b8fdfdbcd1fd8ca0d42d92e63b659062931 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 21 Aug 2026 13:37:53 +0200 Subject: [PATCH 2/3] fix(analyzers): skip helper allocations and duplicate reports in Act handlers Addresses review findings on the Act/ActAsync analysis: - Types in the System namespace (List, DateTime, ...) are never domain events, so creations of those inside handlers are not flagged - Creations inside lambdas passed to Act/ActAsync are reported only by the invocation traversal; the object-creation safety net skips them, so a sync handler no longer produces two identical diagnostics for the same creation Co-Authored-By: Claude Fable 5 --- .../EventUsageAnalyzer.cs | 28 +++++++++++++++++-- .../Analyzed.cs | 17 +++++++++++ .../Analyzer_Ev001_Tests.cs | 26 +++++++++++++++++ 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/Core/gen/Eventuous.Shared.Generators/EventUsageAnalyzer.cs b/src/Core/gen/Eventuous.Shared.Generators/EventUsageAnalyzer.cs index ae49b369b..31c21e274 100644 --- a/src/Core/gen/Eventuous.Shared.Generators/EventUsageAnalyzer.cs +++ b/src/Core/gen/Eventuous.Shared.Generators/EventUsageAnalyzer.cs @@ -215,13 +215,22 @@ static void AnalyzeObjectCreation(OperationAnalysisContext ctx, KnownTypeSymbols if (method == null) return; - if (ReturnsNewEvents(method)) { + // Creations inside lambdas passed to Act/ActAsync are reported by the invocation traversal; skip them here + if (ReturnsNewEvents(method) && !IsWithinFunctionalActInvocation(create, knownTypes)) { if (!HasEventTypeAttribute(created, knownTypes) && !IsExplicitlyRegistered(created, ctx, knownTypes)) { ctx.ReportDiagnostic(Diagnostic.Create(MissingEventTypeAttribute, create.Syntax.GetLocation(), created.ToDisplayString())); } } } + static bool IsWithinFunctionalActInvocation(IOperation op, KnownTypeSymbols knownTypes) { + for (var p = op.Parent; p != null; p = p.Parent) { + if (p is IInvocationOperation inv && IsFunctionalServiceAct(inv.TargetMethod, knownTypes)) return true; + } + + return false; + } + static IMethodSymbol? GetEnclosingMethod(IOperation op) { for (var p = op.Parent; p != null; p = p.Parent) { switch (p) { @@ -293,8 +302,21 @@ static bool IsFunctionalServiceAct(IMethodSymbol method, KnownTypeSymbols knownT } // System.Object is excluded: an object-typed value (e.g. StreamEvent.Payload or IMessageConsumeContext.Message) - // carries a runtime-resolved event type, so there is nothing to annotate at the call site - static bool IsConcreteEvent(ITypeSymbol type) => type.SpecialType is not SpecialType.System_Object && type.TypeKind is TypeKind.Class or TypeKind.Struct; + // carries a runtime-resolved event type, so there is nothing to annotate at the call site. + // The System namespace is excluded as a whole: framework types constructed inside handlers + // (List, DateTime, ...) are never domain events. + static bool IsConcreteEvent(ITypeSymbol type) + => type.SpecialType is not SpecialType.System_Object + && type.TypeKind is TypeKind.Class or TypeKind.Struct + && !IsInSystemNamespace(type); + + static bool IsInSystemNamespace(ITypeSymbol type) { + for (var ns = type.ContainingNamespace; ns is { IsGlobalNamespace: false }; ns = ns.ContainingNamespace) { + if (ns.ContainingNamespace is { IsGlobalNamespace: true }) return ns.Name == "System"; + } + + return false; + } static bool HasEventTypeAttribute(ITypeSymbol type, KnownTypeSymbols knownTypes) => knownTypes.EventTypeAttribute != null diff --git a/src/Core/test/Eventuous.Tests.Shared.Analyzers/Analyzed.cs b/src/Core/test/Eventuous.Tests.Shared.Analyzers/Analyzed.cs index 51f4652a6..653c1b2ac 100644 --- a/src/Core/test/Eventuous.Tests.Shared.Analyzers/Analyzed.cs +++ b/src/Core/test/Eventuous.Tests.Shared.Analyzers/Analyzed.cs @@ -35,11 +35,25 @@ public TestFunctionalService(IEventStore store) : base(store) { .InState(ExpectedState.Existing) .GetStream(cmd => new StreamName($"Booking-{cmd.BookingId}")) .ActAsync((state, events, cmd, ct) => Task.FromResult>(new object[] { new Events.BookingCancelled(cmd.BookingId) })); + + // Sync handler with a helper allocation: List must not be flagged, + // and TableBooked must be reported exactly once (not by both analysis paths) + On() + .InState(ExpectedState.New) + .GetStream(cmd => new StreamName($"Table-{cmd.TableId}")) + .Act(cmd => { + var events = new List(); + events.Add(new Events.TableBooked(cmd.TableId)); + + return events; + }); } } file record CancelBooking(string BookingId); +file record BookTable(string TableId); + file static class TypeRegistration { // Events.RoomRegistered has no [EventType] but is registered explicitly, so it must not be flagged [UsedImplicitly] @@ -71,4 +85,7 @@ public record BookingCancelled(string BookingId); [PublicAPI] public record RoomRegistered(string RoomId); + + [PublicAPI] + public record TableBooked(string TableId); } diff --git a/src/Core/test/Eventuous.Tests.Shared.Analyzers/Analyzer_Ev001_Tests.cs b/src/Core/test/Eventuous.Tests.Shared.Analyzers/Analyzer_Ev001_Tests.cs index 381cbab48..3401f57f3 100644 --- a/src/Core/test/Eventuous.Tests.Shared.Analyzers/Analyzer_Ev001_Tests.cs +++ b/src/Core/test/Eventuous.Tests.Shared.Analyzers/Analyzer_Ev001_Tests.cs @@ -77,6 +77,32 @@ public async Task Should_not_warn_for_event_registered_via_type_map() { await Assert.That(diagnostics.Any(d => d.GetMessage().Contains("RoomRegistered"))).IsFalse(); } + [Test] + public async Task Should_not_warn_for_helper_allocations_in_act_handlers() { + // Infrastructure allocations inside Act handlers (e.g. List) are not domain events + var source = LoadAnalyzedSource(); + + var compilation = CreateCompilation(source); + var analyzer = new EventUsageAnalyzer(); + + var diagnostics = await GetAnalyzerDiagnosticsAsync(compilation, analyzer); + + await Assert.That(diagnostics.Any(d => d.GetMessage().Contains("List"))).IsFalse(); + } + + [Test] + public async Task Should_report_sync_act_event_exactly_once() { + // The Act invocation traversal and the object-creation safety net must not both report the same creation + var source = LoadAnalyzedSource(); + + var compilation = CreateCompilation(source); + var analyzer = new EventUsageAnalyzer(); + + var diagnostics = await GetAnalyzerDiagnosticsAsync(compilation, analyzer); + + await Assert.That(diagnostics.Count(d => d.GetMessage().Contains("TableBooked"))).IsEqualTo(1); + } + [Test] public async Task Should_resolve_all_well_known_type_names() { // Every metadata name the analyzer relies on must resolve against the current assemblies; From f28b6bf8490c3dee30069c26cd18a2614b7bdf29 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 21 Aug 2026 13:40:15 +0200 Subject: [PATCH 3/3] fix(analyzers): stay silent when EventType attribute or TypeMapper cannot resolve Without these two symbols neither annotations nor explicit registrations can be verified, so any EVTC001 diagnostic would be an unverifiable false positive. Skip registering the analysis actions entirely in that case. Co-Authored-By: Claude Fable 5 --- .../EventUsageAnalyzer.cs | 4 +++ .../Analyzer_Ev001_Tests.cs | 29 +++++++++++++++---- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/Core/gen/Eventuous.Shared.Generators/EventUsageAnalyzer.cs b/src/Core/gen/Eventuous.Shared.Generators/EventUsageAnalyzer.cs index 31c21e274..cdb7ae37a 100644 --- a/src/Core/gen/Eventuous.Shared.Generators/EventUsageAnalyzer.cs +++ b/src/Core/gen/Eventuous.Shared.Generators/EventUsageAnalyzer.cs @@ -36,6 +36,10 @@ public override void Initialize(AnalysisContext context) { var compilation = compilationContext.Compilation; var knownTypes = new KnownTypeSymbols(compilation); + // Without these two, annotations and explicit registrations cannot be checked, + // so any diagnostic would be an unverifiable false positive — stay silent instead + if (knownTypes.EventTypeAttribute == null || knownTypes.TypeMapper == null) return; + compilationContext.RegisterOperationAction(ctx => AnalyzeInvocation(ctx, knownTypes), OperationKind.Invocation); compilationContext.RegisterOperationAction(ctx => AnalyzeObjectCreation(ctx, knownTypes), OperationKind.ObjectCreation); }); diff --git a/src/Core/test/Eventuous.Tests.Shared.Analyzers/Analyzer_Ev001_Tests.cs b/src/Core/test/Eventuous.Tests.Shared.Analyzers/Analyzer_Ev001_Tests.cs index 3401f57f3..3c547b6d8 100644 --- a/src/Core/test/Eventuous.Tests.Shared.Analyzers/Analyzer_Ev001_Tests.cs +++ b/src/Core/test/Eventuous.Tests.Shared.Analyzers/Analyzer_Ev001_Tests.cs @@ -103,6 +103,20 @@ public async Task Should_report_sync_act_event_exactly_once() { await Assert.That(diagnostics.Count(d => d.GetMessage().Contains("TableBooked"))).IsEqualTo(1); } + [Test] + public async Task Should_stay_silent_when_event_type_attribute_cannot_be_resolved() { + // Without the assembly defining [EventType] and TypeMapper, neither annotations nor explicit + // registrations can be checked — the analyzer must stay silent rather than risk false positives + var source = LoadAnalyzedSource(); + + var compilation = CreateCompilation(source, includeSharedAssembly: false); + var analyzer = new EventUsageAnalyzer(); + + var diagnostics = await GetAnalyzerDiagnosticsAsync(compilation, analyzer); + + await Assert.That(diagnostics.Length).IsEqualTo(0); + } + [Test] public async Task Should_resolve_all_well_known_type_names() { // Every metadata name the analyzer relies on must resolve against the current assemblies; @@ -127,20 +141,23 @@ static string LoadAnalyzedSource([CallerFilePath] string? caller = null) { return File.ReadAllText(path); } - static CSharpCompilation CreateCompilation(string source) { + static CSharpCompilation CreateCompilation(string source, bool includeSharedAssembly = true) { var syntaxTree = CSharpSyntaxTree.ParseText(source, new(LanguageVersion.Preview)); var refs = new List { MetadataReference.CreateFromFile(typeof(object).GetTypeInfo().Assembly.Location), MetadataReference.CreateFromFile(typeof(Enumerable).GetTypeInfo().Assembly.Location), MetadataReference.CreateFromFile(typeof(State<>).Assembly.Location), - MetadataReference.CreateFromFile(typeof(Aggregate<>).Assembly.Location), - MetadataReference.CreateFromFile(typeof(EventTypeAttribute).Assembly.Location), - MetadataReference.CreateFromFile(typeof(Eventuous.Subscriptions.EventHandler).Assembly.Location), - MetadataReference.CreateFromFile(typeof(ExpectedState).Assembly.Location), - MetadataReference.CreateFromFile(typeof(IEventStore).Assembly.Location) + MetadataReference.CreateFromFile(typeof(Aggregate<>).Assembly.Location) }; + if (includeSharedAssembly) { + refs.Add(MetadataReference.CreateFromFile(typeof(EventTypeAttribute).Assembly.Location)); + refs.Add(MetadataReference.CreateFromFile(typeof(Eventuous.Subscriptions.EventHandler).Assembly.Location)); + refs.Add(MetadataReference.CreateFromFile(typeof(ExpectedState).Assembly.Location)); + refs.Add(MetadataReference.CreateFromFile(typeof(IEventStore).Assembly.Location)); + } + // Add runtime assemblies to resolve core types (DateTime, ValueTask, etc.) var runtimeDir = Path.GetDirectoryName(typeof(object).Assembly.Location)!; refs.AddRange(Directory.GetFiles(runtimeDir, "System.*.dll").Select(dll => MetadataReference.CreateFromFile(dll)).Cast());