// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Threading; using Microsoft.Agents.AI.Workflows.Generators.Diagnostics; using Microsoft.Agents.AI.Workflows.Generators.Models; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; namespace Microsoft.Agents.AI.Workflows.Generators.Analysis; /// /// Provides semantic analysis of executor route candidates. /// /// /// Analysis is split into two phases for efficiency with incremental generators: /// /// - Called per method, extracts data and performs method-level validation only. /// - Groups methods by class and performs class-level validation once. /// /// This avoids redundant class validation when multiple handlers exist in the same class. /// internal static class SemanticAnalyzer { // Fully-qualified type names used for symbol comparison private const string ExecutorTypeName = "Microsoft.Agents.AI.Workflows.Executor"; private const string WorkflowContextTypeName = "Microsoft.Agents.AI.Workflows.IWorkflowContext"; private const string CancellationTokenTypeName = "System.Threading.CancellationToken"; private const string ValueTaskTypeName = "System.Threading.Tasks.ValueTask"; private const string MessageHandlerAttributeName = "Microsoft.Agents.AI.Workflows.MessageHandlerAttribute"; private const string SendsMessageAttributeName = "Microsoft.Agents.AI.Workflows.SendsMessageAttribute"; private const string YieldsOutputAttributeName = "Microsoft.Agents.AI.Workflows.YieldsOutputAttribute"; /// /// Analyzes a method with [MessageHandler] attribute found by ForAttributeWithMetadataName. /// Returns a MethodAnalysisResult containing both method info and class context. /// /// /// This method only extracts raw data and performs method-level validation. /// Class-level validation is deferred to to avoid /// redundant validation when a class has multiple handler methods. /// public static MethodAnalysisResult AnalyzeHandlerMethod( GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) { // The target should be a method if (context.TargetSymbol is not IMethodSymbol methodSymbol) { return MethodAnalysisResult.Empty; } // Get the containing class INamedTypeSymbol? classSymbol = methodSymbol.ContainingType; if (classSymbol is null) { return MethodAnalysisResult.Empty; } // Get the method syntax for location info MethodDeclarationSyntax? methodSyntax = context.TargetNode as MethodDeclarationSyntax; // Extract class-level info (raw facts, no validation here) string classKey = GetClassKey(classSymbol); bool isPartialClass = IsPartialClass(classSymbol, cancellationToken); bool derivesFromExecutor = DerivesFromExecutor(classSymbol); bool hasManualConfigureProtocol = HasConfigureProtocolDefined(classSymbol); // Extract class metadata string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true ? null : classSymbol.ContainingNamespace?.ToDisplayString(); string className = classSymbol.Name; string? genericParameters = GetGenericParameters(classSymbol); bool isNested = classSymbol.ContainingType != null; string containingTypeChain = GetContainingTypeChain(classSymbol); bool baseHasConfigureProtocol = BaseHasConfigureProtocol(classSymbol); ImmutableEquatableArray classSendTypes = GetClassLevelTypes(classSymbol, SendsMessageAttributeName); ImmutableEquatableArray classYieldTypes = GetClassLevelTypes(classSymbol, YieldsOutputAttributeName); // Get class location for class-level diagnostics DiagnosticLocationInfo? classLocation = GetClassLocation(classSymbol, cancellationToken); // Analyze the handler method (method-level validation only) // Skip method analysis if class doesn't derive from Executor (class-level diagnostic will be reported later) var methodDiagnostics = ImmutableArray.CreateBuilder(); HandlerInfo? handler = null; if (derivesFromExecutor) { handler = AnalyzeHandler(methodSymbol, methodSyntax, methodDiagnostics); } return new MethodAnalysisResult( classKey, @namespace, className, genericParameters, isNested, containingTypeChain, baseHasConfigureProtocol, classSendTypes, classYieldTypes, isPartialClass, derivesFromExecutor, hasManualConfigureProtocol, classLocation, handler, Diagnostics: new ImmutableEquatableArray(methodDiagnostics.ToImmutable())); } /// /// Combines multiple MethodAnalysisResults for the same class into an AnalysisResult. /// Performs class-level validation once (instead of per-method) for efficiency. /// public static AnalysisResult CombineHandlerMethodResults(IEnumerable methodResults) { List methods = methodResults.ToList(); if (methods.Count == 0) { return AnalysisResult.Empty; } // All methods should have same class info - take from first MethodAnalysisResult first = methods[0]; Location classLocation = first.ClassLocation?.ToRoslynLocation() ?? Location.None; // Collect method-level diagnostics var allDiagnostics = ImmutableArray.CreateBuilder(); foreach (var method in methods) { foreach (var diag in method.Diagnostics) { allDiagnostics.Add(diag.ToRoslynDiagnostic(null)); } } // Class-level validation (done once, not per-method) if (!first.DerivesFromExecutor) { allDiagnostics.Add(Diagnostic.Create( DiagnosticDescriptors.NotAnExecutor, classLocation, first.ClassName, first.ClassName)); return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable()); } if (!first.IsPartialClass) { allDiagnostics.Add(Diagnostic.Create( DiagnosticDescriptors.ClassMustBePartial, classLocation, first.ClassName)); return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable()); } if (first.HasManualConfigureProtocol) { allDiagnostics.Add(Diagnostic.Create( DiagnosticDescriptors.ConfigureProtocolAlreadyDefined, classLocation, first.ClassName)); return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable()); } // Collect valid handlers ImmutableArray handlers = methods .Where(m => m.Handler is not null) .Select(m => m.Handler!) .ToImmutableArray(); if (handlers.Length == 0) { return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable()); } ExecutorInfo executorInfo = new( first.Namespace, first.ClassName, first.GenericParameters, first.IsNested, first.ContainingTypeChain, first.BaseHasConfigureProtocol, new ImmutableEquatableArray(handlers), first.ClassSendTypes, first.ClassYieldTypes); if (allDiagnostics.Count > 0) { return AnalysisResult.WithInfoAndDiagnostics(executorInfo, allDiagnostics.ToImmutable()); } return AnalysisResult.Success(executorInfo); } /// /// Analyzes a class with [SendsMessage] or [YieldsOutput] attribute found by ForAttributeWithMetadataName. /// Returns ClassProtocolInfo entries for each attribute instance (handles multiple attributes of same type). /// /// The generator attribute syntax context. /// Whether this is a Send or Yield attribute. /// Cancellation token. /// The analysis results for the class protocol attributes. public static ImmutableArray AnalyzeClassProtocolAttribute( GeneratorAttributeSyntaxContext context, ProtocolAttributeKind attributeKind, CancellationToken cancellationToken) { // The target should be a class if (context.TargetSymbol is not INamedTypeSymbol classSymbol) { return ImmutableArray.Empty; } // Extract class-level info (same for all attributes) string classKey = GetClassKey(classSymbol); bool isPartialClass = IsPartialClass(classSymbol, cancellationToken); bool derivesFromExecutor = DerivesFromExecutor(classSymbol); bool hasManualConfigureProtocol = HasConfigureProtocolDefined(classSymbol); bool baseHasConfigureProtocol = BaseHasConfigureProtocol(classSymbol); string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true ? null : classSymbol.ContainingNamespace?.ToDisplayString(); string className = classSymbol.Name; string? genericParameters = GetGenericParameters(classSymbol); bool isNested = classSymbol.ContainingType != null; string containingTypeChain = GetContainingTypeChain(classSymbol); DiagnosticLocationInfo? classLocation = GetClassLocation(classSymbol, cancellationToken); // Extract a ClassProtocolInfo for each attribute instance ImmutableArray.Builder results = ImmutableArray.CreateBuilder(); foreach (AttributeData attr in context.Attributes) { if (attr.ConstructorArguments.Length > 0 && attr.ConstructorArguments[0].Value is INamedTypeSymbol typeSymbol) { string typeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); results.Add(new ClassProtocolInfo( classKey, @namespace, className, genericParameters, isNested, containingTypeChain, isPartialClass, derivesFromExecutor, hasManualConfigureProtocol, baseHasConfigureProtocol, classLocation, typeName, attributeKind)); } } return results.ToImmutable(); } /// /// Combines ClassProtocolInfo results into an AnalysisResult for classes that only have IO attributes /// (no [MessageHandler] methods). This generates only .SendsMessage/.YieldsOutput calls in the protocol /// configuration. /// /// /// This is likely to be seen combined with the basic one-method Executor%lt;TIn> or Executor<TIn, TOut> /// /// The protocol info entries for the class. /// The combined analysis result. public static AnalysisResult CombineOutputOnlyResults(IEnumerable protocolInfos) { List protocols = protocolInfos.ToList(); if (protocols.Count == 0) { return AnalysisResult.Empty; } // All entries should have same class info - take from first ClassProtocolInfo first = protocols[0]; Location classLocation = first.ClassLocation?.ToRoslynLocation() ?? Location.None; ImmutableArray.Builder allDiagnostics = ImmutableArray.CreateBuilder(); // Class-level validation if (!first.DerivesFromExecutor) { allDiagnostics.Add(Diagnostic.Create( DiagnosticDescriptors.NotAnExecutor, classLocation, first.ClassName, first.ClassName)); return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable()); } if (!first.IsPartialClass) { allDiagnostics.Add(Diagnostic.Create( DiagnosticDescriptors.ClassMustBePartial, classLocation, first.ClassName)); return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable()); } // Collect send and yield types ImmutableArray.Builder sendTypes = ImmutableArray.CreateBuilder(); ImmutableArray.Builder yieldTypes = ImmutableArray.CreateBuilder(); foreach (ClassProtocolInfo protocol in protocols) { if (protocol.AttributeKind == ProtocolAttributeKind.Send) { sendTypes.Add(protocol.TypeName); } else { yieldTypes.Add(protocol.TypeName); } } // Sort to ensure consistent ordering for incremental generator caching sendTypes.Sort(StringComparer.Ordinal); yieldTypes.Sort(StringComparer.Ordinal); // Create ExecutorInfo with no handlers but with protocol types ExecutorInfo executorInfo = new( first.Namespace, first.ClassName, first.GenericParameters, first.IsNested, first.ContainingTypeChain, first.BaseHasConfigureProtocol, Handlers: ImmutableEquatableArray.Empty, ClassSendTypes: new ImmutableEquatableArray(sendTypes.ToImmutable()), ClassYieldTypes: new ImmutableEquatableArray(yieldTypes.ToImmutable())); if (allDiagnostics.Count > 0) { return AnalysisResult.WithInfoAndDiagnostics(executorInfo, allDiagnostics.ToImmutable()); } return AnalysisResult.Success(executorInfo); } /// /// Gets the source location of the class identifier for diagnostic reporting. /// private static DiagnosticLocationInfo? GetClassLocation(INamedTypeSymbol classSymbol, CancellationToken cancellationToken) { foreach (SyntaxReference syntaxRef in classSymbol.DeclaringSyntaxReferences) { SyntaxNode syntax = syntaxRef.GetSyntax(cancellationToken); if (syntax is ClassDeclarationSyntax classDecl) { return DiagnosticLocationInfo.FromLocation(classDecl.Identifier.GetLocation()); } } return null; } /// /// Returns a unique identifier for the class used to group methods by their containing type. /// private static string GetClassKey(INamedTypeSymbol classSymbol) { return classSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); } /// /// Checks if any declaration of the class has the 'partial' modifier. /// private static bool IsPartialClass(INamedTypeSymbol classSymbol, CancellationToken cancellationToken) { foreach (SyntaxReference syntaxRef in classSymbol.DeclaringSyntaxReferences) { SyntaxNode syntax = syntaxRef.GetSyntax(cancellationToken); if (syntax is ClassDeclarationSyntax classDecl && classDecl.Modifiers.Any(SyntaxKind.PartialKeyword)) { return true; } } return false; } /// /// Walks the inheritance chain to check if the class derives from Executor or Executor<T>. /// private static bool DerivesFromExecutor(INamedTypeSymbol classSymbol) { INamedTypeSymbol? current = classSymbol.BaseType; while (current != null) { string fullName = current.OriginalDefinition.ToDisplayString(); if (fullName == ExecutorTypeName || fullName.StartsWith(ExecutorTypeName + "<", StringComparison.Ordinal)) { return true; } current = current.BaseType; } return false; } /// /// Checks if this class directly defines ConfigureProtocol (not inherited). /// If so, we skip generation to avoid conflicting with user's manual implementation. /// private static bool HasConfigureProtocolDefined(INamedTypeSymbol classSymbol) { foreach (var member in classSymbol.GetMembers("ConfigureProtocol")) { if (member is IMethodSymbol method && !method.IsAbstract && SymbolEqualityComparer.Default.Equals(method.ContainingType, classSymbol)) { return true; } } return false; } /// /// Checks if any base class (between this class and Executor) defines ConfigureProtocol. /// If so, generated code should call base.ConfigureProtocol() to preserve inherited handlers. /// private static bool BaseHasConfigureProtocol(INamedTypeSymbol classSymbol) { INamedTypeSymbol? baseType = classSymbol.BaseType; while (baseType != null) { string fullName = baseType.OriginalDefinition.ToDisplayString(); // Stop at Executor - its ConfigureProtocol is abstract/empty if (fullName == ExecutorTypeName) { return false; } foreach (var member in baseType.GetMembers("ConfigureProtocol")) { if (member is IMethodSymbol method && !method.IsAbstract) { return true; } } baseType = baseType.BaseType; } return false; } /// /// Validates a handler method's signature and extracts metadata. /// /// /// Valid signatures: /// /// void Handle(TMessage, IWorkflowContext, [CancellationToken]) /// ValueTask HandleAsync(TMessage, IWorkflowContext, [CancellationToken]) /// ValueTask<TResult> HandleAsync(TMessage, IWorkflowContext, [CancellationToken]) /// TResult Handle(TMessage, IWorkflowContext, [CancellationToken]) (sync with result) /// /// private static HandlerInfo? AnalyzeHandler( IMethodSymbol methodSymbol, MethodDeclarationSyntax? methodSyntax, ImmutableArray.Builder diagnostics) { Location location = methodSyntax?.Identifier.GetLocation() ?? Location.None; // Check if static if (methodSymbol.IsStatic) { diagnostics.Add(DiagnosticInfo.Create("MAFGENWF007", location, methodSymbol.Name)); return null; } // Check parameter count if (methodSymbol.Parameters.Length < 2) { diagnostics.Add(DiagnosticInfo.Create("MAFGENWF005", location, methodSymbol.Name)); return null; } // Check second parameter is IWorkflowContext IParameterSymbol secondParam = methodSymbol.Parameters[1]; if (secondParam.Type.ToDisplayString() != WorkflowContextTypeName) { diagnostics.Add(DiagnosticInfo.Create("MAFGENWF001", location, methodSymbol.Name)); return null; } // Check for optional CancellationToken as third parameter bool hasCancellationToken = methodSymbol.Parameters.Length >= 3 && methodSymbol.Parameters[2].Type.ToDisplayString() == CancellationTokenTypeName; // Analyze return type ITypeSymbol returnType = methodSymbol.ReturnType; HandlerSignatureKind? signatureKind = GetSignatureKind(returnType); if (signatureKind == null) { diagnostics.Add(DiagnosticInfo.Create("MAFGENWF002", location, methodSymbol.Name)); return null; } // Get input type ITypeSymbol inputType = methodSymbol.Parameters[0].Type; string inputTypeName = inputType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); // Get output type string? outputTypeName = null; if (signatureKind == HandlerSignatureKind.ResultSync) { outputTypeName = returnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); } else if (signatureKind == HandlerSignatureKind.ResultAsync && returnType is INamedTypeSymbol namedReturn) { if (namedReturn.TypeArguments.Length == 1) { outputTypeName = namedReturn.TypeArguments[0].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); } } // Get Yield and Send types from attribute (ImmutableEquatableArray yieldTypes, ImmutableEquatableArray sendTypes) = GetAttributeTypeArrays(methodSymbol); return new HandlerInfo( methodSymbol.Name, inputTypeName, outputTypeName, signatureKind.Value, hasCancellationToken, yieldTypes, sendTypes); } /// /// Determines the handler signature kind from the return type. /// /// The signature kind, or null if the return type is not supported (e.g., Task, Task<T>). private static HandlerSignatureKind? GetSignatureKind(ITypeSymbol returnType) { string returnTypeName = returnType.ToDisplayString(); if (returnType.SpecialType == SpecialType.System_Void) { return HandlerSignatureKind.VoidSync; } if (returnTypeName == ValueTaskTypeName) { return HandlerSignatureKind.VoidAsync; } if (returnType is INamedTypeSymbol namedType && namedType.OriginalDefinition.ToDisplayString() == "System.Threading.Tasks.ValueTask") { return HandlerSignatureKind.ResultAsync; } // Any non-void, non-Task type is treated as a synchronous result if (returnType.SpecialType != SpecialType.System_Void && !returnTypeName.StartsWith("System.Threading.Tasks.Task", StringComparison.Ordinal) && !returnTypeName.StartsWith("System.Threading.Tasks.ValueTask", StringComparison.Ordinal)) { return HandlerSignatureKind.ResultSync; } // Task/Task not supported - must use ValueTask return null; } /// /// Extracts Yield and Send type arrays from the [MessageHandler] attribute's named arguments. /// /// /// [MessageHandler(Yield = new[] { typeof(OutputA), typeof(OutputB) }, Send = new[] { typeof(Request) })] /// private static (ImmutableEquatableArray YieldTypes, ImmutableEquatableArray SendTypes) GetAttributeTypeArrays( IMethodSymbol methodSymbol) { var yieldTypes = ImmutableArray.Empty; var sendTypes = ImmutableArray.Empty; foreach (var attr in methodSymbol.GetAttributes()) { if (attr.AttributeClass?.ToDisplayString() != MessageHandlerAttributeName) { continue; } foreach (var namedArg in attr.NamedArguments) { if (namedArg.Key.Equals("Yield", StringComparison.Ordinal) && !namedArg.Value.IsNull) { yieldTypes = ExtractTypeArray(namedArg.Value); } else if (namedArg.Key.Equals("Send", StringComparison.Ordinal) && !namedArg.Value.IsNull) { sendTypes = ExtractTypeArray(namedArg.Value); } } } return (new ImmutableEquatableArray(yieldTypes), new ImmutableEquatableArray(sendTypes)); } /// /// Converts a TypedConstant array (from attribute argument) to fully-qualified type name strings. /// /// /// Results are sorted to ensure consistent ordering for incremental generator caching. /// private static ImmutableArray ExtractTypeArray(TypedConstant typedConstant) { if (typedConstant.Kind != TypedConstantKind.Array) { return ImmutableArray.Empty; } ImmutableArray.Builder builder = ImmutableArray.CreateBuilder(); foreach (TypedConstant value in typedConstant.Values) { if (value.Value is INamedTypeSymbol typeSymbol) { builder.Add(typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); } } // Sort to ensure consistent ordering for incremental generator caching builder.Sort(StringComparer.Ordinal); return builder.ToImmutable(); } /// /// Collects types from [SendsMessage] or [YieldsOutput] attributes applied to the class. /// /// /// Results are sorted to ensure consistent ordering for incremental generator caching, /// since GetAttributes() order is not guaranteed across partial class declarations. /// /// /// [SendsMessage(typeof(Request))] /// [YieldsOutput(typeof(Response))] /// public partial class MyExecutor : Executor { } /// private static ImmutableEquatableArray GetClassLevelTypes(INamedTypeSymbol classSymbol, string attributeName) { ImmutableArray.Builder builder = ImmutableArray.CreateBuilder(); foreach (AttributeData attr in classSymbol.GetAttributes()) { if (attr.AttributeClass?.ToDisplayString() == attributeName && attr.ConstructorArguments.Length > 0 && attr.ConstructorArguments[0].Value is INamedTypeSymbol typeSymbol) { builder.Add(typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); } } // Sort to ensure consistent ordering for incremental generator caching builder.Sort(StringComparer.Ordinal); return new ImmutableEquatableArray(builder.ToImmutable()); } /// /// Builds the chain of containing types for nested classes, outermost first. /// /// /// For class Outer.Middle.Inner.MyExecutor, returns "Outer.Middle.Inner" /// private static string GetContainingTypeChain(INamedTypeSymbol classSymbol) { List chain = new(); INamedTypeSymbol? current = classSymbol.ContainingType; while (current != null) { chain.Insert(0, current.Name); current = current.ContainingType; } return string.Join(".", chain); } /// /// Returns the generic type parameter clause (e.g., "<T, U>") for generic classes, or null for non-generic. /// private static string? GetGenericParameters(INamedTypeSymbol classSymbol) { if (!classSymbol.IsGenericType) { return null; } string parameters = string.Join(", ", classSymbol.TypeParameters.Select(p => p.Name)); return $"<{parameters}>"; } }