| | | 1 | | using System.Collections.Concurrent; |
| | | 2 | | using System.Collections.Generic; |
| | | 3 | | using System.Collections.Immutable; |
| | | 4 | | using System.Linq; |
| | | 5 | | |
| | | 6 | | using Microsoft.CodeAnalysis; |
| | | 7 | | using Microsoft.CodeAnalysis.Diagnostics; |
| | | 8 | | |
| | | 9 | | namespace NexusLabs.Needlr.AgentFramework.Analyzers; |
| | | 10 | | |
| | | 11 | | /// <summary> |
| | | 12 | | /// Analyzer that detects cyclic handoff chains in <c>[AgentHandoffsTo]</c> topology declarations. |
| | | 13 | | /// </summary> |
| | | 14 | | /// <remarks> |
| | | 15 | | /// <b>NDLRMAF004</b> (Warning): A cycle was found in the agent handoff graph — for example A → B → A. |
| | | 16 | | /// While MAF may handle runtime termination conditions, a cycle is almost always a topology design error. |
| | | 17 | | /// </remarks> |
| | | 18 | | [DiagnosticAnalyzer(LanguageNames.CSharp)] |
| | | 19 | | public sealed class AgentCyclicHandoffAnalyzer : DiagnosticAnalyzer |
| | | 20 | | { |
| | | 21 | | private const string AgentHandoffsToAttributeName = "NexusLabs.Needlr.AgentFramework.AgentHandoffsToAttribute"; |
| | | 22 | | |
| | | 23 | | public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => |
| | 176 | 24 | | ImmutableArray.Create(MafDiagnosticDescriptors.CyclicHandoffChain); |
| | | 25 | | |
| | | 26 | | public override void Initialize(AnalysisContext context) |
| | | 27 | | { |
| | 12 | 28 | | context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); |
| | 12 | 29 | | context.EnableConcurrentExecution(); |
| | | 30 | | |
| | 12 | 31 | | context.RegisterCompilationStartAction(compilationContext => |
| | 12 | 32 | | { |
| | 12 | 33 | | // source FQN → list of (target FQN, source type symbol, attribute location) |
| | 7 | 34 | | var edges = new ConcurrentDictionary<string, ConcurrentBag<(string TargetFqn, INamedTypeSymbol SourceSymbol, |
| | 7 | 35 | | StringComparer.Ordinal); |
| | 12 | 36 | | |
| | 7 | 37 | | compilationContext.RegisterSymbolAction(symbolContext => |
| | 7 | 38 | | { |
| | 80 | 39 | | var typeSymbol = (INamedTypeSymbol)symbolContext.Symbol; |
| | 80 | 40 | | var sourceFqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); |
| | 7 | 41 | | |
| | 332 | 42 | | foreach (var attr in typeSymbol.GetAttributes()) |
| | 7 | 43 | | { |
| | 86 | 44 | | if (attr.AttributeClass?.ToDisplayString() != AgentHandoffsToAttributeName) |
| | 7 | 45 | | continue; |
| | 7 | 46 | | |
| | 13 | 47 | | if (attr.ConstructorArguments.Length < 1) |
| | 7 | 48 | | continue; |
| | 7 | 49 | | |
| | 13 | 50 | | var typeArg = attr.ConstructorArguments[0]; |
| | 13 | 51 | | if (typeArg.Kind != TypedConstantKind.Type || typeArg.Value is not INamedTypeSymbol targetType) |
| | 7 | 52 | | continue; |
| | 7 | 53 | | |
| | 13 | 54 | | var targetFqn = targetType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); |
| | 13 | 55 | | var attrLocation = attr.ApplicationSyntaxReference?.SyntaxTree is { } tree |
| | 13 | 56 | | ? Location.Create(tree, attr.ApplicationSyntaxReference.Span) |
| | 13 | 57 | | : typeSymbol.Locations[0]; |
| | 7 | 58 | | |
| | 26 | 59 | | edges.GetOrAdd(sourceFqn, _ => new ConcurrentBag<(string, INamedTypeSymbol, Location)>()) |
| | 13 | 60 | | .Add((targetFqn, typeSymbol, attrLocation)); |
| | 7 | 61 | | } |
| | 87 | 62 | | }, SymbolKind.NamedType); |
| | 12 | 63 | | |
| | 7 | 64 | | compilationContext.RegisterCompilationEndAction(endContext => |
| | 7 | 65 | | { |
| | 7 | 66 | | // Build adjacency list: FQN → set of target FQNs |
| | 7 | 67 | | var adjacency = edges.ToDictionary( |
| | 13 | 68 | | kvp => kvp.Key, |
| | 26 | 69 | | kvp => kvp.Value.Select(e => e.TargetFqn).Distinct().ToList(), |
| | 7 | 70 | | StringComparer.Ordinal); |
| | 7 | 71 | | |
| | 7 | 72 | | // Map FQN → (source symbol, attribute locations) for diagnostics |
| | 7 | 73 | | var symbolMap = new Dictionary<string, (INamedTypeSymbol Symbol, List<Location> Locations)>(StringCompar |
| | 40 | 74 | | foreach (var kvp in edges) |
| | 7 | 75 | | { |
| | 13 | 76 | | var entries = kvp.Value.ToList(); |
| | 26 | 77 | | symbolMap[kvp.Key] = (entries[0].SourceSymbol, entries.Select(e => e.Location).ToList()); |
| | 7 | 78 | | } |
| | 7 | 79 | | |
| | 7 | 80 | | var visited = new HashSet<string>(StringComparer.Ordinal); |
| | 7 | 81 | | var reported = new HashSet<string>(StringComparer.Ordinal); |
| | 7 | 82 | | |
| | 40 | 83 | | foreach (var start in adjacency.Keys) |
| | 7 | 84 | | { |
| | 13 | 85 | | if (visited.Contains(start)) |
| | 7 | 86 | | continue; |
| | 7 | 87 | | |
| | 7 | 88 | | var path = new List<string>(); |
| | 7 | 89 | | DetectCycle(start, adjacency, visited, path, reported, endContext, symbolMap); |
| | 7 | 90 | | } |
| | 14 | 91 | | }); |
| | 19 | 92 | | }); |
| | 12 | 93 | | } |
| | | 94 | | |
| | | 95 | | private static void DetectCycle( |
| | | 96 | | string current, |
| | | 97 | | Dictionary<string, List<string>> adjacency, |
| | | 98 | | HashSet<string> visited, |
| | | 99 | | List<string> path, |
| | | 100 | | HashSet<string> reported, |
| | | 101 | | CompilationAnalysisContext context, |
| | | 102 | | Dictionary<string, (INamedTypeSymbol Symbol, List<Location> Locations)> symbolMap) |
| | | 103 | | { |
| | 15 | 104 | | path.Add(current); |
| | | 105 | | |
| | 15 | 106 | | if (adjacency.TryGetValue(current, out var neighbors)) |
| | | 107 | | { |
| | 52 | 108 | | foreach (var next in neighbors) |
| | | 109 | | { |
| | 13 | 110 | | var cycleIndex = path.IndexOf(next); |
| | 13 | 111 | | if (cycleIndex >= 0) |
| | | 112 | | { |
| | | 113 | | // Found a cycle: path[cycleIndex..] + next |
| | 4 | 114 | | var cycleNodes = path.Skip(cycleIndex).Concat([next]).ToList(); |
| | 4 | 115 | | var cycleKey = string.Join("→", cycleNodes.Select(ShortName)); |
| | | 116 | | |
| | | 117 | | // Report on each node in the cycle that we have symbol info for |
| | 28 | 118 | | foreach (var node in cycleNodes.Skip(0).Take(cycleNodes.Count - 1)) |
| | | 119 | | { |
| | 10 | 120 | | if (!reported.Add(node)) |
| | | 121 | | continue; |
| | | 122 | | |
| | 10 | 123 | | if (!symbolMap.TryGetValue(node, out var info)) |
| | | 124 | | continue; |
| | | 125 | | |
| | 40 | 126 | | foreach (var location in info.Locations) |
| | | 127 | | { |
| | 10 | 128 | | context.ReportDiagnostic(Diagnostic.Create( |
| | 10 | 129 | | MafDiagnosticDescriptors.CyclicHandoffChain, |
| | 10 | 130 | | location, |
| | 10 | 131 | | info.Symbol.Name, |
| | 10 | 132 | | cycleKey)); |
| | | 133 | | } |
| | | 134 | | } |
| | | 135 | | } |
| | 9 | 136 | | else if (!visited.Contains(next)) |
| | | 137 | | { |
| | 8 | 138 | | DetectCycle(next, adjacency, visited, path, reported, context, symbolMap); |
| | | 139 | | } |
| | | 140 | | } |
| | | 141 | | } |
| | | 142 | | |
| | 15 | 143 | | path.RemoveAt(path.Count - 1); |
| | 15 | 144 | | visited.Add(current); |
| | 15 | 145 | | } |
| | | 146 | | |
| | | 147 | | private static string ShortName(string fqn) |
| | | 148 | | { |
| | | 149 | | // Strip "global::" prefix and return just the last segment for readability |
| | 14 | 150 | | var clean = fqn.StartsWith("global::") ? fqn.Substring(8) : fqn; |
| | 14 | 151 | | var dot = clean.LastIndexOf('.'); |
| | 14 | 152 | | return dot >= 0 ? clean.Substring(dot + 1) : clean; |
| | | 153 | | } |
| | | 154 | | } |