< Summary

Information
Class: NexusLabs.Needlr.AgentFramework.Analyzers.AgentGraphCycleAnalyzer
Assembly: NexusLabs.Needlr.AgentFramework.Analyzers
File(s): /home/runner/work/needlr/needlr/src/NexusLabs.Needlr.AgentFramework.Analyzers/AgentGraphCycleAnalyzer.cs
Line coverage
100%
Covered lines: 99
Uncovered lines: 0
Coverable lines: 99
Total lines: 162
Line coverage: 100%
Branch coverage
89%
Covered branches: 41
Total branches: 46
Branch coverage: 89.1%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_SupportedDiagnostics()100%11100%
Initialize(...)88.46%2626100%
DetectCycle(...)100%1616100%
ShortName(...)50%44100%

File(s)

/home/runner/work/needlr/needlr/src/NexusLabs.Needlr.AgentFramework.Analyzers/AgentGraphCycleAnalyzer.cs

#LineLine coverage
 1using System;
 2using System.Collections.Concurrent;
 3using System.Collections.Generic;
 4using System.Collections.Immutable;
 5using System.Linq;
 6
 7using Microsoft.CodeAnalysis;
 8using Microsoft.CodeAnalysis.Diagnostics;
 9
 10namespace 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)]
 20public sealed class AgentGraphCycleAnalyzer : DiagnosticAnalyzer
 21{
 22    private const string AgentGraphEdgeAttributeName = "NexusLabs.Needlr.AgentFramework.AgentGraphEdgeAttribute";
 23
 24    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
 21825        ImmutableArray.Create(MafDiagnosticDescriptors.GraphCycleDetected);
 26
 27    public override void Initialize(AnalysisContext context)
 28    {
 1729        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
 1730        context.EnableConcurrentExecution();
 31
 1732        context.RegisterCompilationStartAction(compilationContext =>
 1733        {
 1734            // graphName → { sourceFqn → list of (targetFqn, sourceSymbol, location) }
 1035            var graphEdges = new ConcurrentDictionary<string, ConcurrentDictionary<string, ConcurrentBag<(string TargetF
 1036                StringComparer.Ordinal);
 1737
 1038            compilationContext.RegisterSymbolAction(symbolContext =>
 1039            {
 16240                var typeSymbol = (INamedTypeSymbol)symbolContext.Symbol;
 16241                var sourceFqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
 1042
 65043                foreach (var attr in typeSymbol.GetAttributes())
 1044                {
 16345                    if (attr.AttributeClass?.ToDisplayString() != AgentGraphEdgeAttributeName)
 1046                        continue;
 1047
 2048                    if (attr.ConstructorArguments.Length < 2)
 1049                        continue;
 1050
 2051                    var graphNameArg = attr.ConstructorArguments[0];
 2052                    if (graphNameArg.Value is not string graphName)
 1053                        continue;
 1054
 2055                    var targetTypeArg = attr.ConstructorArguments[1];
 2056                    if (targetTypeArg.Kind != TypedConstantKind.Type || targetTypeArg.Value is not INamedTypeSymbol targ
 1057                        continue;
 1058
 2059                    var targetFqn = targetType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
 2060                    var attrLocation = attr.ApplicationSyntaxReference?.SyntaxTree is { } tree
 2061                        ? Location.Create(tree, attr.ApplicationSyntaxReference.Span)
 2062                        : typeSymbol.Locations[0];
 1063
 3064                    var perGraph = graphEdges.GetOrAdd(graphName, _ => new ConcurrentDictionary<string, ConcurrentBag<(s
 3965                    perGraph.GetOrAdd(sourceFqn, _ => new ConcurrentBag<(string, INamedTypeSymbol, Location)>())
 2066                        .Add((targetFqn, typeSymbol, attrLocation));
 1067                }
 17268            }, SymbolKind.NamedType);
 1769
 1070            compilationContext.RegisterCompilationEndAction(endContext =>
 1071            {
 4072                foreach (var graphKvp in graphEdges)
 1073                {
 1074                    var graphName = graphKvp.Key;
 1075                    var edges = graphKvp.Value;
 1076
 1077                    var adjacency = edges.ToDictionary(
 1978                        kvp => kvp.Key,
 3979                        kvp => kvp.Value.Select(e => e.TargetFqn).Distinct().ToList(),
 1080                        StringComparer.Ordinal);
 1081
 1082                    var symbolMap = new Dictionary<string, (INamedTypeSymbol Symbol, List<Location> Locations)>(StringCo
 5883                    foreach (var kvp in edges)
 1084                    {
 1985                        var entries = kvp.Value.ToList();
 3986                        symbolMap[kvp.Key] = (entries[0].SourceSymbol, entries.Select(e => e.Location).ToList());
 1087                    }
 1088
 1089                    var visited = new HashSet<string>(StringComparer.Ordinal);
 1090                    var reported = new HashSet<string>(StringComparer.Ordinal);
 1091
 5892                    foreach (var start in adjacency.Keys)
 1093                    {
 1994                        if (visited.Contains(start))
 1095                            continue;
 1096
 1397                        var path = new List<string>();
 1398                        DetectCycle(start, adjacency, visited, path, reported, endContext, symbolMap, graphName);
 1099                    }
 10100                }
 20101            });
 27102        });
 17103    }
 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    {
 23115        path.Add(current);
 116
 23117        if (adjacency.TryGetValue(current, out var neighbors))
 118        {
 78119            foreach (var next in neighbors)
 120            {
 20121                var cycleIndex = path.IndexOf(next);
 20122                if (cycleIndex >= 0)
 123                {
 6124                    var cycleNodes = path.Skip(cycleIndex).Concat(new[] { next }).ToList();
 6125                    var cycleKey = string.Join(" \u2192 ", cycleNodes.Select(ShortName));
 126
 36127                    foreach (var node in cycleNodes.Take(cycleNodes.Count - 1))
 128                    {
 12129                        if (!reported.Add(node))
 130                            continue;
 131
 12132                        if (!symbolMap.TryGetValue(node, out var info))
 133                            continue;
 134
 48135                        foreach (var location in info.Locations)
 136                        {
 12137                            context.ReportDiagnostic(Diagnostic.Create(
 12138                                MafDiagnosticDescriptors.GraphCycleDetected,
 12139                                location,
 12140                                graphName,
 12141                                cycleKey));
 142                        }
 143                    }
 144                }
 14145                else if (!visited.Contains(next))
 146                {
 10147                    DetectCycle(next, adjacency, visited, path, reported, context, symbolMap, graphName);
 148                }
 149            }
 150        }
 151
 23152        path.RemoveAt(path.Count - 1);
 23153        visited.Add(current);
 23154    }
 155
 156    private static string ShortName(string fqn)
 157    {
 18158        var clean = fqn.StartsWith("global::") ? fqn.Substring(8) : fqn;
 18159        var dot = clean.LastIndexOf('.');
 18160        return dot >= 0 ? clean.Substring(dot + 1) : clean;
 161    }
 162}