< Summary

Information
Class: NexusLabs.Needlr.AgentFramework.Analyzers.AgentCyclicHandoffAnalyzer
Assembly: NexusLabs.Needlr.AgentFramework.Analyzers
File(s): /home/runner/work/needlr/needlr/src/NexusLabs.Needlr.AgentFramework.Analyzers/AgentCyclicHandoffAnalyzer.cs
Line coverage
100%
Covered lines: 90
Uncovered lines: 0
Coverable lines: 90
Total lines: 154
Line coverage: 100%
Branch coverage
88%
Covered branches: 37
Total branches: 42
Branch coverage: 88%
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(...)86.36%2222100%
DetectCycle(...)100%1616100%
ShortName(...)50%44100%

File(s)

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

#LineLine coverage
 1using System.Collections.Concurrent;
 2using System.Collections.Generic;
 3using System.Collections.Immutable;
 4using System.Linq;
 5
 6using Microsoft.CodeAnalysis;
 7using Microsoft.CodeAnalysis.Diagnostics;
 8
 9namespace 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)]
 19public sealed class AgentCyclicHandoffAnalyzer : DiagnosticAnalyzer
 20{
 21    private const string AgentHandoffsToAttributeName = "NexusLabs.Needlr.AgentFramework.AgentHandoffsToAttribute";
 22
 23    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
 17624        ImmutableArray.Create(MafDiagnosticDescriptors.CyclicHandoffChain);
 25
 26    public override void Initialize(AnalysisContext context)
 27    {
 1228        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
 1229        context.EnableConcurrentExecution();
 30
 1231        context.RegisterCompilationStartAction(compilationContext =>
 1232        {
 1233            // source FQN → list of (target FQN, source type symbol, attribute location)
 734            var edges = new ConcurrentDictionary<string, ConcurrentBag<(string TargetFqn, INamedTypeSymbol SourceSymbol,
 735                StringComparer.Ordinal);
 1236
 737            compilationContext.RegisterSymbolAction(symbolContext =>
 738            {
 8039                var typeSymbol = (INamedTypeSymbol)symbolContext.Symbol;
 8040                var sourceFqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
 741
 33242                foreach (var attr in typeSymbol.GetAttributes())
 743                {
 8644                    if (attr.AttributeClass?.ToDisplayString() != AgentHandoffsToAttributeName)
 745                        continue;
 746
 1347                    if (attr.ConstructorArguments.Length < 1)
 748                        continue;
 749
 1350                    var typeArg = attr.ConstructorArguments[0];
 1351                    if (typeArg.Kind != TypedConstantKind.Type || typeArg.Value is not INamedTypeSymbol targetType)
 752                        continue;
 753
 1354                    var targetFqn = targetType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
 1355                    var attrLocation = attr.ApplicationSyntaxReference?.SyntaxTree is { } tree
 1356                        ? Location.Create(tree, attr.ApplicationSyntaxReference.Span)
 1357                        : typeSymbol.Locations[0];
 758
 2659                    edges.GetOrAdd(sourceFqn, _ => new ConcurrentBag<(string, INamedTypeSymbol, Location)>())
 1360                        .Add((targetFqn, typeSymbol, attrLocation));
 761                }
 8762            }, SymbolKind.NamedType);
 1263
 764            compilationContext.RegisterCompilationEndAction(endContext =>
 765            {
 766                // Build adjacency list: FQN → set of target FQNs
 767                var adjacency = edges.ToDictionary(
 1368                    kvp => kvp.Key,
 2669                    kvp => kvp.Value.Select(e => e.TargetFqn).Distinct().ToList(),
 770                    StringComparer.Ordinal);
 771
 772                // Map FQN → (source symbol, attribute locations) for diagnostics
 773                var symbolMap = new Dictionary<string, (INamedTypeSymbol Symbol, List<Location> Locations)>(StringCompar
 4074                foreach (var kvp in edges)
 775                {
 1376                    var entries = kvp.Value.ToList();
 2677                    symbolMap[kvp.Key] = (entries[0].SourceSymbol, entries.Select(e => e.Location).ToList());
 778                }
 779
 780                var visited = new HashSet<string>(StringComparer.Ordinal);
 781                var reported = new HashSet<string>(StringComparer.Ordinal);
 782
 4083                foreach (var start in adjacency.Keys)
 784                {
 1385                    if (visited.Contains(start))
 786                        continue;
 787
 788                    var path = new List<string>();
 789                    DetectCycle(start, adjacency, visited, path, reported, endContext, symbolMap);
 790                }
 1491            });
 1992        });
 1293    }
 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    {
 15104        path.Add(current);
 105
 15106        if (adjacency.TryGetValue(current, out var neighbors))
 107        {
 52108            foreach (var next in neighbors)
 109            {
 13110                var cycleIndex = path.IndexOf(next);
 13111                if (cycleIndex >= 0)
 112                {
 113                    // Found a cycle: path[cycleIndex..] + next
 4114                    var cycleNodes = path.Skip(cycleIndex).Concat([next]).ToList();
 4115                    var cycleKey = string.Join("→", cycleNodes.Select(ShortName));
 116
 117                    // Report on each node in the cycle that we have symbol info for
 28118                    foreach (var node in cycleNodes.Skip(0).Take(cycleNodes.Count - 1))
 119                    {
 10120                        if (!reported.Add(node))
 121                            continue;
 122
 10123                        if (!symbolMap.TryGetValue(node, out var info))
 124                            continue;
 125
 40126                        foreach (var location in info.Locations)
 127                        {
 10128                            context.ReportDiagnostic(Diagnostic.Create(
 10129                                MafDiagnosticDescriptors.CyclicHandoffChain,
 10130                                location,
 10131                                info.Symbol.Name,
 10132                                cycleKey));
 133                        }
 134                    }
 135                }
 9136                else if (!visited.Contains(next))
 137                {
 8138                    DetectCycle(next, adjacency, visited, path, reported, context, symbolMap);
 139                }
 140            }
 141        }
 142
 15143        path.RemoveAt(path.Count - 1);
 15144        visited.Add(current);
 15145    }
 146
 147    private static string ShortName(string fqn)
 148    {
 149        // Strip "global::" prefix and return just the last segment for readability
 14150        var clean = fqn.StartsWith("global::") ? fqn.Substring(8) : fqn;
 14151        var dot = clean.LastIndexOf('.');
 14152        return dot >= 0 ? clean.Substring(dot + 1) : clean;
 153    }
 154}