work work hard work

This commit is contained in:
StellaOps Bot
2025-12-18 00:47:24 +02:00
parent dee252940b
commit b4235c134c
189 changed files with 9627 additions and 3258 deletions

View File

@@ -0,0 +1,181 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.CallGraph;
public sealed class ReachabilityAnalyzer
{
private readonly TimeProvider _timeProvider;
private readonly int _maxDepth;
public ReachabilityAnalyzer(TimeProvider? timeProvider = null, int maxDepth = 256)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_maxDepth = maxDepth <= 0 ? 256 : maxDepth;
}
public ReachabilityAnalysisResult Analyze(CallGraphSnapshot snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
var trimmed = snapshot.Trimmed();
var adjacency = BuildAdjacency(trimmed);
var entrypoints = trimmed.EntrypointIds;
if (entrypoints.IsDefaultOrEmpty)
{
return EmptyResult(trimmed);
}
var origins = new Dictionary<string, string>(StringComparer.Ordinal);
var parents = new Dictionary<string, string?>(StringComparer.Ordinal);
var depths = new Dictionary<string, int>(StringComparer.Ordinal);
var queue = new Queue<string>();
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 >= _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();
var reachableSinks = trimmed.SinkIds
.Where(origins.ContainsKey)
.OrderBy(id => id, StringComparer.Ordinal)
.ToImmutableArray();
var paths = BuildPaths(reachableSinks, origins, parents);
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<string, ImmutableArray<string>> BuildAdjacency(CallGraphSnapshot snapshot)
{
var map = new Dictionary<string, List<string>>(StringComparer.Ordinal);
foreach (var edge in snapshot.Edges)
{
if (!map.TryGetValue(edge.SourceId, out var list))
{
list = new List<string>();
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<string>.Empty,
ReachableSinkIds: ImmutableArray<string>.Empty,
Paths: ImmutableArray<ReachabilityPath>.Empty,
ResultDigest: string.Empty);
return provisional with { ResultDigest = CallGraphDigests.ComputeResultDigest(provisional) };
}
private static ImmutableArray<ReachabilityPath> BuildPaths(
ImmutableArray<string> reachableSinks,
Dictionary<string, string> origins,
Dictionary<string, string?> parents)
{
var paths = new List<ReachabilityPath>(reachableSinks.Length);
foreach (var sinkId in reachableSinks)
{
if (!origins.TryGetValue(sinkId, out var origin))
{
continue;
}
var nodeIds = ReconstructPathNodeIds(sinkId, parents);
paths.Add(new ReachabilityPath(origin, sinkId, nodeIds));
}
return paths
.OrderBy(p => p.SinkId, StringComparer.Ordinal)
.ThenBy(p => p.EntrypointId, StringComparer.Ordinal)
.ToImmutableArray();
}
private static ImmutableArray<string> ReconstructPathNodeIds(string sinkId, Dictionary<string, string?> parents)
{
var stack = new Stack<string>();
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<string>(stack.Count);
while (stack.Count > 0)
{
builder.Add(stack.Pop());
}
return builder.ToImmutable();
}
}

View File

@@ -0,0 +1,25 @@
using Microsoft.Extensions.Configuration;
namespace StellaOps.Scanner.CallGraph.Caching;
public sealed class CallGraphCacheConfig
{
[ConfigurationKeyName("enabled")]
public bool Enabled { get; set; } = true;
[ConfigurationKeyName("connection_string")]
public string ConnectionString { get; set; } = string.Empty;
[ConfigurationKeyName("key_prefix")]
public string KeyPrefix { get; set; } = "callgraph:";
[ConfigurationKeyName("ttl_seconds")]
public int TtlSeconds { get; set; } = 3600;
[ConfigurationKeyName("gzip")]
public bool EnableGzip { get; set; } = true;
[ConfigurationKeyName("circuit_breaker")]
public CircuitBreakerConfig CircuitBreaker { get; set; } = new();
}

View File

@@ -0,0 +1,16 @@
using Microsoft.Extensions.Configuration;
namespace StellaOps.Scanner.CallGraph.Caching;
public sealed class CircuitBreakerConfig
{
[ConfigurationKeyName("failure_threshold")]
public int FailureThreshold { get; set; } = 5;
[ConfigurationKeyName("timeout_seconds")]
public int TimeoutSeconds { get; set; } = 30;
[ConfigurationKeyName("half_open_timeout")]
public int HalfOpenTimeout { get; set; } = 10;
}

View File

@@ -0,0 +1,133 @@
namespace StellaOps.Scanner.CallGraph.Caching;
public enum CircuitState
{
Closed,
Open,
HalfOpen
}
public sealed class CircuitBreakerState
{
private readonly object _lock = new();
private readonly TimeProvider _timeProvider;
private readonly int _failureThreshold;
private readonly TimeSpan _openTimeout;
private readonly TimeSpan _halfOpenTimeout;
private CircuitState _state = CircuitState.Closed;
private int _failureCount;
private DateTimeOffset _openedAt;
public CircuitBreakerState(CircuitBreakerConfig config, TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(config);
_timeProvider = timeProvider ?? TimeProvider.System;
_failureThreshold = Math.Max(1, config.FailureThreshold);
_openTimeout = TimeSpan.FromSeconds(Math.Max(1, config.TimeoutSeconds));
_halfOpenTimeout = TimeSpan.FromSeconds(Math.Max(1, config.HalfOpenTimeout));
}
public CircuitState State
{
get
{
lock (_lock)
{
UpdateState();
return _state;
}
}
}
public bool IsOpen
{
get
{
lock (_lock)
{
UpdateState();
return _state == CircuitState.Open;
}
}
}
public bool IsHalfOpen
{
get
{
lock (_lock)
{
UpdateState();
return _state == CircuitState.HalfOpen;
}
}
}
public void RecordSuccess()
{
lock (_lock)
{
if (_state is CircuitState.HalfOpen or CircuitState.Open)
{
_state = CircuitState.Closed;
}
_failureCount = 0;
}
}
public void RecordFailure()
{
lock (_lock)
{
var now = _timeProvider.GetUtcNow();
if (_state == CircuitState.HalfOpen)
{
_state = CircuitState.Open;
_openedAt = now;
_failureCount = _failureThreshold;
return;
}
_failureCount++;
if (_failureCount >= _failureThreshold)
{
_state = CircuitState.Open;
_openedAt = now;
}
}
}
public void Reset()
{
lock (_lock)
{
_state = CircuitState.Closed;
_failureCount = 0;
}
}
private void UpdateState()
{
var now = _timeProvider.GetUtcNow();
if (_state == CircuitState.Open)
{
if (now - _openedAt >= _openTimeout)
{
_state = CircuitState.HalfOpen;
}
}
else if (_state == CircuitState.HalfOpen)
{
if (now - _openedAt >= _openTimeout + _halfOpenTimeout)
{
_state = CircuitState.Open;
_openedAt = now;
}
}
}
}

View File

@@ -0,0 +1,13 @@
namespace StellaOps.Scanner.CallGraph.Caching;
public interface ICallGraphCacheService
{
ValueTask<CallGraphSnapshot?> TryGetCallGraphAsync(string scanId, string language, CancellationToken cancellationToken = default);
Task SetCallGraphAsync(CallGraphSnapshot snapshot, TimeSpan? ttl = null, CancellationToken cancellationToken = default);
ValueTask<ReachabilityAnalysisResult?> TryGetReachabilityResultAsync(string scanId, string language, CancellationToken cancellationToken = default);
Task SetReachabilityResultAsync(ReachabilityAnalysisResult result, TimeSpan? ttl = null, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,242 @@
using System.IO.Compression;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
namespace StellaOps.Scanner.CallGraph.Caching;
public sealed class ValkeyCallGraphCacheService : ICallGraphCacheService, IAsyncDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false
};
private readonly CallGraphCacheConfig _options;
private readonly ILogger<ValkeyCallGraphCacheService> _logger;
private readonly TimeProvider _timeProvider;
private readonly CircuitBreakerState _circuitBreaker;
private readonly SemaphoreSlim _connectionLock = new(1, 1);
private readonly Func<ConfigurationOptions, Task<IConnectionMultiplexer>> _connectionFactory;
private IConnectionMultiplexer? _connection;
public ValkeyCallGraphCacheService(
IOptions<CallGraphCacheConfig> options,
ILogger<ValkeyCallGraphCacheService> logger,
TimeProvider? timeProvider = null,
Func<ConfigurationOptions, Task<IConnectionMultiplexer>>? connectionFactory = null)
{
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
_connectionFactory = connectionFactory ?? (config => Task.FromResult<IConnectionMultiplexer>(ConnectionMultiplexer.Connect(config)));
_circuitBreaker = new CircuitBreakerState(_options.CircuitBreaker, _timeProvider);
}
public async ValueTask<CallGraphSnapshot?> TryGetCallGraphAsync(string scanId, string language, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
ArgumentException.ThrowIfNullOrWhiteSpace(language);
if (!IsEnabled())
{
return null;
}
var key = BuildKey(scanId, language, kind: "graph");
var payload = await TryGetBytesAsync(key, cancellationToken).ConfigureAwait(false);
if (payload is null)
{
return null;
}
try
{
var bytes = _options.EnableGzip ? Inflate(payload) : payload;
return JsonSerializer.Deserialize<CallGraphSnapshot>(bytes, JsonOptions);
}
catch (Exception ex) when (ex is JsonException or InvalidDataException)
{
_logger.LogWarning(ex, "Failed to deserialize cached call graph for {ScanId}/{Language}", scanId, language);
return null;
}
}
public async Task SetCallGraphAsync(CallGraphSnapshot snapshot, TimeSpan? ttl = null, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(snapshot);
if (!IsEnabled())
{
return;
}
var key = BuildKey(snapshot.ScanId, snapshot.Language, kind: "graph");
var bytes = JsonSerializer.SerializeToUtf8Bytes(snapshot.Trimmed(), JsonOptions);
var payload = _options.EnableGzip ? Deflate(bytes) : bytes;
await SetBytesAsync(key, payload, ttl, cancellationToken).ConfigureAwait(false);
}
public async ValueTask<ReachabilityAnalysisResult?> TryGetReachabilityResultAsync(string scanId, string language, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
ArgumentException.ThrowIfNullOrWhiteSpace(language);
if (!IsEnabled())
{
return null;
}
var key = BuildKey(scanId, language, kind: "reachability");
var payload = await TryGetBytesAsync(key, cancellationToken).ConfigureAwait(false);
if (payload is null)
{
return null;
}
try
{
var bytes = _options.EnableGzip ? Inflate(payload) : payload;
return JsonSerializer.Deserialize<ReachabilityAnalysisResult>(bytes, JsonOptions);
}
catch (Exception ex) when (ex is JsonException or InvalidDataException)
{
_logger.LogWarning(ex, "Failed to deserialize cached reachability result for {ScanId}/{Language}", scanId, language);
return null;
}
}
public async Task SetReachabilityResultAsync(ReachabilityAnalysisResult result, TimeSpan? ttl = null, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(result);
if (!IsEnabled())
{
return;
}
var key = BuildKey(result.ScanId, result.Language, kind: "reachability");
var bytes = JsonSerializer.SerializeToUtf8Bytes(result.Trimmed(), JsonOptions);
var payload = _options.EnableGzip ? Deflate(bytes) : bytes;
await SetBytesAsync(key, payload, ttl, cancellationToken).ConfigureAwait(false);
}
public async ValueTask DisposeAsync()
{
if (_connection is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync().ConfigureAwait(false);
}
else
{
_connection?.Dispose();
}
}
private bool IsEnabled()
{
if (!_options.Enabled)
{
return false;
}
if (_circuitBreaker.IsOpen)
{
_logger.LogWarning("Call graph cache circuit breaker is open; bypassing Valkey.");
return false;
}
return !string.IsNullOrWhiteSpace(_options.ConnectionString);
}
private async ValueTask<byte[]?> TryGetBytesAsync(string key, CancellationToken cancellationToken)
{
try
{
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
var value = await db.StringGetAsync(key).ConfigureAwait(false);
_circuitBreaker.RecordSuccess();
return value.IsNull ? null : (byte[]?)value;
}
catch (Exception ex)
{
_logger.LogError(ex, "Valkey cache GET failed for key {Key}", key);
_circuitBreaker.RecordFailure();
return null;
}
}
private async Task SetBytesAsync(string key, byte[] payload, TimeSpan? ttl, CancellationToken cancellationToken)
{
var effectiveTtl = ttl ?? TimeSpan.FromSeconds(Math.Max(1, _options.TtlSeconds));
try
{
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await db.StringSetAsync(key, payload, expiry: effectiveTtl).ConfigureAwait(false);
_circuitBreaker.RecordSuccess();
}
catch (Exception ex)
{
_logger.LogError(ex, "Valkey cache SET failed for key {Key}", key);
_circuitBreaker.RecordFailure();
}
}
private async Task<IDatabase> GetDatabaseAsync(CancellationToken cancellationToken)
{
var connection = await GetConnectionAsync(cancellationToken).ConfigureAwait(false);
return connection.GetDatabase();
}
private async Task<IConnectionMultiplexer> GetConnectionAsync(CancellationToken cancellationToken)
{
if (_connection is not null)
{
return _connection;
}
await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_connection is not null)
{
return _connection;
}
var config = ConfigurationOptions.Parse(_options.ConnectionString);
_connection = await _connectionFactory(config).ConfigureAwait(false);
return _connection;
}
finally
{
_connectionLock.Release();
}
}
private string BuildKey(string scanId, string language, string kind)
=> $"{_options.KeyPrefix}{kind}:{scanId.Trim()}:{language.Trim().ToLowerInvariant()}";
private static byte[] Deflate(byte[] payload)
{
using var output = new MemoryStream();
using (var gzip = new GZipStream(output, CompressionLevel.SmallestSize, leaveOpen: true))
{
gzip.Write(payload, 0, payload.Length);
}
return output.ToArray();
}
private static byte[] Inflate(byte[] payload)
{
using var input = new MemoryStream(payload);
using var gzip = new GZipStream(input, CompressionMode.Decompress);
using var output = new MemoryStream();
gzip.CopyTo(output);
return output.ToArray();
}
}

View File

@@ -0,0 +1,27 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Scanner.CallGraph.Caching;
using StellaOps.Scanner.CallGraph.DotNet;
using StellaOps.Scanner.CallGraph.Node;
namespace StellaOps.Scanner.CallGraph.DependencyInjection;
public static class CallGraphServiceCollectionExtensions
{
public static IServiceCollection AddCallGraphServices(this IServiceCollection services, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services.Configure<CallGraphCacheConfig>(configuration.GetSection("CallGraph:Cache"));
services.AddSingleton<ICallGraphExtractor, DotNetCallGraphExtractor>();
services.AddSingleton<ICallGraphExtractor, NodeCallGraphExtractor>();
services.AddSingleton<ReachabilityAnalyzer>();
services.AddSingleton<ICallGraphCacheService, ValkeyCallGraphCacheService>();
return services;
}
}

View File

@@ -0,0 +1,413 @@
using System.Collections.Immutable;
using Microsoft.Build.Locator;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.MSBuild;
using StellaOps.Scanner.Reachability;
namespace StellaOps.Scanner.CallGraph.DotNet;
public sealed class DotNetCallGraphExtractor : ICallGraphExtractor
{
private readonly TimeProvider _timeProvider;
public DotNetCallGraphExtractor(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public string Language => "dotnet";
public async Task<CallGraphSnapshot> ExtractAsync(CallGraphExtractionRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
if (!string.Equals(request.Language, Language, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException($"Expected language '{Language}', got '{request.Language}'.", nameof(request));
}
var resolvedTarget = ResolveTargetPath(request.TargetPath);
if (resolvedTarget is null)
{
throw new FileNotFoundException($"Unable to locate a .sln or .csproj at '{request.TargetPath}'.");
}
var analysisRoot = Path.GetDirectoryName(resolvedTarget) ?? Directory.GetCurrentDirectory();
EnsureMsBuildRegistered();
using var workspace = MSBuildWorkspace.Create();
workspace.WorkspaceFailed += (_, _) => { };
var solution = resolvedTarget.EndsWith(".sln", StringComparison.OrdinalIgnoreCase)
? await workspace.OpenSolutionAsync(resolvedTarget, cancellationToken).ConfigureAwait(false)
: (await workspace.OpenProjectAsync(resolvedTarget, cancellationToken).ConfigureAwait(false)).Solution;
var nodesById = new Dictionary<string, CallGraphNode>(StringComparer.Ordinal);
var edges = new HashSet<CallGraphEdge>(CallGraphEdgeComparer.Instance);
foreach (var project in solution.Projects.OrderBy(p => p.FilePath ?? p.Name, StringComparer.Ordinal))
{
cancellationToken.ThrowIfCancellationRequested();
foreach (var document in project.Documents.OrderBy(d => d.FilePath ?? d.Name, StringComparer.Ordinal))
{
cancellationToken.ThrowIfCancellationRequested();
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
if (root is null)
{
continue;
}
var model = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);
if (model is null)
{
continue;
}
foreach (var methodSyntax in root.DescendantNodes().OfType<MethodDeclarationSyntax>())
{
var methodSymbol = model.GetDeclaredSymbol(methodSyntax, cancellationToken);
if (methodSymbol is null)
{
continue;
}
var methodNode = CreateMethodNode(analysisRoot, methodSymbol, methodSyntax);
nodesById[methodNode.NodeId] = methodNode;
foreach (var invocation in methodSyntax.DescendantNodes().OfType<InvocationExpressionSyntax>())
{
var invoked = model.GetSymbolInfo(invocation, cancellationToken).Symbol as IMethodSymbol;
if (invoked is null)
{
continue;
}
var targetNode = CreateInvokedNode(analysisRoot, invoked);
nodesById.TryAdd(targetNode.NodeId, targetNode);
edges.Add(new CallGraphEdge(
SourceId: methodNode.NodeId,
TargetId: targetNode.NodeId,
CallKind: ClassifyCallKind(invoked),
CallSite: FormatCallSite(analysisRoot, invocation)));
}
}
}
}
var nodes = nodesById.Values
.Select(n => n.Trimmed())
.OrderBy(n => n.NodeId, StringComparer.Ordinal)
.ToImmutableArray();
var entrypoints = nodes
.Where(n => n.IsEntrypoint)
.Select(n => n.NodeId)
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.ToImmutableArray();
var sinks = nodes
.Where(n => n.IsSink)
.Select(n => n.NodeId)
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.ToImmutableArray();
var orderedEdges = edges
.Select(e => e.Trimmed())
.OrderBy(e => e.SourceId, StringComparer.Ordinal)
.ThenBy(e => e.TargetId, StringComparer.Ordinal)
.ThenBy(e => e.CallKind.ToString(), StringComparer.Ordinal)
.ThenBy(e => e.CallSite ?? string.Empty, StringComparer.Ordinal)
.ToImmutableArray();
var extractedAt = _timeProvider.GetUtcNow();
var provisional = new CallGraphSnapshot(
ScanId: request.ScanId,
GraphDigest: string.Empty,
Language: Language,
ExtractedAt: extractedAt,
Nodes: nodes,
Edges: orderedEdges,
EntrypointIds: entrypoints,
SinkIds: sinks);
var digest = CallGraphDigests.ComputeGraphDigest(provisional);
return provisional with { GraphDigest = digest };
}
private static void EnsureMsBuildRegistered()
{
if (MSBuildLocator.IsRegistered)
{
return;
}
MSBuildLocator.RegisterDefaults();
}
private static string? ResolveTargetPath(string targetPath)
{
if (string.IsNullOrWhiteSpace(targetPath))
{
return null;
}
var path = Path.GetFullPath(targetPath);
if (File.Exists(path) && (path.EndsWith(".sln", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)))
{
return path;
}
if (Directory.Exists(path))
{
var sln = Directory.EnumerateFiles(path, "*.sln", SearchOption.TopDirectoryOnly)
.OrderBy(p => p, StringComparer.Ordinal)
.FirstOrDefault();
if (sln is not null)
{
return sln;
}
var csproj = Directory.EnumerateFiles(path, "*.csproj", SearchOption.AllDirectories)
.OrderBy(p => p, StringComparer.Ordinal)
.FirstOrDefault();
return csproj;
}
return null;
}
private static CallKind ClassifyCallKind(IMethodSymbol invoked)
{
if (invoked.MethodKind == MethodKind.DelegateInvoke)
{
return CallKind.Delegate;
}
if (invoked.IsVirtual || invoked.IsAbstract || invoked.IsOverride)
{
return CallKind.Virtual;
}
return CallKind.Direct;
}
private static CallGraphNode CreateMethodNode(string analysisRoot, IMethodSymbol method, MethodDeclarationSyntax syntax)
{
var id = CallGraphNodeIds.Compute(GetStableSymbolId(method));
var (file, line) = GetSourceLocation(analysisRoot, syntax.GetLocation());
var (isEntrypoint, entryType) = EntrypointClassifier.IsEntrypoint(method);
return new CallGraphNode(
NodeId: id,
Symbol: method.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
File: file,
Line: line,
Package: method.ContainingAssembly?.Name ?? "unknown",
Visibility: MapVisibility(method.DeclaredAccessibility),
IsEntrypoint: isEntrypoint,
EntrypointType: entryType,
IsSink: false,
SinkCategory: null);
}
private static CallGraphNode CreateInvokedNode(string analysisRoot, IMethodSymbol method)
{
var id = CallGraphNodeIds.Compute(GetStableSymbolId(method));
var definitionLocation = method.Locations.FirstOrDefault(l => l.IsInSource) ?? Location.None;
var (file, line) = GetSourceLocation(analysisRoot, definitionLocation);
var sink = SinkRegistry.MatchSink("dotnet", method.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat));
return new CallGraphNode(
NodeId: id,
Symbol: method.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
File: file,
Line: line,
Package: method.ContainingAssembly?.Name ?? "unknown",
Visibility: MapVisibility(method.DeclaredAccessibility),
IsEntrypoint: false,
EntrypointType: null,
IsSink: sink is not null,
SinkCategory: sink?.Category);
}
private static Visibility MapVisibility(Accessibility accessibility)
{
return accessibility switch
{
Accessibility.Public => Visibility.Public,
Accessibility.Internal => Visibility.Internal,
Accessibility.Protected => Visibility.Protected,
_ => Visibility.Private
};
}
private static (string File, int Line) GetSourceLocation(string analysisRoot, Location location)
{
if (location is null || !location.IsInSource || location.SourceTree is null)
{
return (string.Empty, 0);
}
var span = location.GetLineSpan();
var relative = Path.GetRelativePath(analysisRoot, span.Path ?? string.Empty);
if (relative.StartsWith("..", StringComparison.Ordinal))
{
relative = Path.GetFileName(span.Path ?? string.Empty);
}
var file = relative.Replace('\\', '/');
var line = span.StartLinePosition.Line + 1;
return (file, line);
}
private static string? FormatCallSite(string analysisRoot, InvocationExpressionSyntax invocation)
{
var location = invocation.GetLocation();
if (location is null || !location.IsInSource || location.SourceTree is null)
{
return null;
}
var span = location.GetLineSpan();
var relative = Path.GetRelativePath(analysisRoot, span.Path ?? string.Empty);
if (relative.StartsWith("..", StringComparison.Ordinal))
{
relative = Path.GetFileName(span.Path ?? string.Empty);
}
var file = relative.Replace('\\', '/');
var line = span.StartLinePosition.Line + 1;
if (string.IsNullOrWhiteSpace(file) || line <= 0)
{
return null;
}
return $"{file}:{line}";
}
private static string GetStableSymbolId(IMethodSymbol method)
{
var docId = method.GetDocumentationCommentId();
if (!string.IsNullOrWhiteSpace(docId))
{
return $"dotnet:{method.ContainingAssembly?.Name}:{docId}";
}
return $"dotnet:{method.ContainingAssembly?.Name}:{method.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}";
}
private sealed class CallGraphEdgeComparer : IEqualityComparer<CallGraphEdge>
{
public static readonly CallGraphEdgeComparer Instance = new();
public bool Equals(CallGraphEdge? x, CallGraphEdge? y)
{
if (ReferenceEquals(x, y))
{
return true;
}
if (x is null || y is null)
{
return false;
}
return string.Equals(x.SourceId, y.SourceId, StringComparison.Ordinal)
&& string.Equals(x.TargetId, y.TargetId, StringComparison.Ordinal)
&& x.CallKind == y.CallKind
&& string.Equals(x.CallSite ?? string.Empty, y.CallSite ?? string.Empty, StringComparison.Ordinal);
}
public int GetHashCode(CallGraphEdge obj)
{
return HashCode.Combine(
obj.SourceId,
obj.TargetId,
obj.CallKind,
obj.CallSite ?? string.Empty);
}
}
}
internal static class EntrypointClassifier
{
private static readonly HashSet<string> HttpMethodAttributes = new(StringComparer.Ordinal)
{
"HttpGetAttribute",
"HttpPostAttribute",
"HttpPutAttribute",
"HttpDeleteAttribute",
"HttpPatchAttribute",
"RouteAttribute"
};
public static (bool IsEntrypoint, EntrypointType? Type) IsEntrypoint(IMethodSymbol method)
{
if (method is null)
{
return (false, null);
}
// Main()
if (method.IsStatic && method.Name == "Main" && method.ContainingType is not null)
{
return (true, EntrypointType.CliCommand);
}
// ASP.NET attributes
foreach (var attribute in method.GetAttributes())
{
var name = attribute.AttributeClass?.Name;
if (name is not null && HttpMethodAttributes.Contains(name))
{
return (true, EntrypointType.HttpHandler);
}
}
// Hosted services
if (method.ContainingType is not null)
{
var type = method.ContainingType;
if (type.AllInterfaces.Any(i => i.ToDisplayString() == "Microsoft.Extensions.Hosting.IHostedService")
|| DerivesFrom(type, "Microsoft.Extensions.Hosting.BackgroundService"))
{
if (method.Name is "StartAsync" or "ExecuteAsync")
{
return (true, EntrypointType.BackgroundJob);
}
}
// gRPC base type
if (DerivesFrom(type, "Grpc.Core.BindableService") || DerivesFrom(type, "Grpc.AspNetCore.Server.BindableService"))
{
if (method.DeclaredAccessibility == Accessibility.Public)
{
return (true, EntrypointType.GrpcMethod);
}
}
}
return (false, null);
}
private static bool DerivesFrom(INamedTypeSymbol type, string fullName)
{
var current = type.BaseType;
while (current is not null)
{
if (current.ToDisplayString() == fullName)
{
return true;
}
current = current.BaseType;
}
return false;
}
}

View File

@@ -0,0 +1,14 @@
namespace StellaOps.Scanner.CallGraph;
public sealed record CallGraphExtractionRequest(
string ScanId,
string Language,
string TargetPath);
public interface ICallGraphExtractor
{
string Language { get; }
Task<CallGraphSnapshot> ExtractAsync(CallGraphExtractionRequest request, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,212 @@
using System.Collections.Immutable;
using System.Text.Json;
using StellaOps.Scanner.Reachability;
namespace StellaOps.Scanner.CallGraph.Node;
/// <summary>
/// Placeholder Node.js call graph extractor.
/// Babel integration is planned; this implementation is intentionally minimal.
/// </summary>
public sealed class NodeCallGraphExtractor : ICallGraphExtractor
{
private readonly TimeProvider _timeProvider;
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public NodeCallGraphExtractor(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public string Language => "node";
public async Task<CallGraphSnapshot> ExtractAsync(CallGraphExtractionRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
if (!string.Equals(request.Language, Language, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException($"Expected language '{Language}', got '{request.Language}'.", nameof(request));
}
var tracePath = ResolveTracePath(request.TargetPath);
if (tracePath is not null && File.Exists(tracePath))
{
try
{
await using var stream = File.OpenRead(tracePath);
var trace = await JsonSerializer.DeserializeAsync<TraceDocument>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
if (trace is not null)
{
return BuildFromTrace(request.ScanId, trace);
}
}
catch (Exception ex) when (ex is IOException or JsonException)
{
// fall through to empty snapshot
}
}
var extractedAt = _timeProvider.GetUtcNow();
var provisional = new CallGraphSnapshot(
ScanId: request.ScanId,
GraphDigest: string.Empty,
Language: Language,
ExtractedAt: extractedAt,
Nodes: ImmutableArray<CallGraphNode>.Empty,
Edges: ImmutableArray<CallGraphEdge>.Empty,
EntrypointIds: ImmutableArray<string>.Empty,
SinkIds: ImmutableArray<string>.Empty);
var digest = CallGraphDigests.ComputeGraphDigest(provisional);
return provisional with { GraphDigest = digest };
}
private CallGraphSnapshot BuildFromTrace(string scanId, TraceDocument trace)
{
var extractedAt = _timeProvider.GetUtcNow();
var nodes = new List<CallGraphNode>();
var edges = new List<CallGraphEdge>();
var entrySymbol = trace.Entry?.Trim() ?? "unknown_entry";
var entryId = CallGraphNodeIds.Compute($"node:entry:{entrySymbol}");
nodes.Add(new CallGraphNode(
NodeId: entryId,
Symbol: entrySymbol,
File: string.Empty,
Line: 0,
Package: "app",
Visibility: Visibility.Public,
IsEntrypoint: true,
EntrypointType: EntrypointType.HttpHandler,
IsSink: false,
SinkCategory: null));
var path = trace.Path ?? Array.Empty<string>();
var previousId = entryId;
foreach (var raw in path)
{
var symbol = raw?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(symbol))
{
continue;
}
var nodeId = CallGraphNodeIds.Compute($"node:{symbol}");
var (file, line) = ParseFileLine(symbol);
var sink = SinkRegistry.MatchSink("node", symbol);
nodes.Add(new CallGraphNode(
NodeId: nodeId,
Symbol: symbol,
File: file,
Line: line,
Package: "app",
Visibility: Visibility.Public,
IsEntrypoint: false,
EntrypointType: null,
IsSink: sink is not null,
SinkCategory: sink?.Category));
edges.Add(new CallGraphEdge(previousId, nodeId, CallKind.Direct));
previousId = nodeId;
}
var distinctNodes = nodes
.GroupBy(n => n.NodeId, StringComparer.Ordinal)
.Select(g => g.First())
.OrderBy(n => n.NodeId, StringComparer.Ordinal)
.ToImmutableArray();
var distinctEdges = edges
.Distinct(CallGraphEdgeStructuralComparer.Instance)
.OrderBy(e => e.SourceId, StringComparer.Ordinal)
.ThenBy(e => e.TargetId, StringComparer.Ordinal)
.ToImmutableArray();
var sinkIds = distinctNodes
.Where(n => n.IsSink)
.Select(n => n.NodeId)
.OrderBy(id => id, StringComparer.Ordinal)
.ToImmutableArray();
var provisional = new CallGraphSnapshot(
ScanId: scanId,
GraphDigest: string.Empty,
Language: Language,
ExtractedAt: extractedAt,
Nodes: distinctNodes,
Edges: distinctEdges,
EntrypointIds: ImmutableArray.Create(entryId),
SinkIds: sinkIds);
return provisional with { GraphDigest = CallGraphDigests.ComputeGraphDigest(provisional) };
}
private static (string File, int Line) ParseFileLine(string symbol)
{
// Common benchmark shape: "app.js:handleRequest" or "app.js::createServer"
var idx = symbol.IndexOf(".js", StringComparison.OrdinalIgnoreCase);
if (idx < 0)
{
return (string.Empty, 0);
}
var end = idx + 3;
var file = symbol[..end].Replace('\\', '/');
return (file, 0);
}
private static string? ResolveTracePath(string targetPath)
{
if (string.IsNullOrWhiteSpace(targetPath))
{
return null;
}
var path = Path.GetFullPath(targetPath);
if (File.Exists(path))
{
return path;
}
if (Directory.Exists(path))
{
var candidate = Path.Combine(path, "outputs", "traces", "traces.json");
if (File.Exists(candidate))
{
return candidate;
}
}
return null;
}
private sealed record TraceDocument(string Entry, string[] Path, string Sink, string Notes);
private sealed class CallGraphEdgeStructuralComparer : IEqualityComparer<CallGraphEdge>
{
public static readonly CallGraphEdgeStructuralComparer Instance = new();
public bool Equals(CallGraphEdge? x, CallGraphEdge? y)
{
if (ReferenceEquals(x, y))
{
return true;
}
if (x is null || y is null)
{
return false;
}
return string.Equals(x.SourceId, y.SourceId, StringComparison.Ordinal)
&& string.Equals(x.TargetId, y.TargetId, StringComparison.Ordinal)
&& x.CallKind == y.CallKind;
}
public int GetHashCode(CallGraphEdge obj)
=> HashCode.Combine(obj.SourceId, obj.TargetId, obj.CallKind);
}
}

View File

@@ -0,0 +1,367 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Scanner.Reachability;
namespace StellaOps.Scanner.CallGraph;
public sealed record CallGraphSnapshot(
[property: JsonPropertyName("scanId")] string ScanId,
[property: JsonPropertyName("graphDigest")] string GraphDigest,
[property: JsonPropertyName("language")] string Language,
[property: JsonPropertyName("extractedAt")] DateTimeOffset ExtractedAt,
[property: JsonPropertyName("nodes")] ImmutableArray<CallGraphNode> Nodes,
[property: JsonPropertyName("edges")] ImmutableArray<CallGraphEdge> Edges,
[property: JsonPropertyName("entrypointIds")] ImmutableArray<string> EntrypointIds,
[property: JsonPropertyName("sinkIds")] ImmutableArray<string> SinkIds)
{
public CallGraphSnapshot Trimmed()
{
var nodes = (Nodes.IsDefault ? ImmutableArray<CallGraphNode>.Empty : Nodes)
.Where(n => !string.IsNullOrWhiteSpace(n.NodeId))
.Select(n => n.Trimmed())
.OrderBy(n => n.NodeId, StringComparer.Ordinal)
.ToImmutableArray();
var edges = (Edges.IsDefault ? ImmutableArray<CallGraphEdge>.Empty : Edges)
.Where(e => !string.IsNullOrWhiteSpace(e.SourceId) && !string.IsNullOrWhiteSpace(e.TargetId))
.Select(e => e.Trimmed())
.OrderBy(e => e.SourceId, StringComparer.Ordinal)
.ThenBy(e => e.TargetId, StringComparer.Ordinal)
.ThenBy(e => e.CallKind.ToString(), StringComparer.Ordinal)
.ThenBy(e => e.CallSite ?? string.Empty, StringComparer.Ordinal)
.ToImmutableArray();
var entrypoints = (EntrypointIds.IsDefault ? ImmutableArray<string>.Empty : EntrypointIds)
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.ToImmutableArray();
var sinks = (SinkIds.IsDefault ? ImmutableArray<string>.Empty : SinkIds)
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.ToImmutableArray();
return this with
{
ScanId = ScanId?.Trim() ?? string.Empty,
GraphDigest = GraphDigest?.Trim() ?? string.Empty,
Language = Language?.Trim() ?? string.Empty,
Nodes = nodes,
Edges = edges,
EntrypointIds = entrypoints,
SinkIds = sinks
};
}
}
public sealed record CallGraphNode(
[property: JsonPropertyName("nodeId")] string NodeId,
[property: JsonPropertyName("symbol")] string Symbol,
[property: JsonPropertyName("file")] string File,
[property: JsonPropertyName("line")] int Line,
[property: JsonPropertyName("package")] string Package,
[property: JsonPropertyName("visibility")] Visibility Visibility,
[property: JsonPropertyName("isEntrypoint")] bool IsEntrypoint,
[property: JsonPropertyName("entrypointType")] EntrypointType? EntrypointType,
[property: JsonPropertyName("isSink")] bool IsSink,
[property: JsonPropertyName("sinkCategory")] SinkCategory? SinkCategory)
{
public CallGraphNode Trimmed()
=> this with
{
NodeId = NodeId?.Trim() ?? string.Empty,
Symbol = Symbol?.Trim() ?? string.Empty,
File = File?.Trim() ?? string.Empty,
Package = Package?.Trim() ?? string.Empty
};
}
public sealed record CallGraphEdge(
[property: JsonPropertyName("sourceId")] string SourceId,
[property: JsonPropertyName("targetId")] string TargetId,
[property: JsonPropertyName("callKind")] CallKind CallKind,
[property: JsonPropertyName("callSite")] string? CallSite = null)
{
public CallGraphEdge Trimmed()
=> this with
{
SourceId = SourceId?.Trim() ?? string.Empty,
TargetId = TargetId?.Trim() ?? string.Empty,
CallSite = string.IsNullOrWhiteSpace(CallSite) ? null : CallSite.Trim()
};
}
[JsonConverter(typeof(JsonStringEnumConverter<Visibility>))]
public enum Visibility
{
Public,
Internal,
Protected,
Private
}
[JsonConverter(typeof(JsonStringEnumConverter<CallKind>))]
public enum CallKind
{
Direct,
Virtual,
Delegate,
Reflection,
Dynamic
}
[JsonConverter(typeof(JsonStringEnumConverter<EntrypointType>))]
public enum EntrypointType
{
HttpHandler,
GrpcMethod,
CliCommand,
BackgroundJob,
ScheduledJob,
MessageHandler,
EventSubscriber,
WebSocketHandler,
Unknown
}
public static class CallGraphDigests
{
private static readonly JsonWriterOptions CanonicalJsonOptions = new()
{
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
Indented = false,
SkipValidation = false
};
public static string ComputeGraphDigest(CallGraphSnapshot snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
var trimmed = snapshot.Trimmed();
using var buffer = new MemoryStream(capacity: 64 * 1024);
using (var writer = new Utf8JsonWriter(buffer, CanonicalJsonOptions))
{
WriteDigestPayload(writer, trimmed);
writer.Flush();
}
var hash = SHA256.HashData(buffer.ToArray());
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
public static string ComputeResultDigest(ReachabilityAnalysisResult result)
{
ArgumentNullException.ThrowIfNull(result);
var trimmed = result.Trimmed();
using var buffer = new MemoryStream(capacity: 64 * 1024);
using (var writer = new Utf8JsonWriter(buffer, CanonicalJsonOptions))
{
WriteDigestPayload(writer, trimmed);
writer.Flush();
}
var hash = SHA256.HashData(buffer.ToArray());
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static void WriteDigestPayload(Utf8JsonWriter writer, CallGraphSnapshot snapshot)
{
writer.WriteStartObject();
writer.WriteString("schema", "stellaops.callgraph@v1");
writer.WriteString("language", snapshot.Language);
writer.WritePropertyName("nodes");
writer.WriteStartArray();
foreach (var node in snapshot.Nodes)
{
writer.WriteStartObject();
writer.WriteString("nodeId", node.NodeId);
writer.WriteString("symbol", node.Symbol);
writer.WriteString("file", node.File);
writer.WriteNumber("line", node.Line);
writer.WriteString("package", node.Package);
writer.WriteString("visibility", node.Visibility.ToString());
writer.WriteBoolean("isEntrypoint", node.IsEntrypoint);
if (node.EntrypointType is not null)
{
writer.WriteString("entrypointType", node.EntrypointType.Value.ToString());
}
writer.WriteBoolean("isSink", node.IsSink);
if (node.SinkCategory is not null)
{
writer.WriteString("sinkCategory", node.SinkCategory.Value.ToString());
}
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WritePropertyName("edges");
writer.WriteStartArray();
foreach (var edge in snapshot.Edges)
{
writer.WriteStartObject();
writer.WriteString("sourceId", edge.SourceId);
writer.WriteString("targetId", edge.TargetId);
writer.WriteString("callKind", edge.CallKind.ToString());
if (!string.IsNullOrWhiteSpace(edge.CallSite))
{
writer.WriteString("callSite", edge.CallSite);
}
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WritePropertyName("entrypointIds");
writer.WriteStartArray();
foreach (var id in snapshot.EntrypointIds)
{
writer.WriteStringValue(id);
}
writer.WriteEndArray();
writer.WritePropertyName("sinkIds");
writer.WriteStartArray();
foreach (var id in snapshot.SinkIds)
{
writer.WriteStringValue(id);
}
writer.WriteEndArray();
writer.WriteEndObject();
}
private static void WriteDigestPayload(Utf8JsonWriter writer, ReachabilityAnalysisResult result)
{
writer.WriteStartObject();
writer.WriteString("schema", "stellaops.reachability@v1");
writer.WriteString("graphDigest", result.GraphDigest);
writer.WriteString("language", result.Language);
writer.WritePropertyName("reachableNodeIds");
writer.WriteStartArray();
foreach (var id in result.ReachableNodeIds)
{
writer.WriteStringValue(id);
}
writer.WriteEndArray();
writer.WritePropertyName("reachableSinkIds");
writer.WriteStartArray();
foreach (var id in result.ReachableSinkIds)
{
writer.WriteStringValue(id);
}
writer.WriteEndArray();
writer.WritePropertyName("paths");
writer.WriteStartArray();
foreach (var path in result.Paths)
{
writer.WriteStartObject();
writer.WriteString("entrypointId", path.EntrypointId);
writer.WriteString("sinkId", path.SinkId);
writer.WritePropertyName("nodeIds");
writer.WriteStartArray();
foreach (var nodeId in path.NodeIds)
{
writer.WriteStringValue(nodeId);
}
writer.WriteEndArray();
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
}
}
public sealed record ReachabilityPath(
[property: JsonPropertyName("entrypointId")] string EntrypointId,
[property: JsonPropertyName("sinkId")] string SinkId,
[property: JsonPropertyName("nodeIds")] ImmutableArray<string> NodeIds)
{
public ReachabilityPath Trimmed()
{
var nodes = (NodeIds.IsDefault ? ImmutableArray<string>.Empty : NodeIds)
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.ToImmutableArray();
return this with
{
EntrypointId = EntrypointId?.Trim() ?? string.Empty,
SinkId = SinkId?.Trim() ?? string.Empty,
NodeIds = nodes
};
}
}
public sealed record ReachabilityAnalysisResult(
[property: JsonPropertyName("scanId")] string ScanId,
[property: JsonPropertyName("graphDigest")] string GraphDigest,
[property: JsonPropertyName("language")] string Language,
[property: JsonPropertyName("computedAt")] DateTimeOffset ComputedAt,
[property: JsonPropertyName("reachableNodeIds")] ImmutableArray<string> ReachableNodeIds,
[property: JsonPropertyName("reachableSinkIds")] ImmutableArray<string> ReachableSinkIds,
[property: JsonPropertyName("paths")] ImmutableArray<ReachabilityPath> Paths,
[property: JsonPropertyName("resultDigest")] string ResultDigest)
{
public ReachabilityAnalysisResult Trimmed()
{
var reachableNodes = (ReachableNodeIds.IsDefault ? ImmutableArray<string>.Empty : ReachableNodeIds)
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.ToImmutableArray();
var reachableSinks = (ReachableSinkIds.IsDefault ? ImmutableArray<string>.Empty : ReachableSinkIds)
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.ToImmutableArray();
var paths = (Paths.IsDefault ? ImmutableArray<ReachabilityPath>.Empty : Paths)
.Select(p => p.Trimmed())
.OrderBy(p => p.SinkId, StringComparer.Ordinal)
.ThenBy(p => p.EntrypointId, StringComparer.Ordinal)
.ToImmutableArray();
return this with
{
ScanId = ScanId?.Trim() ?? string.Empty,
GraphDigest = GraphDigest?.Trim() ?? string.Empty,
Language = Language?.Trim() ?? string.Empty,
ResultDigest = ResultDigest?.Trim() ?? string.Empty,
ReachableNodeIds = reachableNodes,
ReachableSinkIds = reachableSinks,
Paths = paths
};
}
}
public static class CallGraphNodeIds
{
public static string Compute(string stableSymbolId)
{
if (string.IsNullOrWhiteSpace(stableSymbolId))
{
throw new ArgumentException("Symbol id must be provided.", nameof(stableSymbolId));
}
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(stableSymbolId.Trim()));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
public static string StableSymbolId(string language, string symbol)
=> $"{language.Trim().ToLowerInvariant()}:{symbol.Trim()}";
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Build.Locator" Version="1.10.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.14.0" />
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="4.14.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\StellaOps.Scanner.Reachability\\StellaOps.Scanner.Reachability.csproj" />
</ItemGroup>
</Project>