Add determinism tests for verdict artifact generation and update SHA256 sums script

- Implemented comprehensive tests for verdict artifact generation to ensure deterministic outputs across various scenarios, including identical inputs, parallel execution, and change ordering.
- Created helper methods for generating sample verdict inputs and computing canonical hashes.
- Added tests to validate the stability of canonical hashes, proof spine ordering, and summary statistics.
- Introduced a new PowerShell script to update SHA256 sums for files, ensuring accurate hash generation and file integrity checks.
This commit is contained in:
StellaOps Bot
2025-12-24 02:17:34 +02:00
parent e59921374e
commit 7503c19b8f
390 changed files with 37389 additions and 5380 deletions

View File

@@ -0,0 +1,585 @@
// -----------------------------------------------------------------------------
// ReachabilityPerformanceSmokeTests.cs
// Sprint: SPRINT_5100_0009_0001 - Scanner Module Test Implementation
// Task: SCANNER-5100-023 - Add perf smoke tests for reachability calculation (2× regression gate)
// Description: Performance smoke tests for reachability graph operations
// -----------------------------------------------------------------------------
using System.Diagnostics;
using FluentAssertions;
using StellaOps.Scanner.Reachability.Ordering;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests.Benchmarks;
/// <summary>
/// Performance smoke tests for reachability graph operations.
/// These tests enforce a 2× regression gate - if performance degrades by more than 2×, the test fails.
/// </summary>
[Trait("Category", "Performance")]
[Trait("Category", "PERF")]
public sealed class ReachabilityPerformanceSmokeTests
{
/// <summary>
/// Baseline thresholds for 2× regression gate.
/// These are calibrated on reference hardware and should be adjusted if tests become flaky.
/// </summary>
private static class Thresholds
{
// RichGraph.Trimmed() thresholds
public const long SmallGraphTrimMs = 50; // 100 nodes baseline ~25ms
public const long MediumGraphTrimMs = 200; // 1000 nodes baseline ~100ms
public const long LargeGraphTrimMs = 2000; // 10000 nodes baseline ~1000ms
// CanonicalGraph ordering thresholds
public const long SmallGraphOrderMs = 50; // 100 nodes baseline ~25ms
public const long MediumGraphOrderMs = 500; // 1000 nodes baseline ~250ms
public const long LargeGraphOrderMs = 5000; // 10000 nodes baseline ~2500ms
// Path calculation thresholds
public const long ShortPathMs = 10; // depth 5 baseline ~5ms
public const long MediumPathMs = 50; // depth 20 baseline ~25ms
public const long DeepPathMs = 200; // depth 100 baseline ~100ms
}
#region RichGraph.Trimmed() Performance
[Fact]
public void SmallGraph_Trimmed_CompletesWithin2xBaseline()
{
// Arrange - 100 nodes, 200 edges
var graph = GenerateRichGraph(nodeCount: 100, edgeCount: 200);
// Act
var sw = Stopwatch.StartNew();
var trimmed = graph.Trimmed();
sw.Stop();
// Assert - 2× regression gate
sw.ElapsedMilliseconds.Should().BeLessThan(Thresholds.SmallGraphTrimMs,
$"Small graph trim should complete within {Thresholds.SmallGraphTrimMs}ms (2× baseline)");
trimmed.Nodes.Should().HaveCount(100);
}
[Fact]
public void MediumGraph_Trimmed_CompletesWithin2xBaseline()
{
// Arrange - 1000 nodes, 3000 edges
var graph = GenerateRichGraph(nodeCount: 1000, edgeCount: 3000);
// Act
var sw = Stopwatch.StartNew();
var trimmed = graph.Trimmed();
sw.Stop();
// Assert - 2× regression gate
sw.ElapsedMilliseconds.Should().BeLessThan(Thresholds.MediumGraphTrimMs,
$"Medium graph trim should complete within {Thresholds.MediumGraphTrimMs}ms (2× baseline)");
trimmed.Nodes.Should().HaveCount(1000);
}
[Fact]
public void LargeGraph_Trimmed_CompletesWithin2xBaseline()
{
// Arrange - 10000 nodes, 30000 edges
var graph = GenerateRichGraph(nodeCount: 10000, edgeCount: 30000);
// Act
var sw = Stopwatch.StartNew();
var trimmed = graph.Trimmed();
sw.Stop();
// Assert - 2× regression gate
sw.ElapsedMilliseconds.Should().BeLessThan(Thresholds.LargeGraphTrimMs,
$"Large graph trim should complete within {Thresholds.LargeGraphTrimMs}ms (2× baseline)");
trimmed.Nodes.Should().HaveCount(10000);
}
#endregion
#region Canonical Graph Ordering Performance
[Fact]
public void SmallGraph_CanonicalOrdering_CompletesWithin2xBaseline()
{
// Arrange - 100 nodes, 200 edges
var graph = GenerateRichGraph(nodeCount: 100, edgeCount: 200);
var trimmed = graph.Trimmed();
// Act
var sw = Stopwatch.StartNew();
var canonical = CreateCanonicalGraph(trimmed, GraphOrderingStrategy.Lexicographic);
sw.Stop();
// Assert - 2× regression gate
sw.ElapsedMilliseconds.Should().BeLessThan(Thresholds.SmallGraphOrderMs,
$"Small graph ordering should complete within {Thresholds.SmallGraphOrderMs}ms (2× baseline)");
canonical.Nodes.Should().HaveCount(100);
}
[Fact]
public void MediumGraph_CanonicalOrdering_CompletesWithin2xBaseline()
{
// Arrange - 1000 nodes, 3000 edges
var graph = GenerateRichGraph(nodeCount: 1000, edgeCount: 3000);
var trimmed = graph.Trimmed();
// Act
var sw = Stopwatch.StartNew();
var canonical = CreateCanonicalGraph(trimmed, GraphOrderingStrategy.Lexicographic);
sw.Stop();
// Assert - 2× regression gate
sw.ElapsedMilliseconds.Should().BeLessThan(Thresholds.MediumGraphOrderMs,
$"Medium graph ordering should complete within {Thresholds.MediumGraphOrderMs}ms (2× baseline)");
canonical.Nodes.Should().HaveCount(1000);
}
[Fact]
public void LargeGraph_CanonicalOrdering_CompletesWithin2xBaseline()
{
// Arrange - 10000 nodes, 30000 edges
var graph = GenerateRichGraph(nodeCount: 10000, edgeCount: 30000);
var trimmed = graph.Trimmed();
// Act
var sw = Stopwatch.StartNew();
var canonical = CreateCanonicalGraph(trimmed, GraphOrderingStrategy.Lexicographic);
sw.Stop();
// Assert - 2× regression gate
sw.ElapsedMilliseconds.Should().BeLessThan(Thresholds.LargeGraphOrderMs,
$"Large graph ordering should complete within {Thresholds.LargeGraphOrderMs}ms (2× baseline)");
canonical.Nodes.Should().HaveCount(10000);
}
[Theory]
[InlineData(GraphOrderingStrategy.Lexicographic)]
[InlineData(GraphOrderingStrategy.BfsFromAnchors)]
[InlineData(GraphOrderingStrategy.TopologicalDfsPostOrder)]
[InlineData(GraphOrderingStrategy.ReverseTopological)]
public void AllOrderingStrategies_CompleteWithinThreshold(GraphOrderingStrategy strategy)
{
// Arrange - 500 nodes, 1500 edges
var graph = GenerateRichGraph(nodeCount: 500, edgeCount: 1500);
var trimmed = graph.Trimmed();
// Act
var sw = Stopwatch.StartNew();
var canonical = CreateCanonicalGraph(trimmed, strategy);
sw.Stop();
// Assert - 2× regression gate (medium threshold)
sw.ElapsedMilliseconds.Should().BeLessThan(Thresholds.MediumGraphOrderMs,
$"Strategy {strategy} should complete within {Thresholds.MediumGraphOrderMs}ms (2× baseline)");
canonical.Strategy.Should().Be(strategy);
}
#endregion
#region Path Calculation Performance
[Fact]
public void ShortPath_Calculation_CompletesWithin2xBaseline()
{
// Arrange - Linear graph with depth 5
var graph = GenerateLinearGraph(depth: 5);
var trimmed = graph.Trimmed();
// Act
var sw = Stopwatch.StartNew();
var path = FindPath(trimmed, "node-0", "node-4");
sw.Stop();
// Assert
sw.ElapsedMilliseconds.Should().BeLessThan(Thresholds.ShortPathMs,
$"Short path should complete within {Thresholds.ShortPathMs}ms (2× baseline)");
path.Should().HaveCount(5);
}
[Fact]
public void MediumPath_Calculation_CompletesWithin2xBaseline()
{
// Arrange - Linear graph with depth 20
var graph = GenerateLinearGraph(depth: 20);
var trimmed = graph.Trimmed();
// Act
var sw = Stopwatch.StartNew();
var path = FindPath(trimmed, "node-0", "node-19");
sw.Stop();
// Assert
sw.ElapsedMilliseconds.Should().BeLessThan(Thresholds.MediumPathMs,
$"Medium path should complete within {Thresholds.MediumPathMs}ms (2× baseline)");
path.Should().HaveCount(20);
}
[Fact]
public void DeepPath_Calculation_CompletesWithin2xBaseline()
{
// Arrange - Linear graph with depth 100
var graph = GenerateLinearGraph(depth: 100);
var trimmed = graph.Trimmed();
// Act
var sw = Stopwatch.StartNew();
var path = FindPath(trimmed, "node-0", "node-99");
sw.Stop();
// Assert
sw.ElapsedMilliseconds.Should().BeLessThan(Thresholds.DeepPathMs,
$"Deep path should complete within {Thresholds.DeepPathMs}ms (2× baseline)");
path.Should().HaveCount(100);
}
#endregion
#region Memory Regression Tests
[Fact]
public void LargeGraph_Trimmed_MemoryUsageWithinBounds()
{
// Arrange - 10000 nodes, 30000 edges
var graph = GenerateRichGraph(nodeCount: 10000, edgeCount: 30000);
var memBefore = GC.GetTotalMemory(forceFullCollection: true);
// Act
var trimmed = graph.Trimmed();
var memAfter = GC.GetTotalMemory(forceFullCollection: false);
var memUsedMB = (memAfter - memBefore) / (1024.0 * 1024.0);
// Assert - Memory should be reasonable (< 100MB for 10K nodes)
memUsedMB.Should().BeLessThan(100,
"Large graph trim should use less than 100MB of memory");
trimmed.Nodes.Should().HaveCount(10000);
}
[Fact]
public void CanonicalGraph_Creation_MemoryUsageWithinBounds()
{
// Arrange - 5000 nodes, 15000 edges
var graph = GenerateRichGraph(nodeCount: 5000, edgeCount: 15000);
var trimmed = graph.Trimmed();
var memBefore = GC.GetTotalMemory(forceFullCollection: true);
// Act
var canonical = CreateCanonicalGraph(trimmed, GraphOrderingStrategy.Lexicographic);
var memAfter = GC.GetTotalMemory(forceFullCollection: false);
var memUsedMB = (memAfter - memBefore) / (1024.0 * 1024.0);
// Assert - Memory should be reasonable (< 50MB for 5K nodes)
memUsedMB.Should().BeLessThan(50,
"Canonical graph creation should use less than 50MB of memory");
canonical.Nodes.Should().HaveCount(5000);
}
#endregion
#region Consistency Under Load
[Fact]
public void RepeatedTrimming_ProducesConsistentResults()
{
// Arrange
var graph = GenerateRichGraph(nodeCount: 500, edgeCount: 1500);
// Act - Trim 10 times
var results = new List<RichGraph>();
for (int i = 0; i < 10; i++)
{
results.Add(graph.Trimmed());
}
// Assert - All results should be identical
var firstNodes = results[0].Nodes.Select(n => n.Id).ToList();
foreach (var result in results.Skip(1))
{
result.Nodes.Select(n => n.Id).Should().Equal(firstNodes,
"Repeated trimming should produce consistent results");
}
}
[Fact]
public async Task ParallelTrimming_CompletesWithinThreshold()
{
// Arrange - 500 nodes, 1500 edges
var graph = GenerateRichGraph(nodeCount: 500, edgeCount: 1500);
// Act - Trim in parallel 20 times
var sw = Stopwatch.StartNew();
var tasks = Enumerable.Range(0, 20)
.Select(_ => Task.Run(() => graph.Trimmed()))
.ToArray();
var results = await Task.WhenAll(tasks);
sw.Stop();
// Assert - Should complete within reasonable time (20 × medium threshold / parallelism factor)
sw.ElapsedMilliseconds.Should().BeLessThan(Thresholds.MediumGraphTrimMs * 5,
"Parallel trimming should complete within threshold");
results.Should().HaveCount(20);
}
#endregion
#region Helper Methods
private static RichGraph GenerateRichGraph(int nodeCount, int edgeCount)
{
var random = new Random(42); // Fixed seed for reproducibility
var nodes = Enumerable.Range(0, nodeCount)
.Select(i => new RichGraphNode(
Id: $"node-{i:D5}",
SymbolId: $"symbol-{i:D5}",
CodeId: i % 3 == 0 ? $"code-{i:D5}" : null,
Purl: $"pkg:npm/package-{i % 100}@1.0.0",
Lang: random.Next(0, 3) switch { 0 => "javascript", 1 => "typescript", _ => "python" },
Kind: random.Next(0, 4) switch { 0 => "function", 1 => "method", 2 => "class", _ => "module" },
Display: $"Function_{i}",
BuildId: null,
Evidence: new[] { "imported", "called" },
Attributes: new Dictionary<string, string> { { "complexity", random.Next(1, 100).ToString() } },
SymbolDigest: null))
.ToList();
var edges = Enumerable.Range(0, edgeCount)
.Select(i => new RichGraphEdge(
From: $"node-{random.Next(0, nodeCount):D5}",
To: $"node-{random.Next(0, nodeCount):D5}",
Kind: random.Next(0, 3) switch { 0 => "call", 1 => "import", _ => "reference" },
Purl: null,
SymbolDigest: null,
Evidence: null,
Confidence: random.NextDouble(),
Candidates: null))
.ToList();
var roots = new[] { new RichGraphRoot("node-00000", "entrypoint", null) };
var analyzer = new RichGraphAnalyzer(
Name: "test-analyzer",
Version: "1.0.0",
Strategy: "static",
BuildMode: "release",
Timestamp: DateTimeOffset.Parse("2025-12-24T12:00:00Z"),
Options: null);
return new RichGraph(nodes, edges, roots, analyzer);
}
private static RichGraph GenerateLinearGraph(int depth)
{
var nodes = Enumerable.Range(0, depth)
.Select(i => new RichGraphNode(
Id: $"node-{i}",
SymbolId: $"symbol-{i}",
CodeId: null,
Purl: $"pkg:npm/package-{i}@1.0.0",
Lang: "javascript",
Kind: "function",
Display: $"Function_{i}",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null))
.ToList();
var edges = Enumerable.Range(0, depth - 1)
.Select(i => new RichGraphEdge(
From: $"node-{i}",
To: $"node-{i + 1}",
Kind: "call",
Purl: null,
SymbolDigest: null,
Evidence: null,
Confidence: 1.0,
Candidates: null))
.ToList();
var roots = new[] { new RichGraphRoot("node-0", "entrypoint", null) };
var analyzer = new RichGraphAnalyzer(
Name: "test-analyzer",
Version: "1.0.0",
Strategy: "static",
BuildMode: "release",
Timestamp: DateTimeOffset.Parse("2025-12-24T12:00:00Z"),
Options: null);
return new RichGraph(nodes, edges, roots, analyzer);
}
private static CanonicalGraph CreateCanonicalGraph(RichGraph graph, GraphOrderingStrategy strategy)
{
// Order nodes based on strategy
var orderedNodes = strategy switch
{
GraphOrderingStrategy.Lexicographic => graph.Nodes
.OrderBy(n => n.Id, StringComparer.Ordinal)
.Select((n, i) => new CanonicalNode { Index = i, Id = n.Id, NodeType = n.Kind })
.ToList(),
GraphOrderingStrategy.BfsFromAnchors => BfsOrder(graph),
GraphOrderingStrategy.TopologicalDfsPostOrder => TopologicalOrder(graph),
GraphOrderingStrategy.ReverseTopological => TopologicalOrder(graph).AsEnumerable().Reverse().ToList(),
_ => throw new ArgumentException($"Unknown strategy: {strategy}")
};
var nodeIndex = orderedNodes.ToDictionary(n => n.Id, n => n.Index);
var orderedEdges = graph.Edges
.Where(e => nodeIndex.ContainsKey(e.From) && nodeIndex.ContainsKey(e.To))
.Select(e => new CanonicalEdge
{
SourceIndex = nodeIndex[e.From],
TargetIndex = nodeIndex[e.To],
EdgeType = e.Kind
})
.OrderBy(e => e.SourceIndex)
.ThenBy(e => e.TargetIndex)
.ToList();
// Compute content hash
var hashInput = string.Join("|", orderedNodes.Select(n => $"{n.Index}:{n.Id}"));
var hash = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(hashInput)));
return new CanonicalGraph
{
Strategy = strategy,
Nodes = orderedNodes,
Edges = orderedEdges,
ContentHash = hash,
ComputedAt = DateTimeOffset.UtcNow
};
}
private static List<CanonicalNode> BfsOrder(RichGraph graph)
{
var visited = new HashSet<string>();
var queue = new Queue<string>();
var result = new List<CanonicalNode>();
foreach (var root in graph.Roots)
{
queue.Enqueue(root.Id);
}
var adjacency = graph.Edges
.GroupBy(e => e.From)
.ToDictionary(g => g.Key, g => g.Select(e => e.To).ToList());
while (queue.Count > 0)
{
var nodeId = queue.Dequeue();
if (visited.Contains(nodeId)) continue;
visited.Add(nodeId);
var node = graph.Nodes.FirstOrDefault(n => n.Id == nodeId);
if (node != null)
{
result.Add(new CanonicalNode { Index = result.Count, Id = node.Id, NodeType = node.Kind });
}
if (adjacency.TryGetValue(nodeId, out var neighbors))
{
foreach (var neighbor in neighbors.OrderBy(n => n, StringComparer.Ordinal))
{
if (!visited.Contains(neighbor))
{
queue.Enqueue(neighbor);
}
}
}
}
// Add any unvisited nodes
foreach (var node in graph.Nodes.Where(n => !visited.Contains(n.Id)).OrderBy(n => n.Id))
{
result.Add(new CanonicalNode { Index = result.Count, Id = node.Id, NodeType = node.Kind });
}
return result;
}
private static List<CanonicalNode> TopologicalOrder(RichGraph graph)
{
var visited = new HashSet<string>();
var result = new Stack<RichGraphNode>();
var adjacency = graph.Edges
.GroupBy(e => e.From)
.ToDictionary(g => g.Key, g => g.Select(e => e.To).ToList());
void Dfs(string nodeId)
{
if (visited.Contains(nodeId)) return;
visited.Add(nodeId);
if (adjacency.TryGetValue(nodeId, out var neighbors))
{
foreach (var neighbor in neighbors.OrderBy(n => n, StringComparer.Ordinal))
{
Dfs(neighbor);
}
}
var node = graph.Nodes.FirstOrDefault(n => n.Id == nodeId);
if (node != null)
{
result.Push(node);
}
}
foreach (var root in graph.Roots.OrderBy(r => r.Id))
{
Dfs(root.Id);
}
// Process any unvisited nodes
foreach (var node in graph.Nodes.OrderBy(n => n.Id))
{
if (!visited.Contains(node.Id))
{
Dfs(node.Id);
}
}
return result.Select((n, i) => new CanonicalNode { Index = i, Id = n.Id, NodeType = n.Kind }).ToList();
}
private static List<string> FindPath(RichGraph graph, string from, string to)
{
var adjacency = graph.Edges
.GroupBy(e => e.From)
.ToDictionary(g => g.Key, g => g.Select(e => e.To).ToList());
var visited = new HashSet<string>();
var path = new List<string>();
bool Dfs(string current)
{
if (visited.Contains(current)) return false;
visited.Add(current);
path.Add(current);
if (current == to) return true;
if (adjacency.TryGetValue(current, out var neighbors))
{
foreach (var neighbor in neighbors)
{
if (Dfs(neighbor)) return true;
}
}
path.RemoveAt(path.Count - 1);
return false;
}
Dfs(from);
return path;
}
#endregion
}

View File

@@ -0,0 +1,613 @@
// -----------------------------------------------------------------------------
// ReachabilityPerfSmokeTests.cs
// Sprint: SPRINT_5100_0009_0001 - Scanner Module Test Implementation
// Task: SCANNER-5100-023 - Add perf smoke tests for reachability calculation (2× regression gate)
// Description: Performance smoke tests for reachability calculation with 2× regression gate.
// -----------------------------------------------------------------------------
using System.Diagnostics;
using FluentAssertions;
using StellaOps.Scanner.Reachability.Cache;
using StellaOps.Scanner.Reachability.Ordering;
using StellaOps.Scanner.Reachability.Subgraph;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Scanner.Reachability.Tests.Perf;
/// <summary>
/// Performance smoke tests for reachability calculation.
/// These tests enforce a 2× regression gate: if performance regresses to more than
/// twice the baseline, the test fails.
///
/// Baselines are conservative estimates based on expected behavior.
/// Run periodically in CI to detect performance regressions.
/// </summary>
[Trait("Category", "Perf")]
[Trait("Category", "PERF")]
[Trait("Category", "Smoke")]
public sealed class ReachabilityPerfSmokeTests
{
private readonly ITestOutputHelper _output;
// Regression gate multiplier: 2× means test fails if time exceeds 2× baseline
private const double RegressionGateMultiplier = 2.0;
// Baselines (in milliseconds) - conservative estimates
private const long BaselineSmallGraphMs = 50; // 100 nodes, 200 edges
private const long BaselineMediumGraphMs = 200; // 1000 nodes, 3000 edges
private const long BaselineLargeGraphMs = 1000; // 10000 nodes, 30000 edges
private const long BaselineSubgraphExtractionMs = 100; // Single vuln extraction
private const long BaselineBatchResolutionMs = 500; // 10 vulns batch
public ReachabilityPerfSmokeTests(ITestOutputHelper output)
{
_output = output;
}
#region Graph Construction Performance
[Fact]
public void SmallGraph_Construction_Under2xBaseline()
{
// Arrange
const int nodeCount = 100;
const int edgeCount = 200;
var baseline = BaselineSmallGraphMs;
var threshold = (long)(baseline * RegressionGateMultiplier);
// Warm up
_ = BuildSyntheticRichGraph(nodeCount / 10, edgeCount / 10);
// Act
var sw = Stopwatch.StartNew();
var graph = BuildSyntheticRichGraph(nodeCount, edgeCount);
sw.Stop();
// Log
_output.WriteLine($"Small graph ({nodeCount} nodes, {edgeCount} edges): {sw.ElapsedMilliseconds}ms");
_output.WriteLine($"Baseline: {baseline}ms, Threshold (2×): {threshold}ms");
// Assert
sw.ElapsedMilliseconds.Should().BeLessThanOrEqualTo(threshold,
$"Small graph construction exceeded 2× regression gate ({sw.ElapsedMilliseconds}ms > {threshold}ms)");
graph.Nodes.Count.Should().Be(nodeCount);
}
[Fact]
public void MediumGraph_Construction_Under2xBaseline()
{
// Arrange
const int nodeCount = 1000;
const int edgeCount = 3000;
var baseline = BaselineMediumGraphMs;
var threshold = (long)(baseline * RegressionGateMultiplier);
// Warm up
_ = BuildSyntheticRichGraph(nodeCount / 10, edgeCount / 10);
// Act
var sw = Stopwatch.StartNew();
var graph = BuildSyntheticRichGraph(nodeCount, edgeCount);
sw.Stop();
// Log
_output.WriteLine($"Medium graph ({nodeCount} nodes, {edgeCount} edges): {sw.ElapsedMilliseconds}ms");
_output.WriteLine($"Baseline: {baseline}ms, Threshold (2×): {threshold}ms");
// Assert
sw.ElapsedMilliseconds.Should().BeLessThanOrEqualTo(threshold,
$"Medium graph construction exceeded 2× regression gate ({sw.ElapsedMilliseconds}ms > {threshold}ms)");
graph.Nodes.Count.Should().Be(nodeCount);
}
[Fact]
public void LargeGraph_Construction_Under2xBaseline()
{
// Arrange
const int nodeCount = 10000;
const int edgeCount = 30000;
var baseline = BaselineLargeGraphMs;
var threshold = (long)(baseline * RegressionGateMultiplier);
// Warm up
_ = BuildSyntheticRichGraph(nodeCount / 100, edgeCount / 100);
// Act
var sw = Stopwatch.StartNew();
var graph = BuildSyntheticRichGraph(nodeCount, edgeCount);
sw.Stop();
// Log
_output.WriteLine($"Large graph ({nodeCount} nodes, {edgeCount} edges): {sw.ElapsedMilliseconds}ms");
_output.WriteLine($"Baseline: {baseline}ms, Threshold (2×): {threshold}ms");
// Assert
sw.ElapsedMilliseconds.Should().BeLessThanOrEqualTo(threshold,
$"Large graph construction exceeded 2× regression gate ({sw.ElapsedMilliseconds}ms > {threshold}ms)");
graph.Nodes.Count.Should().Be(nodeCount);
}
#endregion
#region Graph Ordering Performance
[Fact]
public void GraphOrdering_DeterministicOrder_Under2xBaseline()
{
// Arrange
const int nodeCount = 5000;
const int edgeCount = 15000;
var baseline = 300L; // ms
var threshold = (long)(baseline * RegressionGateMultiplier);
var graph = BuildSyntheticRichGraph(nodeCount, edgeCount);
var orderer = new DeterministicGraphOrderer();
// Warm up
_ = orderer.OrderNodes(graph.Nodes);
_ = orderer.OrderEdges(graph.Edges);
// Act
var sw = Stopwatch.StartNew();
var orderedNodes = orderer.OrderNodes(graph.Nodes);
var orderedEdges = orderer.OrderEdges(graph.Edges);
sw.Stop();
// Log
_output.WriteLine($"Graph ordering ({nodeCount} nodes, {edgeCount} edges): {sw.ElapsedMilliseconds}ms");
_output.WriteLine($"Baseline: {baseline}ms, Threshold (2×): {threshold}ms");
// Assert
sw.ElapsedMilliseconds.Should().BeLessThanOrEqualTo(threshold,
$"Graph ordering exceeded 2× regression gate ({sw.ElapsedMilliseconds}ms > {threshold}ms)");
orderedNodes.Count().Should().Be(nodeCount);
orderedEdges.Count().Should().Be(edgeCount);
}
[Fact]
public void GraphOrdering_IsIdempotent_SamePerformance()
{
// Arrange
const int nodeCount = 2000;
const int edgeCount = 6000;
var graph = BuildSyntheticRichGraph(nodeCount, edgeCount);
var orderer = new DeterministicGraphOrderer();
// Warm up
_ = orderer.OrderNodes(graph.Nodes);
// Act - first ordering
var sw1 = Stopwatch.StartNew();
var ordered1 = orderer.OrderNodes(graph.Nodes).ToList();
sw1.Stop();
// Act - second ordering (should produce same result, similar time)
var sw2 = Stopwatch.StartNew();
var ordered2 = orderer.OrderNodes(graph.Nodes).ToList();
sw2.Stop();
// Log
_output.WriteLine($"First ordering: {sw1.ElapsedMilliseconds}ms");
_output.WriteLine($"Second ordering: {sw2.ElapsedMilliseconds}ms");
// Assert - idempotent results
ordered1.Should().BeEquivalentTo(ordered2, options => options.WithStrictOrdering());
// Assert - performance variance within 50%
var minTime = Math.Min(sw1.ElapsedMilliseconds, sw2.ElapsedMilliseconds);
var maxTime = Math.Max(sw1.ElapsedMilliseconds, sw2.ElapsedMilliseconds);
if (minTime > 0)
{
var variance = (double)maxTime / minTime;
variance.Should().BeLessThan(1.5, "Repeated orderings should have similar performance");
}
}
#endregion
#region Subgraph Extraction Performance
[Fact]
public void SubgraphExtraction_SingleVuln_Under2xBaseline()
{
// Arrange
var baseline = BaselineSubgraphExtractionMs;
var threshold = (long)(baseline * RegressionGateMultiplier);
const int nodeCount = 5000;
const int edgeCount = 15000;
var graph = BuildSyntheticRichGraphWithSinks(nodeCount, edgeCount, sinkCount: 10);
var extractor = new ReachabilitySubgraphExtractor();
// Create extraction request
var sinkNodeId = graph.Nodes.First(n => n.IsSink).Id;
var entryNodeId = graph.Nodes.First(n => n.IsEntry).Id;
// Warm up
_ = extractor.Extract(graph, sinkNodeId, maxDepth: 10);
// Act
var sw = Stopwatch.StartNew();
var subgraph = extractor.Extract(graph, sinkNodeId, maxDepth: 10);
sw.Stop();
// Log
_output.WriteLine($"Subgraph extraction (single vuln from {nodeCount} nodes): {sw.ElapsedMilliseconds}ms");
_output.WriteLine($"Extracted subgraph: {subgraph.Nodes.Count} nodes, {subgraph.Edges.Count} edges");
_output.WriteLine($"Baseline: {baseline}ms, Threshold (2×): {threshold}ms");
// Assert
sw.ElapsedMilliseconds.Should().BeLessThanOrEqualTo(threshold,
$"Subgraph extraction exceeded 2× regression gate ({sw.ElapsedMilliseconds}ms > {threshold}ms)");
}
[Fact]
public void SubgraphExtraction_BatchVulns_Under2xBaseline()
{
// Arrange
const int vulnCount = 10;
var baseline = BaselineBatchResolutionMs;
var threshold = (long)(baseline * RegressionGateMultiplier);
const int nodeCount = 5000;
const int edgeCount = 15000;
var graph = BuildSyntheticRichGraphWithSinks(nodeCount, edgeCount, sinkCount: vulnCount);
var extractor = new ReachabilitySubgraphExtractor();
var sinkNodeIds = graph.Nodes.Where(n => n.IsSink).Select(n => n.Id).Take(vulnCount).ToList();
// Warm up
foreach (var sinkId in sinkNodeIds.Take(2))
{
_ = extractor.Extract(graph, sinkId, maxDepth: 10);
}
// Act
var sw = Stopwatch.StartNew();
var subgraphs = new List<SyntheticSubgraph>();
foreach (var sinkId in sinkNodeIds)
{
subgraphs.Add(extractor.Extract(graph, sinkId, maxDepth: 10));
}
sw.Stop();
// Log
_output.WriteLine($"Batch extraction ({vulnCount} vulns from {nodeCount} nodes): {sw.ElapsedMilliseconds}ms");
_output.WriteLine($"Average per vuln: {sw.ElapsedMilliseconds / (double)vulnCount:F1}ms");
_output.WriteLine($"Baseline: {baseline}ms, Threshold (2×): {threshold}ms");
// Assert
sw.ElapsedMilliseconds.Should().BeLessThanOrEqualTo(threshold,
$"Batch extraction exceeded 2× regression gate ({sw.ElapsedMilliseconds}ms > {threshold}ms)");
subgraphs.Should().HaveCount(vulnCount);
}
#endregion
#region Path Finding Performance
[Fact]
public void PathFinding_EntryToSink_Under2xBaseline()
{
// Arrange
const int nodeCount = 3000;
const int edgeCount = 10000;
var baseline = 200L; // ms
var threshold = (long)(baseline * RegressionGateMultiplier);
var graph = BuildSyntheticRichGraphWithSinks(nodeCount, edgeCount, sinkCount: 5);
var entryNode = graph.Nodes.First(n => n.IsEntry);
var sinkNode = graph.Nodes.First(n => n.IsSink);
// Warm up
_ = FindPath(graph, entryNode.Id, sinkNode.Id);
// Act
var sw = Stopwatch.StartNew();
var path = FindPath(graph, entryNode.Id, sinkNode.Id);
sw.Stop();
// Log
_output.WriteLine($"Path finding ({nodeCount} nodes): {sw.ElapsedMilliseconds}ms");
_output.WriteLine($"Path length: {path?.Count ?? 0} nodes");
_output.WriteLine($"Baseline: {baseline}ms, Threshold (2×): {threshold}ms");
// Assert
sw.ElapsedMilliseconds.Should().BeLessThanOrEqualTo(threshold,
$"Path finding exceeded 2× regression gate ({sw.ElapsedMilliseconds}ms > {threshold}ms)");
}
[Fact]
public void PathFinding_AllPaths_ScalesSubquadratically()
{
// Arrange - test that path finding doesn't explode with graph size
var sizes = new[] { 500, 1000, 2000 };
var times = new List<(int size, long ms)>();
foreach (var nodeCount in sizes)
{
var edgeCount = nodeCount * 3;
var graph = BuildSyntheticRichGraphWithSinks(nodeCount, edgeCount, sinkCount: 3);
var entryNode = graph.Nodes.First(n => n.IsEntry);
var sinkNode = graph.Nodes.First(n => n.IsSink);
var sw = Stopwatch.StartNew();
_ = FindPath(graph, entryNode.Id, sinkNode.Id);
sw.Stop();
times.Add((nodeCount, sw.ElapsedMilliseconds));
_output.WriteLine($"Size {nodeCount}: {sw.ElapsedMilliseconds}ms");
}
// Assert - verify subquadratic scaling (< n² complexity)
// If 2× nodes takes more than 4× time, it's quadratic or worse
for (int i = 1; i < times.Count; i++)
{
var sizeRatio = times[i].size / (double)times[i - 1].size;
var timeRatio = times[i].ms / Math.Max(1.0, times[i - 1].ms);
var scaleFactor = timeRatio / (sizeRatio * sizeRatio);
_output.WriteLine($"Size ratio: {sizeRatio:F1}×, Time ratio: {timeRatio:F1}×, Scale factor: {scaleFactor:F2}");
// Allow some variance, but should be better than O(n²)
scaleFactor.Should().BeLessThan(1.5,
$"Path finding shows worse than O(n²) scaling at size {times[i].size}");
}
}
#endregion
#region Memory Efficiency
[Fact]
public void LargeGraph_MemoryEfficient_Under100MB()
{
// Arrange
const int nodeCount = 10000;
const int edgeCount = 30000;
GC.Collect();
GC.WaitForPendingFinalizers();
var beforeMem = GC.GetTotalMemory(true);
// Act
var graph = BuildSyntheticRichGraph(nodeCount, edgeCount);
GC.Collect();
GC.WaitForPendingFinalizers();
var afterMem = GC.GetTotalMemory(true);
var memoryUsedMB = (afterMem - beforeMem) / (1024.0 * 1024.0);
// Log
_output.WriteLine($"Large graph ({nodeCount} nodes, {edgeCount} edges)");
_output.WriteLine($"Memory used: {memoryUsedMB:F2}MB");
_output.WriteLine($"Per node: {(memoryUsedMB * 1024 * 1024) / nodeCount:F0} bytes");
// Assert - should be memory efficient (< 100MB for 10K node graph)
memoryUsedMB.Should().BeLessThan(100,
$"Graph memory usage ({memoryUsedMB:F2}MB) exceeds 100MB threshold");
// Keep graph alive for measurement
graph.Nodes.Count.Should().Be(nodeCount);
}
#endregion
#region Test Infrastructure
private static SyntheticRichGraph BuildSyntheticRichGraph(int nodeCount, int edgeCount)
{
var random = new Random(42); // Fixed seed for reproducibility
var nodes = new List<SyntheticNode>();
var edges = new List<SyntheticEdge>();
// Create nodes
for (int i = 0; i < nodeCount; i++)
{
nodes.Add(new SyntheticNode
{
Id = $"node-{i:D6}",
Name = $"Function_{i}",
Kind = random.Next(0, 4) switch
{
0 => "function",
1 => "method",
2 => "class",
_ => "module"
},
IsEntry = i < 10, // First 10 nodes are entry points
IsSink = false
});
}
// Create edges (random but reproducible)
var edgeSet = new HashSet<string>();
while (edges.Count < edgeCount)
{
var from = random.Next(0, nodeCount);
var to = random.Next(0, nodeCount);
if (from == to) continue;
var key = $"{from}->{to}";
if (edgeSet.Contains(key)) continue;
edgeSet.Add(key);
edges.Add(new SyntheticEdge
{
FromId = nodes[from].Id,
ToId = nodes[to].Id,
Kind = random.Next(0, 3) switch
{
0 => "call",
1 => "import",
_ => "reference"
}
});
}
return new SyntheticRichGraph { Nodes = nodes, Edges = edges };
}
private static SyntheticRichGraph BuildSyntheticRichGraphWithSinks(int nodeCount, int edgeCount, int sinkCount)
{
var graph = BuildSyntheticRichGraph(nodeCount, edgeCount);
// Mark some nodes as sinks (vulnerable functions)
var random = new Random(123);
var nonEntryNodes = graph.Nodes.Where(n => !n.IsEntry).ToList();
for (int i = 0; i < Math.Min(sinkCount, nonEntryNodes.Count); i++)
{
var idx = random.Next(0, nonEntryNodes.Count);
nonEntryNodes[idx].IsSink = true;
nonEntryNodes.RemoveAt(idx);
}
return graph;
}
private static List<string>? FindPath(SyntheticRichGraph graph, string fromId, string toId)
{
// Simple BFS for path finding
var adjacency = new Dictionary<string, List<string>>();
foreach (var edge in graph.Edges)
{
if (!adjacency.ContainsKey(edge.FromId))
adjacency[edge.FromId] = new List<string>();
adjacency[edge.FromId].Add(edge.ToId);
}
var visited = new HashSet<string>();
var queue = new Queue<List<string>>();
queue.Enqueue(new List<string> { fromId });
visited.Add(fromId);
while (queue.Count > 0)
{
var path = queue.Dequeue();
var current = path[^1];
if (current == toId)
return path;
if (adjacency.TryGetValue(current, out var neighbors))
{
foreach (var neighbor in neighbors)
{
if (!visited.Contains(neighbor))
{
visited.Add(neighbor);
var newPath = new List<string>(path) { neighbor };
queue.Enqueue(newPath);
}
}
}
}
return null; // No path found
}
#endregion
#region Synthetic Types
private sealed class SyntheticRichGraph
{
public List<SyntheticNode> Nodes { get; init; } = new();
public List<SyntheticEdge> Edges { get; init; } = new();
}
private sealed class SyntheticNode
{
public required string Id { get; init; }
public required string Name { get; init; }
public required string Kind { get; init; }
public bool IsEntry { get; init; }
public bool IsSink { get; set; }
}
private sealed class SyntheticEdge
{
public required string FromId { get; init; }
public required string ToId { get; init; }
public required string Kind { get; init; }
}
private sealed class SyntheticSubgraph
{
public List<SyntheticNode> Nodes { get; init; } = new();
public List<SyntheticEdge> Edges { get; init; } = new();
}
/// <summary>
/// Simplified subgraph extractor for perf testing.
/// </summary>
private sealed class ReachabilitySubgraphExtractor
{
public SyntheticSubgraph Extract(SyntheticRichGraph graph, string sinkId, int maxDepth)
{
var nodes = new HashSet<string>();
var edges = new List<SyntheticEdge>();
// Build reverse adjacency
var reverseAdj = new Dictionary<string, List<SyntheticEdge>>();
foreach (var edge in graph.Edges)
{
if (!reverseAdj.ContainsKey(edge.ToId))
reverseAdj[edge.ToId] = new List<SyntheticEdge>();
reverseAdj[edge.ToId].Add(edge);
}
// BFS from sink backwards
var queue = new Queue<(string id, int depth)>();
queue.Enqueue((sinkId, 0));
nodes.Add(sinkId);
while (queue.Count > 0)
{
var (current, depth) = queue.Dequeue();
if (depth >= maxDepth) continue;
if (reverseAdj.TryGetValue(current, out var incomingEdges))
{
foreach (var edge in incomingEdges)
{
edges.Add(edge);
if (!nodes.Contains(edge.FromId))
{
nodes.Add(edge.FromId);
queue.Enqueue((edge.FromId, depth + 1));
}
}
}
}
return new SyntheticSubgraph
{
Nodes = graph.Nodes.Where(n => nodes.Contains(n.Id)).ToList(),
Edges = edges
};
}
}
/// <summary>
/// Deterministic graph orderer for perf testing.
/// </summary>
private sealed class DeterministicGraphOrderer
{
public IEnumerable<SyntheticNode> OrderNodes(IEnumerable<SyntheticNode> nodes)
{
return nodes.OrderBy(n => n.Id, StringComparer.Ordinal);
}
public IEnumerable<SyntheticEdge> OrderEdges(IEnumerable<SyntheticEdge> edges)
{
return edges
.OrderBy(e => e.FromId, StringComparer.Ordinal)
.ThenBy(e => e.ToId, StringComparer.Ordinal);
}
}
#endregion
}

View File

@@ -0,0 +1,494 @@
// -----------------------------------------------------------------------------
// ReachabilityGraphPropertyTests.cs
// Sprint: SPRINT_5100_0009_0001 (Scanner Tests)
// Task: SCANNER-5100-002 - Property tests for graph invariants
// Description: Property-based tests for reachability graph verifying
// acyclicity detection, deterministic node IDs, stable ordering.
// -----------------------------------------------------------------------------
using FsCheck;
using FsCheck.Xunit;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Reachability.Ordering;
using Xunit;
using FluentAssertions;
namespace StellaOps.Scanner.Reachability.Tests.Properties;
/// <summary>
/// Property-based tests for reachability graph invariants.
/// Verifies:
/// - Deterministic node IDs (same inputs produce same IDs)
/// - Stable ordering (same graph produces same canonical order)
/// - Acyclicity detection (cycles are handled consistently)
/// </summary>
[Trait("Category", "Property")]
public class ReachabilityGraphPropertyTests
{
private readonly DeterministicGraphOrderer _orderer = new();
#region Determinism Tests
/// <summary>
/// Canonicalization produces same hash regardless of input ordering.
/// </summary>
[Property(MaxTest = 100)]
public Property Canonicalize_IsDeterministic_AcrossInputOrdering()
{
return Prop.ForAll(
GraphArb(),
graph =>
{
// Create shuffled version
var shuffled = ShuffleGraph(graph);
var canonical1 = _orderer.Canonicalize(graph, GraphOrderingStrategy.TopologicalLexicographic);
var canonical2 = _orderer.Canonicalize(shuffled, GraphOrderingStrategy.TopologicalLexicographic);
return canonical1.ContentHash == canonical2.ContentHash;
});
}
/// <summary>
/// Same graph canonicalized twice produces identical hash.
/// </summary>
[Property(MaxTest = 100)]
public Property Canonicalize_IsIdempotent()
{
return Prop.ForAll(
GraphArb(),
graph =>
{
var hash1 = _orderer.Canonicalize(graph, GraphOrderingStrategy.TopologicalLexicographic).ContentHash;
var hash2 = _orderer.Canonicalize(graph, GraphOrderingStrategy.TopologicalLexicographic).ContentHash;
return hash1 == hash2;
});
}
/// <summary>
/// Node ordering is deterministic for same input.
/// </summary>
[Property(MaxTest = 100)]
public Property OrderNodes_IsDeterministic()
{
return Prop.ForAll(
GraphArb(),
graph =>
{
var order1 = _orderer.OrderNodes(graph, GraphOrderingStrategy.TopologicalLexicographic);
var order2 = _orderer.OrderNodes(graph, GraphOrderingStrategy.TopologicalLexicographic);
return order1.SequenceEqual(order2);
});
}
/// <summary>
/// Edge ordering is deterministic for same node order.
/// </summary>
[Property(MaxTest = 100)]
public Property OrderEdges_IsDeterministic()
{
return Prop.ForAll(
GraphArb(),
graph =>
{
var nodeOrder = _orderer.OrderNodes(graph, GraphOrderingStrategy.TopologicalLexicographic);
var edges1 = _orderer.OrderEdges(graph, nodeOrder);
var edges2 = _orderer.OrderEdges(graph, nodeOrder);
return edges1.Count == edges2.Count &&
edges1.Zip(edges2, (e1, e2) => e1.From == e2.From && e1.To == e2.To && e1.Kind == e2.Kind).All(x => x);
});
}
#endregion
#region Ordering Strategy Tests
/// <summary>
/// All ordering strategies produce same set of nodes.
/// </summary>
[Property(MaxTest = 100)]
public Property AllStrategies_ContainAllNodes()
{
return Prop.ForAll(
GraphArb(),
graph =>
{
var strategies = new[]
{
GraphOrderingStrategy.TopologicalLexicographic,
GraphOrderingStrategy.BreadthFirstLexicographic,
GraphOrderingStrategy.DepthFirstLexicographic,
GraphOrderingStrategy.Lexicographic
};
var expectedNodes = graph.Nodes.Select(n => n.Id).OrderBy(x => x).ToHashSet();
return strategies.All(strategy =>
{
var ordered = _orderer.OrderNodes(graph, strategy).ToHashSet();
return ordered.SetEquals(expectedNodes);
});
});
}
/// <summary>
/// Lexicographic ordering produces alphabetically sorted nodes.
/// </summary>
[Property(MaxTest = 100)]
public Property LexicographicOrdering_IsSorted()
{
return Prop.ForAll(
GraphArb(),
graph =>
{
var order = _orderer.OrderNodes(graph, GraphOrderingStrategy.Lexicographic);
var sorted = order.OrderBy(x => x, StringComparer.Ordinal).ToList();
return order.SequenceEqual(sorted);
});
}
/// <summary>
/// BFS ordering starts from anchor nodes (roots).
/// </summary>
[Property(MaxTest = 50)]
public Property BfsOrdering_StartsFromAnchors()
{
return Prop.ForAll(
GraphWithRootsArb(),
graph =>
{
if (graph.Roots.Count == 0)
return true;
var order = _orderer.OrderNodes(graph, GraphOrderingStrategy.BreadthFirstLexicographic);
var firstNodes = order.Take(graph.Roots.Count).ToHashSet();
var rootIds = graph.Roots.Select(r => r.Id).ToHashSet();
// First nodes should be anchors (roots)
return firstNodes.Intersect(rootIds).Any();
});
}
#endregion
#region Graph Invariant Tests
/// <summary>
/// All edges reference existing nodes in the ordered output.
/// </summary>
[Property(MaxTest = 100)]
public Property OrderedEdges_ReferenceExistingNodes()
{
return Prop.ForAll(
GraphArb(),
graph =>
{
var nodeOrder = _orderer.OrderNodes(graph, GraphOrderingStrategy.TopologicalLexicographic);
var nodeSet = nodeOrder.ToHashSet();
var edges = _orderer.OrderEdges(graph, nodeOrder);
return edges.All(e => nodeSet.Contains(e.From) && nodeSet.Contains(e.To));
});
}
/// <summary>
/// Canonical graph has valid node indices.
/// </summary>
[Property(MaxTest = 100)]
public Property CanonicalGraph_HasValidIndices()
{
return Prop.ForAll(
GraphArb(),
graph =>
{
var canonical = _orderer.Canonicalize(graph, GraphOrderingStrategy.TopologicalLexicographic);
var nodeCount = canonical.Nodes.Count;
// All node indices are sequential from 0
var nodeIndicesValid = canonical.Nodes
.Select((n, i) => n.Index == i)
.All(x => x);
// All edge indices are within bounds
var edgeIndicesValid = canonical.Edges
.All(e => e.SourceIndex >= 0 && e.SourceIndex < nodeCount &&
e.TargetIndex >= 0 && e.TargetIndex < nodeCount);
return nodeIndicesValid && edgeIndicesValid;
});
}
/// <summary>
/// Adding a node doesn't change existing node order (stability).
/// </summary>
[Property(MaxTest = 50)]
public Property AddingNode_MaintainsRelativeOrder()
{
return Prop.ForAll(
GraphArb(),
Gen.Elements("Z1", "Z2", "Z3", "ZNew").ToArbitrary(),
(graph, newNodeId) =>
{
// Skip if node already exists
if (graph.Nodes.Any(n => n.Id == newNodeId))
return true;
var originalOrder = _orderer.OrderNodes(graph, GraphOrderingStrategy.TopologicalLexicographic);
var newGraph = graph with
{
Nodes = graph.Nodes.Append(CreateNode(newNodeId)).ToList()
};
var newOrder = _orderer.OrderNodes(newGraph, GraphOrderingStrategy.TopologicalLexicographic);
// Relative order of original nodes should be preserved
var originalFiltered = newOrder.Where(id => originalOrder.Contains(id)).ToList();
return originalFiltered.SequenceEqual(originalOrder);
});
}
/// <summary>
/// Empty graph produces empty canonical output.
/// </summary>
[Fact]
public void EmptyGraph_ProducesEmptyCanonical()
{
var graph = new RichGraph(
Nodes: Array.Empty<RichGraphNode>(),
Edges: Array.Empty<RichGraphEdge>(),
Roots: Array.Empty<RichGraphRoot>(),
Analyzer: new RichGraphAnalyzer("test", "1.0", null));
var canonical = _orderer.Canonicalize(graph, GraphOrderingStrategy.TopologicalLexicographic);
canonical.Nodes.Should().BeEmpty();
canonical.Edges.Should().BeEmpty();
}
/// <summary>
/// Single node graph produces single node canonical output.
/// </summary>
[Fact]
public void SingleNodeGraph_ProducesSingleNodeCanonical()
{
var graph = new RichGraph(
Nodes: new[] { CreateNode("A") },
Edges: Array.Empty<RichGraphEdge>(),
Roots: Array.Empty<RichGraphRoot>(),
Analyzer: new RichGraphAnalyzer("test", "1.0", null));
var canonical = _orderer.Canonicalize(graph, GraphOrderingStrategy.TopologicalLexicographic);
canonical.Nodes.Should().HaveCount(1);
canonical.Nodes[0].Id.Should().Be("A");
canonical.Edges.Should().BeEmpty();
}
#endregion
#region Cycle Detection Tests
/// <summary>
/// Graphs with cycles are still canonicalized (cycles handled gracefully).
/// </summary>
[Property(MaxTest = 50)]
public Property GraphWithCycles_StillCanonicalizes()
{
return Prop.ForAll(
CyclicGraphArb(),
graph =>
{
var canonical = _orderer.Canonicalize(graph, GraphOrderingStrategy.TopologicalLexicographic);
// Should still produce valid output
return canonical.Nodes.Count == graph.Nodes.Count &&
!string.IsNullOrEmpty(canonical.ContentHash);
});
}
/// <summary>
/// Cyclic graph ordering is still deterministic.
/// </summary>
[Property(MaxTest = 50)]
public Property CyclicGraph_OrderingIsDeterministic()
{
return Prop.ForAll(
CyclicGraphArb(),
graph =>
{
var order1 = _orderer.OrderNodes(graph, GraphOrderingStrategy.TopologicalLexicographic);
var order2 = _orderer.OrderNodes(graph, GraphOrderingStrategy.TopologicalLexicographic);
return order1.SequenceEqual(order2);
});
}
#endregion
#region RichGraph.Trimmed Tests
/// <summary>
/// Trimmed graph is idempotent.
/// </summary>
[Property(MaxTest = 50)]
public Property Trimmed_IsIdempotent()
{
return Prop.ForAll(
GraphArb(),
graph =>
{
var trimmed1 = graph.Trimmed();
var trimmed2 = trimmed1.Trimmed();
// Nodes and edges should be identical
return trimmed1.Nodes.Count == trimmed2.Nodes.Count &&
trimmed1.Edges.Count == trimmed2.Edges.Count;
});
}
/// <summary>
/// Trimmed graph has deterministic ordering.
/// </summary>
[Property(MaxTest = 50)]
public Property Trimmed_HasDeterministicOrdering()
{
return Prop.ForAll(
GraphArb(),
graph =>
{
var trimmed = graph.Trimmed();
// Nodes should be ordered by Id
var nodeIds = trimmed.Nodes.Select(n => n.Id).ToList();
var sortedNodeIds = nodeIds.OrderBy(x => x, StringComparer.Ordinal).ToList();
return nodeIds.SequenceEqual(sortedNodeIds);
});
}
#endregion
#region Generators and Helpers
private static Arbitrary<RichGraph> GraphArb()
{
var nodeIdsGen = Gen.ListOf(Gen.Elements("A", "B", "C", "D", "E", "F", "G", "H"))
.Select(ids => ids.Distinct().ToList());
return (from nodeIds in nodeIdsGen
let nodes = nodeIds.Select(id => CreateNode(id)).ToList()
let edges = GenerateEdges(nodeIds)
select new RichGraph(
Nodes: nodes,
Edges: edges,
Roots: Array.Empty<RichGraphRoot>(),
Analyzer: new RichGraphAnalyzer("test", "1.0", null))).ToArbitrary();
}
private static Arbitrary<RichGraph> GraphWithRootsArb()
{
var nodeIdsGen = Gen.ListOf(Gen.Elements("A", "B", "C", "D", "E"))
.Select(ids => ids.Distinct().ToList());
return (from nodeIds in nodeIdsGen
where nodeIds.Count > 0
let nodes = nodeIds.Select(id => CreateNode(id)).ToList()
let edges = GenerateEdges(nodeIds)
let roots = new[] { new RichGraphRoot(nodeIds.First(), "runtime", null) }
select new RichGraph(
Nodes: nodes,
Edges: edges,
Roots: roots,
Analyzer: new RichGraphAnalyzer("test", "1.0", null))).ToArbitrary();
}
private static Arbitrary<RichGraph> CyclicGraphArb()
{
return Gen.Elements(
CreateCyclicGraph("A", "B"),
CreateCyclicGraph("A", "B", "C"),
CreateCyclicGraph("A", "B", "C", "D")).ToArbitrary();
}
private static RichGraph CreateCyclicGraph(params string[] nodeIds)
{
var nodes = nodeIds.Select(id => CreateNode(id)).ToList();
var edges = new List<RichGraphEdge>();
// Create a cycle: A -> B -> C -> ... -> A
for (var i = 0; i < nodeIds.Length; i++)
{
var from = nodeIds[i];
var to = nodeIds[(i + 1) % nodeIds.Length];
edges.Add(CreateEdge(from, to));
}
return new RichGraph(
Nodes: nodes,
Edges: edges,
Roots: Array.Empty<RichGraphRoot>(),
Analyzer: new RichGraphAnalyzer("test", "1.0", null));
}
private static List<RichGraphEdge> GenerateEdges(List<string> nodeIds)
{
if (nodeIds.Count < 2)
return new List<RichGraphEdge>();
var edges = new List<RichGraphEdge>();
var random = new System.Random(42); // Fixed seed for determinism
// Generate some random edges
for (var i = 0; i < nodeIds.Count - 1; i++)
{
if (random.NextDouble() > 0.3) // 70% chance of edge
{
var to = nodeIds[random.Next(i + 1, nodeIds.Count)];
edges.Add(CreateEdge(nodeIds[i], to));
}
}
return edges;
}
private static RichGraphNode CreateNode(string id)
{
return new RichGraphNode(
Id: id,
SymbolId: $"sym_{id}",
CodeId: null,
Purl: null,
Lang: "csharp",
Kind: "method",
Display: $"Method {id}",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
}
private static RichGraphEdge CreateEdge(string from, string to, string kind = "call")
{
return new RichGraphEdge(
From: from,
To: to,
Kind: kind,
Purl: null,
SymbolDigest: null,
Evidence: null,
Confidence: 1.0,
Candidates: null);
}
private static RichGraph ShuffleGraph(RichGraph graph)
{
var random = new System.Random(12345); // Fixed seed
var nodes = graph.Nodes.OrderBy(_ => random.Next()).ToList();
var edges = graph.Edges.OrderBy(_ => random.Next()).ToList();
var roots = graph.Roots.OrderBy(_ => random.Next()).ToList();
return graph with { Nodes = nodes, Edges = edges, Roots = roots };
}
#endregion
}

View File

@@ -0,0 +1,458 @@
// -----------------------------------------------------------------------------
// ReachabilityEvidenceSnapshotTests.cs
// Sprint: SPRINT_5100_0009_0001 (Scanner Tests)
// Task: SCANNER-5100-005 - Add snapshot tests for reachability evidence emission
// Description: Snapshot tests verifying canonical JSON output for reachability
// evidence including RichGraph, EdgeBundle, and lattice results.
// Uses baseline fixtures with UPDATE_REACH_SNAPSHOTS=1 to regenerate.
// -----------------------------------------------------------------------------
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using StellaOps.Cryptography;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Reachability.Gates;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests.Snapshots;
/// <summary>
/// Snapshot tests for reachability evidence emission ensuring canonical, deterministic output.
/// Verifies RichGraph, EdgeBundle, and reachability lattice format stability.
/// </summary>
[Trait("Category", "Snapshot")]
[Trait("Category", "Determinism")]
public sealed class ReachabilityEvidenceSnapshotTests : IDisposable
{
private static readonly JsonSerializerOptions PrettyPrintOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
private static readonly string FixturesDir = Path.Combine(
AppContext.BaseDirectory, "..", "..", "..", "Snapshots", "Fixtures");
private static bool UpdateSnapshots =>
string.Equals(Environment.GetEnvironmentVariable("UPDATE_REACH_SNAPSHOTS"), "1", StringComparison.OrdinalIgnoreCase);
private readonly TempDir _tempDir;
private readonly RichGraphWriter _writer;
public ReachabilityEvidenceSnapshotTests()
{
_tempDir = new TempDir();
_writer = new RichGraphWriter(CryptoHashFactory.CreateDefault());
}
public void Dispose()
{
_tempDir.Dispose();
}
#region RichGraph Snapshot Tests
[Fact]
public async Task RichGraph_MinimalGraph_MatchesSnapshot()
{
// Arrange
var union = BuildMinimalUnionGraph();
var rich = RichGraphBuilder.FromUnion(union, "StellaOps.Scanner", "1.0.0");
// Act
var result = await _writer.WriteAsync(rich, _tempDir.Path, "minimal-graph");
var actualJson = await NormalizeJsonFromFileAsync(result.GraphPath);
// Assert/Update snapshot
var snapshotPath = Path.Combine(FixturesDir, "richgraph-minimal.snapshot.json");
AssertOrUpdateSnapshot(snapshotPath, actualJson);
}
[Fact]
public async Task RichGraph_ComplexGraph_MatchesSnapshot()
{
// Arrange
var union = BuildComplexUnionGraph();
var rich = RichGraphBuilder.FromUnion(union, "StellaOps.Scanner", "1.0.0");
// Act
var result = await _writer.WriteAsync(rich, _tempDir.Path, "complex-graph");
var actualJson = await NormalizeJsonFromFileAsync(result.GraphPath);
// Assert/Update snapshot
var snapshotPath = Path.Combine(FixturesDir, "richgraph-complex.snapshot.json");
AssertOrUpdateSnapshot(snapshotPath, actualJson);
}
[Fact]
public async Task RichGraph_WithGates_MatchesSnapshot()
{
// Arrange
var union = BuildGraphWithGates();
var rich = RichGraphBuilder.FromUnion(union, "StellaOps.Scanner", "1.0.0");
// Apply gates
var gate = new DetectedGate
{
Type = GateType.AuthRequired,
Detail = "Auth required: JWT validation",
GuardSymbol = "sym:dotnet:Controller.SecureEndpoint",
Confidence = 0.92,
DetectionMethod = "annotation:[Authorize]"
};
rich = rich with
{
Edges = rich.Edges.Select((e, i) => i == 0 ? e with { Gates = new[] { gate }, GateMultiplierBps = 2500 } : e).ToArray()
};
// Act
var result = await _writer.WriteAsync(rich, _tempDir.Path, "gated-graph");
var actualJson = await NormalizeJsonFromFileAsync(result.GraphPath);
// Assert/Update snapshot
var snapshotPath = Path.Combine(FixturesDir, "richgraph-with-gates.snapshot.json");
AssertOrUpdateSnapshot(snapshotPath, actualJson);
}
[Fact]
public async Task RichGraph_WithSymbolMetadata_MatchesSnapshot()
{
// Arrange
var union = new ReachabilityUnionGraph(
Nodes: new[]
{
new ReachabilityUnionNode(
"sym:binary:ssl_read",
"binary",
"function",
"ssl_read",
CodeBlockHash: "sha256:abcd1234efgh5678",
Symbol: new ReachabilitySymbol("_Zssl_readPvj", "ssl_read", "DWARF", 0.95)),
new ReachabilityUnionNode(
"sym:binary:main",
"binary",
"function",
"main",
CodeBlockHash: "sha256:main0000hash1234",
Symbol: new ReachabilitySymbol("main", "main", "ELF_SYMTAB", 1.0))
},
Edges: new[]
{
new ReachabilityUnionEdge("sym:binary:main", "sym:binary:ssl_read", "call", "high")
});
var rich = RichGraphBuilder.FromUnion(union, "StellaOps.Scanner", "1.0.0");
// Act
var result = await _writer.WriteAsync(rich, _tempDir.Path, "symbol-rich-graph");
var actualJson = await NormalizeJsonFromFileAsync(result.GraphPath);
// Assert/Update snapshot
var snapshotPath = Path.Combine(FixturesDir, "richgraph-with-symbols.snapshot.json");
AssertOrUpdateSnapshot(snapshotPath, actualJson);
}
#endregion
#region Meta File Snapshot Tests
[Fact]
public async Task RichGraph_MetaFile_MatchesSnapshot()
{
// Arrange
var union = BuildMinimalUnionGraph();
var rich = RichGraphBuilder.FromUnion(union, "StellaOps.Scanner", "1.0.0");
// Act
var result = await _writer.WriteAsync(rich, _tempDir.Path, "meta-test");
var actualJson = await NormalizeJsonFromFileAsync(result.MetaPath);
// Assert/Update snapshot
var snapshotPath = Path.Combine(FixturesDir, "richgraph-meta.snapshot.json");
// Meta includes paths which vary by machine; normalize
actualJson = NormalizeMeta(actualJson);
AssertOrUpdateSnapshot(snapshotPath, actualJson);
}
#endregion
#region Hash Stability Tests
[Fact]
public async Task RichGraph_HashIsStable_AcrossMultipleWrites()
{
var union = BuildComplexUnionGraph();
var rich = RichGraphBuilder.FromUnion(union, "StellaOps.Scanner", "1.0.0");
var hashes = new List<string>();
for (int i = 0; i < 5; i++)
{
var result = await _writer.WriteAsync(rich, _tempDir.Path, $"stability-{i}");
hashes.Add(result.GraphHash);
}
hashes.Distinct().Should().HaveCount(1, "RichGraph hash should be stable across writes");
}
[Fact]
public async Task DifferentNodeOrder_ProducesSameHash()
{
// Create two graphs with nodes in different order
var union1 = new ReachabilityUnionGraph(
Nodes: new[]
{
new ReachabilityUnionNode("sym:dotnet:A", "dotnet", "method", "A"),
new ReachabilityUnionNode("sym:dotnet:B", "dotnet", "method", "B"),
new ReachabilityUnionNode("sym:dotnet:C", "dotnet", "method", "C")
},
Edges: new[]
{
new ReachabilityUnionEdge("sym:dotnet:A", "sym:dotnet:B", "call", "high"),
new ReachabilityUnionEdge("sym:dotnet:B", "sym:dotnet:C", "call", "medium")
});
var union2 = new ReachabilityUnionGraph(
Nodes: new[]
{
new ReachabilityUnionNode("sym:dotnet:C", "dotnet", "method", "C"),
new ReachabilityUnionNode("sym:dotnet:A", "dotnet", "method", "A"),
new ReachabilityUnionNode("sym:dotnet:B", "dotnet", "method", "B")
},
Edges: new[]
{
new ReachabilityUnionEdge("sym:dotnet:B", "sym:dotnet:C", "call", "medium"),
new ReachabilityUnionEdge("sym:dotnet:A", "sym:dotnet:B", "call", "high")
});
var rich1 = RichGraphBuilder.FromUnion(union1, "StellaOps.Scanner", "1.0.0");
var rich2 = RichGraphBuilder.FromUnion(union2, "StellaOps.Scanner", "1.0.0");
var result1 = await _writer.WriteAsync(rich1, _tempDir.Path, "order-1");
var result2 = await _writer.WriteAsync(rich2, _tempDir.Path, "order-2");
result1.GraphHash.Should().Be(result2.GraphHash, "node/edge input order should not affect hash");
}
[Fact]
public async Task EmptyGraph_ProducesStableHash()
{
var union = new ReachabilityUnionGraph(
Nodes: Array.Empty<ReachabilityUnionNode>(),
Edges: Array.Empty<ReachabilityUnionEdge>());
var rich = RichGraphBuilder.FromUnion(union, "StellaOps.Scanner", "1.0.0");
var hashes = new List<string>();
for (int i = 0; i < 3; i++)
{
var result = await _writer.WriteAsync(rich, _tempDir.Path, $"empty-{i}");
hashes.Add(result.GraphHash);
}
hashes.Distinct().Should().HaveCount(1, "empty graph hash should be stable");
}
#endregion
#region EdgeBundle Tests
[Fact]
public void EdgeBundle_Serialize_MatchesExpectedFormat()
{
// Arrange
var bundle = new EdgeBundle(
SourceId: "sym:dotnet:Controller.Action",
TargetIds: new[] { "sym:dotnet:Service.Method", "sym:dotnet:Repository.Save" },
EdgeType: "call",
Confidence: "high",
Metadata: new Dictionary<string, string>
{
["source_file"] = "Controllers/UserController.cs",
["source_line"] = "42"
});
// Act
var json = JsonSerializer.Serialize(bundle, PrettyPrintOptions);
// Assert
json.Should().Contain("\"sourceId\":");
json.Should().Contain("\"targetIds\":");
json.Should().Contain("\"edgeType\":");
json.Should().Contain("\"confidence\":");
}
[Fact]
public void EdgeBundle_SerializationIsStable()
{
var bundle = new EdgeBundle(
SourceId: "sym:binary:main",
TargetIds: new[] { "sym:binary:foo", "sym:binary:bar" },
EdgeType: "call",
Confidence: "medium");
var json1 = JsonSerializer.Serialize(bundle, PrettyPrintOptions);
var json2 = JsonSerializer.Serialize(bundle, PrettyPrintOptions);
json1.Should().Be(json2, "EdgeBundle serialization should be deterministic");
}
#endregion
#region Helpers
private static ReachabilityUnionGraph BuildMinimalUnionGraph()
{
return new ReachabilityUnionGraph(
Nodes: new[]
{
new ReachabilityUnionNode("sym:dotnet:Entry", "dotnet", "method", "Entry"),
new ReachabilityUnionNode("sym:dotnet:Sink", "dotnet", "method", "VulnSink")
},
Edges: new[]
{
new ReachabilityUnionEdge("sym:dotnet:Entry", "sym:dotnet:Sink", "call", "high")
});
}
private static ReachabilityUnionGraph BuildComplexUnionGraph()
{
return new ReachabilityUnionGraph(
Nodes: new[]
{
new ReachabilityUnionNode("sym:dotnet:Program.Main", "dotnet", "method", "Main"),
new ReachabilityUnionNode("sym:dotnet:Controller.Get", "dotnet", "method", "Get"),
new ReachabilityUnionNode("sym:dotnet:Service.Process", "dotnet", "method", "Process"),
new ReachabilityUnionNode("sym:dotnet:Repository.Query", "dotnet", "method", "Query"),
new ReachabilityUnionNode("sym:dotnet:VulnLib.Execute", "dotnet", "method", "Execute", IsSink: true)
},
Edges: new[]
{
new ReachabilityUnionEdge("sym:dotnet:Program.Main", "sym:dotnet:Controller.Get", "call", "high"),
new ReachabilityUnionEdge("sym:dotnet:Controller.Get", "sym:dotnet:Service.Process", "call", "high"),
new ReachabilityUnionEdge("sym:dotnet:Service.Process", "sym:dotnet:Repository.Query", "call", "medium"),
new ReachabilityUnionEdge("sym:dotnet:Service.Process", "sym:dotnet:VulnLib.Execute", "call", "high")
});
}
private static ReachabilityUnionGraph BuildGraphWithGates()
{
return new ReachabilityUnionGraph(
Nodes: new[]
{
new ReachabilityUnionNode("sym:dotnet:Controller.PublicEndpoint", "dotnet", "method", "PublicEndpoint"),
new ReachabilityUnionNode("sym:dotnet:Controller.SecureEndpoint", "dotnet", "method", "SecureEndpoint"),
new ReachabilityUnionNode("sym:dotnet:Service.SensitiveOp", "dotnet", "method", "SensitiveOp")
},
Edges: new[]
{
new ReachabilityUnionEdge("sym:dotnet:Controller.PublicEndpoint", "sym:dotnet:Controller.SecureEndpoint", "call", "high"),
new ReachabilityUnionEdge("sym:dotnet:Controller.SecureEndpoint", "sym:dotnet:Service.SensitiveOp", "call", "high")
});
}
private static void AssertOrUpdateSnapshot(string snapshotPath, string actualJson)
{
Directory.CreateDirectory(Path.GetDirectoryName(snapshotPath)!);
if (UpdateSnapshots)
{
File.WriteAllText(snapshotPath, actualJson, Encoding.UTF8);
return;
}
if (!File.Exists(snapshotPath))
{
throw new InvalidOperationException(
$"Snapshot '{snapshotPath}' not found. Set UPDATE_REACH_SNAPSHOTS=1 to generate.");
}
var expectedJson = File.ReadAllText(snapshotPath, Encoding.UTF8);
AssertJsonEquivalent(expectedJson, actualJson);
}
private static void AssertJsonEquivalent(string expected, string actual)
{
using var expectedDoc = JsonDocument.Parse(expected);
using var actualDoc = JsonDocument.Parse(actual);
var expectedHash = ComputeCanonicalHash(expectedDoc);
var actualHash = ComputeCanonicalHash(actualDoc);
if (expectedHash != actualHash)
{
var expectedNorm = JsonSerializer.Serialize(
JsonSerializer.Deserialize<JsonElement>(expected), PrettyPrintOptions);
var actualNorm = JsonSerializer.Serialize(
JsonSerializer.Deserialize<JsonElement>(actual), PrettyPrintOptions);
actualNorm.Should().Be(expectedNorm, "Reachability evidence output should match snapshot");
}
}
private static string ComputeCanonicalHash(JsonDocument doc)
{
var canonical = JsonSerializer.SerializeToUtf8Bytes(doc.RootElement);
var hash = SHA256.HashData(canonical);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static async Task<string> NormalizeJsonFromFileAsync(string path)
{
var bytes = await File.ReadAllBytesAsync(path);
using var doc = JsonDocument.Parse(bytes);
return JsonSerializer.Serialize(doc.RootElement, PrettyPrintOptions);
}
private static string NormalizeMeta(string json)
{
// Replace absolute paths with relative markers for snapshot comparison
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var normalized = new Dictionary<string, object?>
{
["schema"] = root.GetProperty("schema").GetString(),
["graph_hash"] = "{{HASH}}", // Hash varies by content
["files"] = new[]
{
new Dictionary<string, string>
{
["path"] = "{{PATH}}",
["hash"] = "{{HASH}}"
}
}
};
return JsonSerializer.Serialize(normalized, PrettyPrintOptions);
}
#endregion
private sealed class TempDir : IDisposable
{
public string Path { get; }
public TempDir()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path);
}
public void Dispose()
{
try
{
Directory.Delete(Path, recursive: true);
}
catch
{
// Best effort cleanup
}
}
}
}

View File

@@ -9,8 +9,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="FsCheck" Version="2.16.6" />
<PackageReference Include="FsCheck.Xunit" Version="2.16.6" />
<PackageReference Include="JsonSchema.Net" Version="7.3.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>