diff --git a/src/Core/gen/Eventuous.Shared.Generators/Constants.cs b/src/Core/gen/Eventuous.Shared.Generators/Constants.cs index 2c8db157..57be3966 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 069f17d4..cdb7ae37 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 @@ -37,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); }); @@ -44,23 +47,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 +94,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 +176,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); } } } @@ -204,13 +219,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) { @@ -248,105 +272,57 @@ 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 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; - } - } - } - - return false; - } - - static bool IsState(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.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; - } - } - } + // 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 IsEventHandler(INamedTypeSymbol? type, KnownTypeSymbols knownTypes) { - if (type == null) return false; + static bool DerivesFrom(INamedTypeSymbol? type, INamedTypeSymbol? baseDefinition) { + if (baseDefinition == 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; + var definition = method.ContainingType?.OriginalDefinition; - if (containing == null) return false; + if (definition == null) return false; - // 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; - - 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; - - 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)); + // 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. + // 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"; } - // 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(); + return false; } + + 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 00000000..83b80e05 --- /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 70ace361..653c1b2a 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,64 @@ 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) })); + + // 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] + 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); + + [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 7ada3bdc..3c547b6d 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,101 @@ 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_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_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; + // 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); @@ -46,18 +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(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());