using StellaOps.Scanner.Contracts; using System.Collections.Immutable; namespace StellaOps.Scanner.CallGraph; /// /// Analyzes call graph reachability from entrypoints to sinks using BFS traversal. /// Provides deterministically-ordered paths suitable for witness generation. /// /// /// Sprint: SPRINT_3700_0001_0001 (WIT-007A, WIT-007B) /// Contract: Paths are ordered by (SinkId ASC, EntrypointId ASC, PathLength ASC). /// Node IDs within paths are ordered from entrypoint to sink (caller → callee). /// public sealed class ReachabilityAnalyzer { private readonly TimeProvider _timeProvider; private readonly ReachabilityAnalysisOptions _options; /// /// Creates a new ReachabilityAnalyzer with default options. /// public ReachabilityAnalyzer(TimeProvider? timeProvider = null, int maxDepth = 256) : this(timeProvider, new ReachabilityAnalysisOptions { MaxDepth = maxDepth }) { } /// /// Creates a new ReachabilityAnalyzer with specified options. /// public ReachabilityAnalyzer(TimeProvider? timeProvider, ReachabilityAnalysisOptions options) { _timeProvider = timeProvider ?? TimeProvider.System; _options = (options ?? ReachabilityAnalysisOptions.Default).Validated(); } /// /// Analyzes reachability using default options. /// public ReachabilityAnalysisResult Analyze(CallGraphSnapshot snapshot) => Analyze(snapshot, _options); /// /// Analyzes reachability with explicit options for this invocation. /// /// The call graph snapshot to analyze. /// Options controlling limits and output format. /// Analysis result with deterministically-ordered paths. public ReachabilityAnalysisResult Analyze(CallGraphSnapshot snapshot, ReachabilityAnalysisOptions options) { ArgumentNullException.ThrowIfNull(snapshot); var opts = (options ?? _options).Validated(); var trimmed = snapshot.Trimmed(); var adjacency = BuildAdjacency(trimmed); var entrypoints = trimmed.EntrypointIds; if (entrypoints.IsDefaultOrEmpty) { return EmptyResult(trimmed); } var origins = new Dictionary(StringComparer.Ordinal); var parents = new Dictionary(StringComparer.Ordinal); var depths = new Dictionary(StringComparer.Ordinal); var queue = new Queue(); foreach (var entry in entrypoints.OrderBy(e => e, StringComparer.Ordinal)) { origins[entry] = entry; parents[entry] = null; depths[entry] = 0; queue.Enqueue(entry); } while (queue.Count > 0) { var current = queue.Dequeue(); if (!depths.TryGetValue(current, out var depth)) { continue; } if (depth >= opts.MaxDepth) { continue; } if (!adjacency.TryGetValue(current, out var neighbors)) { continue; } foreach (var next in neighbors) { if (origins.ContainsKey(next)) { continue; } origins[next] = origins[current]; parents[next] = current; depths[next] = depth + 1; queue.Enqueue(next); } } var reachableNodes = origins.Keys.OrderBy(id => id, StringComparer.Ordinal).ToImmutableArray(); // WIT-007B: Use explicit sinks if specified, otherwise use snapshot sinks var targetSinks = opts.ExplicitSinks.HasValue && !opts.ExplicitSinks.Value.IsDefaultOrEmpty ? opts.ExplicitSinks.Value : trimmed.SinkIds; var reachableSinks = targetSinks .Where(origins.ContainsKey) .OrderBy(id => id, StringComparer.Ordinal) .ToImmutableArray(); var paths = BuildPaths(reachableSinks, origins, parents, opts); var computedAt = _timeProvider.GetUtcNow(); var provisional = new ReachabilityAnalysisResult( ScanId: trimmed.ScanId, GraphDigest: trimmed.GraphDigest, Language: trimmed.Language, ComputedAt: computedAt, ReachableNodeIds: reachableNodes, ReachableSinkIds: reachableSinks, Paths: paths, ResultDigest: string.Empty); var resultDigest = CallGraphDigests.ComputeResultDigest(provisional); return provisional with { ResultDigest = resultDigest }; } private static Dictionary> BuildAdjacency(CallGraphSnapshot snapshot) { var map = new Dictionary>(StringComparer.Ordinal); foreach (var edge in snapshot.Edges) { if (!map.TryGetValue(edge.SourceId, out var list)) { list = new List(); map[edge.SourceId] = list; } list.Add(edge.TargetId); } return map.ToDictionary( kvp => kvp.Key, kvp => kvp.Value .Where(v => !string.IsNullOrWhiteSpace(v)) .Distinct(StringComparer.Ordinal) .OrderBy(v => v, StringComparer.Ordinal) .ToImmutableArray(), StringComparer.Ordinal); } private static ReachabilityAnalysisResult EmptyResult(CallGraphSnapshot snapshot) { var computedAt = TimeProvider.System.GetUtcNow(); var provisional = new ReachabilityAnalysisResult( ScanId: snapshot.ScanId, GraphDigest: snapshot.GraphDigest, Language: snapshot.Language, ComputedAt: computedAt, ReachableNodeIds: ImmutableArray.Empty, ReachableSinkIds: ImmutableArray.Empty, Paths: ImmutableArray.Empty, ResultDigest: string.Empty); return provisional with { ResultDigest = CallGraphDigests.ComputeResultDigest(provisional) }; } private static ImmutableArray BuildPaths( ImmutableArray reachableSinks, Dictionary origins, Dictionary parents, ReachabilityAnalysisOptions options) { var paths = new List(reachableSinks.Length); var pathCountPerSink = new Dictionary(StringComparer.Ordinal); foreach (var sinkId in reachableSinks) { if (!origins.TryGetValue(sinkId, out var origin)) { continue; } // Enforce per-sink limit pathCountPerSink.TryGetValue(sinkId, out var currentCount); if (currentCount >= options.MaxPathsPerSink) { continue; } pathCountPerSink[sinkId] = currentCount + 1; var nodeIds = ReconstructPathNodeIds(sinkId, parents); paths.Add(new ReachabilityPath(origin, sinkId, nodeIds)); // Enforce total path limit if (paths.Count >= options.MaxTotalPaths) { break; } } // Deterministic ordering: SinkId ASC, EntrypointId ASC, PathLength ASC return paths .OrderBy(p => p.SinkId, StringComparer.Ordinal) .ThenBy(p => p.EntrypointId, StringComparer.Ordinal) .ThenBy(p => p.NodeIds.Length) .ToImmutableArray(); } private static ImmutableArray ReconstructPathNodeIds(string sinkId, Dictionary parents) { var stack = new Stack(); var cursor = sinkId; while (true) { stack.Push(cursor); if (!parents.TryGetValue(cursor, out var parent) || parent is null) { break; } cursor = parent; } var builder = ImmutableArray.CreateBuilder(stack.Count); while (stack.Count > 0) { builder.Add(stack.Pop()); } return builder.ToImmutable(); } }