| | | 1 | | using System; |
| | | 2 | | using System.Collections.Concurrent; |
| | | 3 | | using System.Collections.Generic; |
| | | 4 | | using System.Collections.Immutable; |
| | | 5 | | using System.Linq; |
| | | 6 | | |
| | | 7 | | using Microsoft.CodeAnalysis; |
| | | 8 | | using Microsoft.CodeAnalysis.Diagnostics; |
| | | 9 | | |
| | | 10 | | namespace NexusLabs.Needlr.AgentFramework.Analyzers; |
| | | 11 | | |
| | | 12 | | /// <summary> |
| | | 13 | | /// Analyzer that detects cycles in agent graphs declared via <c>[AgentGraphEdge]</c>. |
| | | 14 | | /// </summary> |
| | | 15 | | /// <remarks> |
| | | 16 | | /// <b>NDLRMAF016</b> (Error): A cycle was found in the agent graph — agent graphs must be |
| | | 17 | | /// directed acyclic graphs (DAGs). |
| | | 18 | | /// </remarks> |
| | | 19 | | [DiagnosticAnalyzer(LanguageNames.CSharp)] |
| | | 20 | | public sealed class AgentGraphCycleAnalyzer : DiagnosticAnalyzer |
| | | 21 | | { |
| | | 22 | | private const string AgentGraphEdgeAttributeName = "NexusLabs.Needlr.AgentFramework.AgentGraphEdgeAttribute"; |
| | | 23 | | |
| | | 24 | | public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => |
| | 218 | 25 | | ImmutableArray.Create(MafDiagnosticDescriptors.GraphCycleDetected); |
| | | 26 | | |
| | | 27 | | public override void Initialize(AnalysisContext context) |
| | | 28 | | { |
| | 17 | 29 | | context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); |
| | 17 | 30 | | context.EnableConcurrentExecution(); |
| | | 31 | | |
| | 17 | 32 | | context.RegisterCompilationStartAction(compilationContext => |
| | 17 | 33 | | { |
| | 17 | 34 | | // graphName → { sourceFqn → list of (targetFqn, sourceSymbol, location) } |
| | 10 | 35 | | var graphEdges = new ConcurrentDictionary<string, ConcurrentDictionary<string, ConcurrentBag<(string TargetF |
| | 10 | 36 | | StringComparer.Ordinal); |
| | 17 | 37 | | |
| | 10 | 38 | | compilationContext.RegisterSymbolAction(symbolContext => |
| | 10 | 39 | | { |
| | 162 | 40 | | var typeSymbol = (INamedTypeSymbol)symbolContext.Symbol; |
| | 162 | 41 | | var sourceFqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); |
| | 10 | 42 | | |
| | 650 | 43 | | foreach (var attr in typeSymbol.GetAttributes()) |
| | 10 | 44 | | { |
| | 163 | 45 | | if (attr.AttributeClass?.ToDisplayString() != AgentGraphEdgeAttributeName) |
| | 10 | 46 | | continue; |
| | 10 | 47 | | |
| | 20 | 48 | | if (attr.ConstructorArguments.Length < 2) |
| | 10 | 49 | | continue; |
| | 10 | 50 | | |
| | 20 | 51 | | var graphNameArg = attr.ConstructorArguments[0]; |
| | 20 | 52 | | if (graphNameArg.Value is not string graphName) |
| | 10 | 53 | | continue; |
| | 10 | 54 | | |
| | 20 | 55 | | var targetTypeArg = attr.ConstructorArguments[1]; |
| | 20 | 56 | | if (targetTypeArg.Kind != TypedConstantKind.Type || targetTypeArg.Value is not INamedTypeSymbol targ |
| | 10 | 57 | | continue; |
| | 10 | 58 | | |
| | 20 | 59 | | var targetFqn = targetType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); |
| | 20 | 60 | | var attrLocation = attr.ApplicationSyntaxReference?.SyntaxTree is { } tree |
| | 20 | 61 | | ? Location.Create(tree, attr.ApplicationSyntaxReference.Span) |
| | 20 | 62 | | : typeSymbol.Locations[0]; |
| | 10 | 63 | | |
| | 30 | 64 | | var perGraph = graphEdges.GetOrAdd(graphName, _ => new ConcurrentDictionary<string, ConcurrentBag<(s |
| | 39 | 65 | | perGraph.GetOrAdd(sourceFqn, _ => new ConcurrentBag<(string, INamedTypeSymbol, Location)>()) |
| | 20 | 66 | | .Add((targetFqn, typeSymbol, attrLocation)); |
| | 10 | 67 | | } |
| | 172 | 68 | | }, SymbolKind.NamedType); |
| | 17 | 69 | | |
| | 10 | 70 | | compilationContext.RegisterCompilationEndAction(endContext => |
| | 10 | 71 | | { |
| | 40 | 72 | | foreach (var graphKvp in graphEdges) |
| | 10 | 73 | | { |
| | 10 | 74 | | var graphName = graphKvp.Key; |
| | 10 | 75 | | var edges = graphKvp.Value; |
| | 10 | 76 | | |
| | 10 | 77 | | var adjacency = edges.ToDictionary( |
| | 19 | 78 | | kvp => kvp.Key, |
| | 39 | 79 | | kvp => kvp.Value.Select(e => e.TargetFqn).Distinct().ToList(), |
| | 10 | 80 | | StringComparer.Ordinal); |
| | 10 | 81 | | |
| | 10 | 82 | | var symbolMap = new Dictionary<string, (INamedTypeSymbol Symbol, List<Location> Locations)>(StringCo |
| | 58 | 83 | | foreach (var kvp in edges) |
| | 10 | 84 | | { |
| | 19 | 85 | | var entries = kvp.Value.ToList(); |
| | 39 | 86 | | symbolMap[kvp.Key] = (entries[0].SourceSymbol, entries.Select(e => e.Location).ToList()); |
| | 10 | 87 | | } |
| | 10 | 88 | | |
| | 10 | 89 | | var visited = new HashSet<string>(StringComparer.Ordinal); |
| | 10 | 90 | | var reported = new HashSet<string>(StringComparer.Ordinal); |
| | 10 | 91 | | |
| | 58 | 92 | | foreach (var start in adjacency.Keys) |
| | 10 | 93 | | { |
| | 19 | 94 | | if (visited.Contains(start)) |
| | 10 | 95 | | continue; |
| | 10 | 96 | | |
| | 13 | 97 | | var path = new List<string>(); |
| | 13 | 98 | | DetectCycle(start, adjacency, visited, path, reported, endContext, symbolMap, graphName); |
| | 10 | 99 | | } |
| | 10 | 100 | | } |
| | 20 | 101 | | }); |
| | 27 | 102 | | }); |
| | 17 | 103 | | } |
| | | 104 | | |
| | | 105 | | private static void DetectCycle( |
| | | 106 | | string current, |
| | | 107 | | Dictionary<string, List<string>> adjacency, |
| | | 108 | | HashSet<string> visited, |
| | | 109 | | List<string> path, |
| | | 110 | | HashSet<string> reported, |
| | | 111 | | CompilationAnalysisContext context, |
| | | 112 | | Dictionary<string, (INamedTypeSymbol Symbol, List<Location> Locations)> symbolMap, |
| | | 113 | | string graphName) |
| | | 114 | | { |
| | 23 | 115 | | path.Add(current); |
| | | 116 | | |
| | 23 | 117 | | if (adjacency.TryGetValue(current, out var neighbors)) |
| | | 118 | | { |
| | 78 | 119 | | foreach (var next in neighbors) |
| | | 120 | | { |
| | 20 | 121 | | var cycleIndex = path.IndexOf(next); |
| | 20 | 122 | | if (cycleIndex >= 0) |
| | | 123 | | { |
| | 6 | 124 | | var cycleNodes = path.Skip(cycleIndex).Concat(new[] { next }).ToList(); |
| | 6 | 125 | | var cycleKey = string.Join(" \u2192 ", cycleNodes.Select(ShortName)); |
| | | 126 | | |
| | 36 | 127 | | foreach (var node in cycleNodes.Take(cycleNodes.Count - 1)) |
| | | 128 | | { |
| | 12 | 129 | | if (!reported.Add(node)) |
| | | 130 | | continue; |
| | | 131 | | |
| | 12 | 132 | | if (!symbolMap.TryGetValue(node, out var info)) |
| | | 133 | | continue; |
| | | 134 | | |
| | 48 | 135 | | foreach (var location in info.Locations) |
| | | 136 | | { |
| | 12 | 137 | | context.ReportDiagnostic(Diagnostic.Create( |
| | 12 | 138 | | MafDiagnosticDescriptors.GraphCycleDetected, |
| | 12 | 139 | | location, |
| | 12 | 140 | | graphName, |
| | 12 | 141 | | cycleKey)); |
| | | 142 | | } |
| | | 143 | | } |
| | | 144 | | } |
| | 14 | 145 | | else if (!visited.Contains(next)) |
| | | 146 | | { |
| | 10 | 147 | | DetectCycle(next, adjacency, visited, path, reported, context, symbolMap, graphName); |
| | | 148 | | } |
| | | 149 | | } |
| | | 150 | | } |
| | | 151 | | |
| | 23 | 152 | | path.RemoveAt(path.Count - 1); |
| | 23 | 153 | | visited.Add(current); |
| | 23 | 154 | | } |
| | | 155 | | |
| | | 156 | | private static string ShortName(string fqn) |
| | | 157 | | { |
| | 18 | 158 | | var clean = fqn.StartsWith("global::") ? fqn.Substring(8) : fqn; |
| | 18 | 159 | | var dot = clean.LastIndexOf('.'); |
| | 18 | 160 | | return dot >= 0 ? clean.Substring(dot + 1) : clean; |
| | | 161 | | } |
| | | 162 | | } |