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();
}
}