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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user