sprints work
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Canonical Serializer Adapter
|
||||
* Sprint: SPRINT_9100_0001_0001 (Core Resolver Package)
|
||||
* Tasks: RESOLVER-9100-017
|
||||
*
|
||||
* Wraps CanonicalJsonSerializer for use with resolver interfaces.
|
||||
*/
|
||||
|
||||
using StellaOps.Canonicalization.Json;
|
||||
|
||||
namespace StellaOps.Resolver;
|
||||
|
||||
/// <summary>
|
||||
/// Adapter wrapping CanonicalJsonSerializer.
|
||||
/// </summary>
|
||||
public sealed class CanonicalSerializerAdapter : ICanonicalSerializer
|
||||
{
|
||||
public string Serialize<T>(T value)
|
||||
=> CanonicalJsonSerializer.Serialize(value);
|
||||
|
||||
public (string Json, string Digest) SerializeWithDigest<T>(T value)
|
||||
=> CanonicalJsonSerializer.SerializeWithDigest(value);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Default Trust Lattice Evaluator
|
||||
* Sprint: SPRINT_9100_0001_0001 (Core Resolver Package)
|
||||
* Tasks: RESOLVER-9100-016
|
||||
*
|
||||
* Provides a default implementation of ITrustLatticeEvaluator.
|
||||
* Uses pure evaluation without ambient access.
|
||||
*/
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Resolver;
|
||||
|
||||
/// <summary>
|
||||
/// Default trust lattice evaluator using pure evaluation.
|
||||
/// </summary>
|
||||
public sealed class DefaultTrustLatticeEvaluator : ITrustLatticeEvaluator
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates a node based on its inbound edges and predecessor verdicts.
|
||||
/// </summary>
|
||||
public Verdict Evaluate(
|
||||
Node node,
|
||||
IReadOnlyList<Edge> inboundEdges,
|
||||
Policy policy,
|
||||
IReadOnlyDictionary<NodeId, Verdict> predecessorVerdicts)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(node);
|
||||
|
||||
// If no inbound edges, default to Pass (root node)
|
||||
if (inboundEdges.Count == 0)
|
||||
{
|
||||
return Verdict.Create(
|
||||
node.Id,
|
||||
VerdictStatus.Pass,
|
||||
CreateEvidence("No inbound evidence; root node"),
|
||||
"Root node - no dependencies");
|
||||
}
|
||||
|
||||
// Check predecessor verdicts
|
||||
var hasFailingPredecessor = false;
|
||||
var hasBlockedPredecessor = false;
|
||||
var hasConflict = false;
|
||||
var allPredecessorsPass = true;
|
||||
|
||||
foreach (var edge in inboundEdges)
|
||||
{
|
||||
if (predecessorVerdicts.TryGetValue(edge.Src, out var predVerdict))
|
||||
{
|
||||
switch (predVerdict.Status)
|
||||
{
|
||||
case VerdictStatus.Fail:
|
||||
hasFailingPredecessor = true;
|
||||
allPredecessorsPass = false;
|
||||
break;
|
||||
case VerdictStatus.Blocked:
|
||||
hasBlockedPredecessor = true;
|
||||
allPredecessorsPass = false;
|
||||
break;
|
||||
case VerdictStatus.Conflict:
|
||||
hasConflict = true;
|
||||
allPredecessorsPass = false;
|
||||
break;
|
||||
case VerdictStatus.Warn:
|
||||
// Warn still allows passing
|
||||
break;
|
||||
case VerdictStatus.Pass:
|
||||
case VerdictStatus.Ignored:
|
||||
// Good - maintain allPredecessorsPass
|
||||
break;
|
||||
default:
|
||||
allPredecessorsPass = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine verdict based on aggregate predecessor status
|
||||
if (hasConflict)
|
||||
{
|
||||
return Verdict.Create(
|
||||
node.Id,
|
||||
VerdictStatus.Conflict,
|
||||
CreateEvidence("Predecessor has conflicting evidence"),
|
||||
"Conflict inherited from predecessor");
|
||||
}
|
||||
|
||||
if (hasBlockedPredecessor)
|
||||
{
|
||||
return Verdict.Create(
|
||||
node.Id,
|
||||
VerdictStatus.Blocked,
|
||||
CreateEvidence("Predecessor is blocked"),
|
||||
"Blocked due to predecessor");
|
||||
}
|
||||
|
||||
if (hasFailingPredecessor)
|
||||
{
|
||||
return Verdict.Create(
|
||||
node.Id,
|
||||
VerdictStatus.Fail,
|
||||
CreateEvidence("Predecessor failed evaluation"),
|
||||
"Failed due to predecessor");
|
||||
}
|
||||
|
||||
if (allPredecessorsPass)
|
||||
{
|
||||
return Verdict.Create(
|
||||
node.Id,
|
||||
VerdictStatus.Pass,
|
||||
CreateEvidence("All predecessors pass"),
|
||||
"All dependencies satisfied");
|
||||
}
|
||||
|
||||
// Default: unknown status
|
||||
return Verdict.Create(
|
||||
node.Id,
|
||||
VerdictStatus.Unknown,
|
||||
CreateEvidence("Indeterminate predecessor state"),
|
||||
"Unable to determine verdict");
|
||||
}
|
||||
|
||||
private static JsonElement CreateEvidence(string reason)
|
||||
{
|
||||
var json = $$"""{"reason": "{{reason}}"}""";
|
||||
return JsonDocument.Parse(json).RootElement;
|
||||
}
|
||||
}
|
||||
153
src/__Libraries/StellaOps.Resolver/DeterministicResolver.cs
Normal file
153
src/__Libraries/StellaOps.Resolver/DeterministicResolver.cs
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* DeterministicResolver - Core Implementation
|
||||
* Sprint: SPRINT_9100_0001_0001 (Core Resolver Package)
|
||||
* Tasks: RESOLVER-9100-010, RESOLVER-9100-011, RESOLVER-9100-012, RESOLVER-9100-013, RESOLVER-9100-014
|
||||
*
|
||||
* Main resolver implementation providing:
|
||||
* - Deterministic graph canonicalization
|
||||
* - Ordered traversal
|
||||
* - Per-node evaluation
|
||||
* - Digest computation
|
||||
*/
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Resolver;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic resolver that guarantees reproducible results.
|
||||
/// </summary>
|
||||
public sealed class DeterministicResolver : IDeterministicResolver
|
||||
{
|
||||
private readonly Policy _policy;
|
||||
private readonly IGraphOrderer _orderer;
|
||||
private readonly ITrustLatticeEvaluator _evaluator;
|
||||
private readonly IFinalDigestComputer _digestComputer;
|
||||
private readonly IGraphValidator _validator;
|
||||
private readonly string _version;
|
||||
|
||||
public DeterministicResolver(
|
||||
Policy policy,
|
||||
IGraphOrderer orderer,
|
||||
ITrustLatticeEvaluator evaluator,
|
||||
IFinalDigestComputer? digestComputer = null,
|
||||
IGraphValidator? validator = null,
|
||||
string? version = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
ArgumentNullException.ThrowIfNull(orderer);
|
||||
ArgumentNullException.ThrowIfNull(evaluator);
|
||||
|
||||
_policy = policy;
|
||||
_orderer = orderer;
|
||||
_evaluator = evaluator;
|
||||
_digestComputer = digestComputer ?? new Sha256FinalDigestComputer();
|
||||
_validator = validator ?? new DefaultGraphValidator();
|
||||
_version = version ?? "1.0.0";
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ResolutionResult Run(EvidenceGraph graph)
|
||||
=> Run(graph, DateTimeOffset.UtcNow);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ResolutionResult Run(EvidenceGraph graph, DateTimeOffset resolvedAt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
// Phase 1: Validate graph
|
||||
var validationResult = _validator.Validate(graph);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
throw new InvalidGraphException(validationResult);
|
||||
}
|
||||
|
||||
// Phase 2: Compute traversal order
|
||||
var traversalOrder = _orderer.OrderNodes(graph);
|
||||
|
||||
// Phase 3: Evaluate each node in order
|
||||
var verdicts = new Dictionary<NodeId, Verdict>();
|
||||
var verdictList = new List<Verdict>();
|
||||
|
||||
for (var i = 0; i < traversalOrder.Count; i++)
|
||||
{
|
||||
var nodeId = traversalOrder[i];
|
||||
var node = graph.GetNode(nodeId);
|
||||
|
||||
if (node is null)
|
||||
{
|
||||
// Node referenced but not in graph - this should be caught by validation
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gather inbound evidence (edges where Dst == nodeId)
|
||||
var inboundEdges = GatherInboundEvidence(graph, nodeId);
|
||||
|
||||
// Build predecessor verdicts dictionary
|
||||
var predecessorVerdicts = new Dictionary<NodeId, Verdict>();
|
||||
foreach (var edge in inboundEdges)
|
||||
{
|
||||
if (verdicts.TryGetValue(edge.Src, out var srcVerdict))
|
||||
{
|
||||
predecessorVerdicts[edge.Src] = srcVerdict;
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate pure (no IO)
|
||||
var verdict = EvaluatePure(node, inboundEdges, _policy, predecessorVerdicts, i);
|
||||
|
||||
verdicts[nodeId] = verdict;
|
||||
verdictList.Add(verdict);
|
||||
}
|
||||
|
||||
// Phase 4: Compute final digest
|
||||
var verdictEntries = verdictList
|
||||
.Select(v => new VerdictDigestEntry(v.Node.Value, v.VerdictDigest))
|
||||
.ToImmutableArray();
|
||||
|
||||
var digestInput = new DigestInput(
|
||||
graph.GraphDigest,
|
||||
_policy.Digest,
|
||||
verdictEntries);
|
||||
|
||||
var finalDigest = _digestComputer.Compute(digestInput);
|
||||
|
||||
return new ResolutionResult
|
||||
{
|
||||
TraversalSequence = traversalOrder.ToImmutableArray(),
|
||||
Verdicts = verdictList.ToImmutableArray(),
|
||||
GraphDigest = graph.GraphDigest,
|
||||
PolicyDigest = _policy.Digest,
|
||||
FinalDigest = finalDigest,
|
||||
ResolvedAt = resolvedAt,
|
||||
ResolverVersion = _version
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gathers all inbound edges for a node (edges where Dst == nodeId).
|
||||
/// </summary>
|
||||
private static IReadOnlyList<Edge> GatherInboundEvidence(EvidenceGraph graph, NodeId nodeId)
|
||||
{
|
||||
return graph.Edges
|
||||
.Where(e => e.Dst == nodeId)
|
||||
.OrderBy(e => e.Id) // Deterministic ordering
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure evaluation function - no IO allowed.
|
||||
/// </summary>
|
||||
private Verdict EvaluatePure(
|
||||
Node node,
|
||||
IReadOnlyList<Edge> inboundEdges,
|
||||
Policy policy,
|
||||
IReadOnlyDictionary<NodeId, Verdict> predecessorVerdicts,
|
||||
int traversalIndex)
|
||||
{
|
||||
return _evaluator.Evaluate(node, inboundEdges, policy, predecessorVerdicts) with
|
||||
{
|
||||
TraversalIndex = traversalIndex
|
||||
};
|
||||
}
|
||||
}
|
||||
85
src/__Libraries/StellaOps.Resolver/Edge.cs
Normal file
85
src/__Libraries/StellaOps.Resolver/Edge.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Edge - Graph Edge Model
|
||||
* Sprint: SPRINT_9100_0001_0001 (Core Resolver Package)
|
||||
* Task: RESOLVER-9100-004
|
||||
*
|
||||
* Extended in Sprint: SPRINT_9100_0001_0002 (Cycle-Cut Edge Support)
|
||||
* Task: CYCLE-9100-001
|
||||
*
|
||||
* Represents a directed edge in the evidence graph.
|
||||
* Edges have:
|
||||
* - A content-addressed EdgeId (computed from src, kind, dst)
|
||||
* - Source and destination NodeIds
|
||||
* - A kind (type of relationship)
|
||||
* - Optional attributes as JSON
|
||||
* - IsCycleCut flag for cycle handling
|
||||
*/
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Resolver;
|
||||
|
||||
/// <summary>
|
||||
/// A directed edge in the evidence graph with content-addressed identity.
|
||||
/// </summary>
|
||||
/// <param name="Id">Content-addressed edge identifier (computed on construction).</param>
|
||||
/// <param name="Src">Source node identifier.</param>
|
||||
/// <param name="Kind">Edge kind (e.g., "depends_on", "calls", "imports", "affects").</param>
|
||||
/// <param name="Dst">Destination node identifier.</param>
|
||||
/// <param name="Attrs">Optional edge attributes as JSON.</param>
|
||||
/// <param name="IsCycleCut">True if this edge breaks a cycle for topological ordering.</param>
|
||||
public sealed record Edge(
|
||||
EdgeId Id,
|
||||
NodeId Src,
|
||||
string Kind,
|
||||
NodeId Dst,
|
||||
JsonElement? Attrs = null,
|
||||
bool IsCycleCut = false)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an edge with automatically computed EdgeId.
|
||||
/// </summary>
|
||||
public static Edge Create(NodeId src, string kind, NodeId dst, JsonElement? attrs = null, bool isCycleCut = false)
|
||||
{
|
||||
var id = EdgeId.From(src, kind, dst);
|
||||
return new Edge(id, src, kind, dst, attrs, isCycleCut);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a cycle-cut edge that breaks cycles for topological ordering.
|
||||
/// Cycle-cut edges are included in digests but excluded from traversal dependencies.
|
||||
/// </summary>
|
||||
public static Edge CreateCycleCut(NodeId src, string kind, NodeId dst, JsonElement? attrs = null)
|
||||
=> Create(src, kind, dst, attrs, isCycleCut: true);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an attribute value by key path.
|
||||
/// </summary>
|
||||
public T? GetAttr<T>(string path)
|
||||
{
|
||||
if (Attrs is null || Attrs.Value.ValueKind == JsonValueKind.Undefined)
|
||||
return default;
|
||||
|
||||
try
|
||||
{
|
||||
var current = Attrs.Value;
|
||||
foreach (var segment in path.Split('.'))
|
||||
{
|
||||
if (current.ValueKind != JsonValueKind.Object)
|
||||
return default;
|
||||
if (!current.TryGetProperty(segment, out current))
|
||||
return default;
|
||||
}
|
||||
return current.Deserialize<T>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a new edge with IsCycleCut set to true.
|
||||
/// </summary>
|
||||
public Edge AsCycleCut() => this with { IsCycleCut = true };
|
||||
}
|
||||
111
src/__Libraries/StellaOps.Resolver/EdgeDelta.cs
Normal file
111
src/__Libraries/StellaOps.Resolver/EdgeDelta.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Edge Delta Detection
|
||||
* Sprint: SPRINT_9100_0001_0003 (Content-Addressed EdgeId)
|
||||
* Tasks: EDGEID-9100-012 through EDGEID-9100-014
|
||||
*
|
||||
* Provides delta detection between evidence graphs at the edge level.
|
||||
*/
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Resolver;
|
||||
|
||||
/// <summary>
|
||||
/// Delta between two graphs at the edge level.
|
||||
/// </summary>
|
||||
/// <param name="AddedEdges">Edges present in new graph but not in old.</param>
|
||||
/// <param name="RemovedEdges">Edges present in old graph but not in new.</param>
|
||||
/// <param name="ModifiedEdges">Edges with same (src, kind, dst) but different attributes.</param>
|
||||
public sealed record EdgeDelta(
|
||||
ImmutableArray<Edge> AddedEdges,
|
||||
ImmutableArray<Edge> RemovedEdges,
|
||||
ImmutableArray<(Edge Old, Edge New)> ModifiedEdges)
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if there are no differences.
|
||||
/// </summary>
|
||||
public bool IsEmpty => AddedEdges.IsEmpty && RemovedEdges.IsEmpty && ModifiedEdges.IsEmpty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for detecting edge deltas.
|
||||
/// </summary>
|
||||
public interface IEdgeDeltaDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Detects differences between two graphs at the edge level.
|
||||
/// </summary>
|
||||
EdgeDelta Detect(EvidenceGraph old, EvidenceGraph @new);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default edge delta detector.
|
||||
/// </summary>
|
||||
public sealed class DefaultEdgeDeltaDetector : IEdgeDeltaDetector
|
||||
{
|
||||
public EdgeDelta Detect(EvidenceGraph old, EvidenceGraph @new)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(old);
|
||||
ArgumentNullException.ThrowIfNull(@new);
|
||||
|
||||
// Group edges by their identity (EdgeId), which is based on (src, kind, dst)
|
||||
var oldEdges = old.Edges.ToDictionary(e => e.Id);
|
||||
var newEdges = @new.Edges.ToDictionary(e => e.Id);
|
||||
|
||||
var added = new List<Edge>();
|
||||
var removed = new List<Edge>();
|
||||
var modified = new List<(Edge Old, Edge New)>();
|
||||
|
||||
// Find added and modified
|
||||
foreach (var (edgeId, newEdge) in newEdges)
|
||||
{
|
||||
if (oldEdges.TryGetValue(edgeId, out var oldEdge))
|
||||
{
|
||||
// Same EdgeId - check if attributes changed
|
||||
if (!AttributesEqual(oldEdge.Attrs, newEdge.Attrs))
|
||||
{
|
||||
modified.Add((oldEdge, newEdge));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
added.Add(newEdge);
|
||||
}
|
||||
}
|
||||
|
||||
// Find removed
|
||||
foreach (var (edgeId, oldEdge) in oldEdges)
|
||||
{
|
||||
if (!newEdges.ContainsKey(edgeId))
|
||||
{
|
||||
removed.Add(oldEdge);
|
||||
}
|
||||
}
|
||||
|
||||
return new EdgeDelta(
|
||||
added.ToImmutableArray(),
|
||||
removed.ToImmutableArray(),
|
||||
modified.ToImmutableArray());
|
||||
}
|
||||
|
||||
private static bool AttributesEqual(JsonElement? a, JsonElement? b)
|
||||
{
|
||||
if (a is null && b is null) return true;
|
||||
if (a is null || b is null) return false;
|
||||
|
||||
var aHash = ComputeAttrsHash(a.Value);
|
||||
var bHash = ComputeAttrsHash(b.Value);
|
||||
|
||||
return aHash == bHash;
|
||||
}
|
||||
|
||||
private static string ComputeAttrsHash(JsonElement attrs)
|
||||
{
|
||||
var json = attrs.GetRawText();
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
92
src/__Libraries/StellaOps.Resolver/EdgeId.cs
Normal file
92
src/__Libraries/StellaOps.Resolver/EdgeId.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* EdgeId - Content-Addressed Edge Identifier
|
||||
* Sprint: SPRINT_9100_0001_0003 (Content-Addressed EdgeId)
|
||||
* Task: EDGEID-9100-001, EDGEID-9100-002, EDGEID-9100-003
|
||||
*
|
||||
* A content-addressed identifier for graph edges.
|
||||
* EdgeId = sha256(srcId + "->" + kind + "->" + dstId)
|
||||
*
|
||||
* Enables:
|
||||
* - Edge-level attestations
|
||||
* - Delta detection between graphs
|
||||
* - Merkle tree inclusion for proof chains
|
||||
*/
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Resolver;
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed edge identifier computed as SHA256 of src->kind->dst.
|
||||
/// Immutable value type for deterministic graph operations.
|
||||
/// </summary>
|
||||
public readonly record struct EdgeId : IComparable<EdgeId>, IEquatable<EdgeId>
|
||||
{
|
||||
private readonly string _value;
|
||||
|
||||
/// <summary>
|
||||
/// The SHA256 hex digest (lowercase, 64 characters).
|
||||
/// </summary>
|
||||
public string Value => _value ?? string.Empty;
|
||||
|
||||
private EdgeId(string value) => _value = value;
|
||||
|
||||
/// <summary>
|
||||
/// Creates an EdgeId from a pre-computed digest value.
|
||||
/// Use <see cref="From(NodeId, string, NodeId)"/> for computing from components.
|
||||
/// </summary>
|
||||
/// <param name="digest">A valid SHA256 hex digest (64 lowercase hex chars).</param>
|
||||
public static EdgeId FromDigest(string digest)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
|
||||
if (digest.Length != 64)
|
||||
throw new ArgumentException("EdgeId digest must be 64 hex characters", nameof(digest));
|
||||
|
||||
return new EdgeId(digest.ToLowerInvariant());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes an EdgeId from source, kind, and destination.
|
||||
/// Format: sha256(srcId->kind->dstId)
|
||||
/// </summary>
|
||||
/// <param name="src">Source node identifier.</param>
|
||||
/// <param name="kind">Edge kind (e.g., "depends_on", "calls", "imports").</param>
|
||||
/// <param name="dst">Destination node identifier.</param>
|
||||
/// <returns>Content-addressed EdgeId.</returns>
|
||||
public static EdgeId From(NodeId src, string kind, NodeId dst)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(kind);
|
||||
|
||||
// NFC normalize kind for Unicode consistency
|
||||
var normalizedKind = kind.Normalize(NormalizationForm.FormC);
|
||||
|
||||
var input = $"{src.Value}->{normalizedKind}->{dst.Value}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
var digest = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
|
||||
return new EdgeId(digest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ordinal comparison for deterministic ordering.
|
||||
/// </summary>
|
||||
public int CompareTo(EdgeId other)
|
||||
=> string.Compare(Value, other.Value, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Equality is based on digest value.
|
||||
/// </summary>
|
||||
public bool Equals(EdgeId other)
|
||||
=> string.Equals(Value, other.Value, StringComparison.Ordinal);
|
||||
|
||||
public override int GetHashCode()
|
||||
=> Value.GetHashCode(StringComparison.Ordinal);
|
||||
|
||||
public override string ToString() => Value;
|
||||
|
||||
public static bool operator <(EdgeId left, EdgeId right) => left.CompareTo(right) < 0;
|
||||
public static bool operator >(EdgeId left, EdgeId right) => left.CompareTo(right) > 0;
|
||||
public static bool operator <=(EdgeId left, EdgeId right) => left.CompareTo(right) <= 0;
|
||||
public static bool operator >=(EdgeId left, EdgeId right) => left.CompareTo(right) >= 0;
|
||||
}
|
||||
125
src/__Libraries/StellaOps.Resolver/EvidenceGraph.cs
Normal file
125
src/__Libraries/StellaOps.Resolver/EvidenceGraph.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* EvidenceGraph - Graph Container
|
||||
* Sprint: SPRINT_9100_0001_0001 (Core Resolver Package)
|
||||
* Task: RESOLVER-9100-006
|
||||
*
|
||||
* Immutable container for nodes and edges representing an evidence graph.
|
||||
* Provides content-addressed graph digest for verification.
|
||||
*/
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Canonicalization.Json;
|
||||
|
||||
namespace StellaOps.Resolver;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable evidence graph containing nodes and edges.
|
||||
/// </summary>
|
||||
public sealed record EvidenceGraph
|
||||
{
|
||||
/// <summary>
|
||||
/// All nodes in the graph, sorted by NodeId for determinism.
|
||||
/// </summary>
|
||||
public ImmutableArray<Node> Nodes { get; init; } = ImmutableArray<Node>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// All edges in the graph, sorted by EdgeId for determinism.
|
||||
/// </summary>
|
||||
public ImmutableArray<Edge> Edges { get; init; } = ImmutableArray<Edge>.Empty;
|
||||
|
||||
private string? _graphDigest;
|
||||
private ImmutableArray<NodeId>? _nodeIds;
|
||||
private ImmutableArray<EdgeId>? _edgeIds;
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed digest of the entire graph.
|
||||
/// </summary>
|
||||
public string GraphDigest => _graphDigest ??= ComputeGraphDigest();
|
||||
|
||||
/// <summary>
|
||||
/// All node IDs in sorted order.
|
||||
/// </summary>
|
||||
public ImmutableArray<NodeId> NodeIds => _nodeIds ??= Nodes.Select(n => n.Id).OrderBy(id => id).ToImmutableArray();
|
||||
|
||||
/// <summary>
|
||||
/// All edge IDs in sorted order.
|
||||
/// </summary>
|
||||
public ImmutableArray<EdgeId> EdgeIds => _edgeIds ??= Edges.Select(e => e.Id).OrderBy(id => id).ToImmutableArray();
|
||||
|
||||
/// <summary>
|
||||
/// Creates an evidence graph from nodes and edges.
|
||||
/// Sorts both collections for deterministic ordering.
|
||||
/// </summary>
|
||||
public static EvidenceGraph Create(IEnumerable<Node> nodes, IEnumerable<Edge> edges)
|
||||
{
|
||||
var sortedNodes = nodes
|
||||
.OrderBy(n => n.Id)
|
||||
.ToImmutableArray();
|
||||
|
||||
var sortedEdges = edges
|
||||
.OrderBy(e => e.Id)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new EvidenceGraph
|
||||
{
|
||||
Nodes = sortedNodes,
|
||||
Edges = sortedEdges
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty evidence graph.
|
||||
/// </summary>
|
||||
public static EvidenceGraph Empty => new();
|
||||
|
||||
/// <summary>
|
||||
/// Returns a new graph with an additional node.
|
||||
/// </summary>
|
||||
public EvidenceGraph AddNode(Node node)
|
||||
{
|
||||
var nodes = Nodes.Add(node).OrderBy(n => n.Id).ToImmutableArray();
|
||||
return this with { Nodes = nodes, _graphDigest = null, _nodeIds = null };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a new graph with an additional edge.
|
||||
/// </summary>
|
||||
public EvidenceGraph AddEdge(Edge edge)
|
||||
{
|
||||
var edges = Edges.Add(edge).OrderBy(e => e.Id).ToImmutableArray();
|
||||
return this with { Edges = edges, _graphDigest = null, _edgeIds = null };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a node by its ID.
|
||||
/// </summary>
|
||||
public Node? GetNode(NodeId id)
|
||||
=> Nodes.FirstOrDefault(n => n.Id == id);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all edges where the destination is the given node.
|
||||
/// </summary>
|
||||
public ImmutableArray<Edge> GetInboundEdges(NodeId nodeId)
|
||||
=> Edges.Where(e => e.Dst == nodeId).ToImmutableArray();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all edges where the source is the given node.
|
||||
/// </summary>
|
||||
public ImmutableArray<Edge> GetOutboundEdges(NodeId nodeId)
|
||||
=> Edges.Where(e => e.Src == nodeId).ToImmutableArray();
|
||||
|
||||
private string ComputeGraphDigest()
|
||||
{
|
||||
// Create canonical representation of graph
|
||||
var graphData = new
|
||||
{
|
||||
nodes = NodeIds.Select(id => id.Value).ToArray(),
|
||||
edges = EdgeIds.Select(id => id.Value).ToArray()
|
||||
};
|
||||
|
||||
var (_, digest) = CanonicalJsonSerializer.SerializeWithDigest(graphData);
|
||||
return digest;
|
||||
}
|
||||
}
|
||||
330
src/__Libraries/StellaOps.Resolver/GraphValidation.cs
Normal file
330
src/__Libraries/StellaOps.Resolver/GraphValidation.cs
Normal file
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* Graph Validation - Cycle Detection and Validation
|
||||
* Sprint: SPRINT_9100_0001_0002 (Cycle-Cut Edge Support)
|
||||
* Tasks: CYCLE-9100-002 through CYCLE-9100-012
|
||||
*
|
||||
* Sprint: SPRINT_9100_0003_0002 (Graph Validation & NFC)
|
||||
* Tasks: VALID-9100-007 through VALID-9100-020
|
||||
*
|
||||
* Provides:
|
||||
* - Cycle detection using Tarjan's SCC algorithm
|
||||
* - Validation that all cycles have IsCycleCut edges
|
||||
* - Implicit data detection (dangling edges, duplicates)
|
||||
* - Evidence completeness checking
|
||||
*/
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Resolver;
|
||||
|
||||
/// <summary>
|
||||
/// Information about a detected cycle in the graph.
|
||||
/// </summary>
|
||||
/// <param name="CycleNodes">Nodes forming the cycle.</param>
|
||||
/// <param name="CutEdge">The edge marked as IsCycleCut, if any.</param>
|
||||
public sealed record CycleInfo(
|
||||
ImmutableArray<NodeId> CycleNodes,
|
||||
Edge? CutEdge);
|
||||
|
||||
/// <summary>
|
||||
/// Violation of implicit data rules.
|
||||
/// </summary>
|
||||
/// <param name="ViolationType">Type of violation.</param>
|
||||
/// <param name="NodeId">Related node, if applicable.</param>
|
||||
/// <param name="Description">Human-readable description.</param>
|
||||
public sealed record ImplicitDataViolation(
|
||||
string ViolationType,
|
||||
NodeId? NodeId,
|
||||
string Description);
|
||||
|
||||
/// <summary>
|
||||
/// Result of graph validation.
|
||||
/// </summary>
|
||||
/// <param name="IsValid">True if graph passes all validation checks.</param>
|
||||
/// <param name="Cycles">Detected cycles in the graph.</param>
|
||||
/// <param name="Errors">Validation errors (blocking).</param>
|
||||
/// <param name="Warnings">Validation warnings (non-blocking).</param>
|
||||
/// <param name="ImplicitDataViolations">Implicit data violations found.</param>
|
||||
public sealed record GraphValidationResult(
|
||||
bool IsValid,
|
||||
ImmutableArray<CycleInfo> Cycles,
|
||||
ImmutableArray<string> Errors,
|
||||
ImmutableArray<string> Warnings,
|
||||
ImmutableArray<ImplicitDataViolation> ImplicitDataViolations)
|
||||
{
|
||||
public static GraphValidationResult Valid { get; } = new(
|
||||
true,
|
||||
ImmutableArray<CycleInfo>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<string>.Empty,
|
||||
ImmutableArray<ImplicitDataViolation>.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when graph validation fails.
|
||||
/// </summary>
|
||||
public sealed class InvalidGraphException : Exception
|
||||
{
|
||||
public GraphValidationResult ValidationResult { get; }
|
||||
|
||||
public InvalidGraphException(GraphValidationResult validationResult)
|
||||
: base(FormatMessage(validationResult))
|
||||
{
|
||||
ValidationResult = validationResult;
|
||||
}
|
||||
|
||||
private static string FormatMessage(GraphValidationResult result)
|
||||
{
|
||||
var errors = string.Join("; ", result.Errors);
|
||||
return $"Graph validation failed: {errors}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Graph validator interface.
|
||||
/// </summary>
|
||||
public interface IGraphValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates the evidence graph.
|
||||
/// </summary>
|
||||
GraphValidationResult Validate(EvidenceGraph graph);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cycle detector interface.
|
||||
/// </summary>
|
||||
public interface ICycleDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Detects cycles in the graph.
|
||||
/// </summary>
|
||||
ImmutableArray<CycleInfo> DetectCycles(EvidenceGraph graph);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implicit data detector interface.
|
||||
/// </summary>
|
||||
public interface IImplicitDataDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Detects implicit data violations in the graph.
|
||||
/// </summary>
|
||||
ImmutableArray<ImplicitDataViolation> Detect(EvidenceGraph graph);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tarjan's algorithm for strongly connected component detection.
|
||||
/// Used to detect cycles in the graph.
|
||||
/// </summary>
|
||||
public sealed class TarjanCycleDetector : ICycleDetector
|
||||
{
|
||||
public ImmutableArray<CycleInfo> DetectCycles(EvidenceGraph graph)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
// Build adjacency list, excluding cycle-cut edges
|
||||
var adjacency = new Dictionary<NodeId, List<(NodeId dst, Edge edge)>>();
|
||||
foreach (var node in graph.Nodes)
|
||||
{
|
||||
adjacency[node.Id] = new List<(NodeId, Edge)>();
|
||||
}
|
||||
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
if (!edge.IsCycleCut && adjacency.ContainsKey(edge.Src))
|
||||
{
|
||||
adjacency[edge.Src].Add((edge.Dst, edge));
|
||||
}
|
||||
}
|
||||
|
||||
// Tarjan's algorithm
|
||||
var index = 0;
|
||||
var stack = new Stack<NodeId>();
|
||||
var onStack = new HashSet<NodeId>();
|
||||
var indices = new Dictionary<NodeId, int>();
|
||||
var lowLinks = new Dictionary<NodeId, int>();
|
||||
var sccs = new List<ImmutableArray<NodeId>>();
|
||||
|
||||
void StrongConnect(NodeId v)
|
||||
{
|
||||
indices[v] = index;
|
||||
lowLinks[v] = index;
|
||||
index++;
|
||||
stack.Push(v);
|
||||
onStack.Add(v);
|
||||
|
||||
if (adjacency.TryGetValue(v, out var neighbors))
|
||||
{
|
||||
foreach (var (w, _) in neighbors)
|
||||
{
|
||||
if (!indices.ContainsKey(w))
|
||||
{
|
||||
StrongConnect(w);
|
||||
lowLinks[v] = Math.Min(lowLinks[v], lowLinks[w]);
|
||||
}
|
||||
else if (onStack.Contains(w))
|
||||
{
|
||||
lowLinks[v] = Math.Min(lowLinks[v], indices[w]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lowLinks[v] == indices[v])
|
||||
{
|
||||
var scc = new List<NodeId>();
|
||||
NodeId w;
|
||||
do
|
||||
{
|
||||
w = stack.Pop();
|
||||
onStack.Remove(w);
|
||||
scc.Add(w);
|
||||
} while (!w.Equals(v));
|
||||
|
||||
if (scc.Count > 1)
|
||||
{
|
||||
sccs.Add(scc.ToImmutableArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var node in graph.Nodes)
|
||||
{
|
||||
if (!indices.ContainsKey(node.Id))
|
||||
{
|
||||
StrongConnect(node.Id);
|
||||
}
|
||||
}
|
||||
|
||||
// For each SCC, check if there's a cycle-cut edge
|
||||
var cycles = new List<CycleInfo>();
|
||||
foreach (var scc in sccs)
|
||||
{
|
||||
var sccSet = scc.ToHashSet();
|
||||
var cutEdge = graph.Edges
|
||||
.Where(e => e.IsCycleCut && sccSet.Contains(e.Src) && sccSet.Contains(e.Dst))
|
||||
.FirstOrDefault();
|
||||
|
||||
cycles.Add(new CycleInfo(scc, cutEdge));
|
||||
}
|
||||
|
||||
return cycles.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects implicit data violations in the graph.
|
||||
/// </summary>
|
||||
public sealed class DefaultImplicitDataDetector : IImplicitDataDetector
|
||||
{
|
||||
public ImmutableArray<ImplicitDataViolation> Detect(EvidenceGraph graph)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
var violations = new List<ImplicitDataViolation>();
|
||||
var nodeIds = graph.Nodes.Select(n => n.Id).ToHashSet();
|
||||
|
||||
// Check for edges referencing non-existent nodes
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
if (!nodeIds.Contains(edge.Src))
|
||||
{
|
||||
violations.Add(new ImplicitDataViolation(
|
||||
"DanglingEdgeSource",
|
||||
edge.Src,
|
||||
$"Edge {edge.Id.Value[..8]}... references non-existent source node {edge.Src.Value[..8]}..."));
|
||||
}
|
||||
if (!nodeIds.Contains(edge.Dst))
|
||||
{
|
||||
violations.Add(new ImplicitDataViolation(
|
||||
"DanglingEdgeDestination",
|
||||
edge.Dst,
|
||||
$"Edge {edge.Id.Value[..8]}... references non-existent destination node {edge.Dst.Value[..8]}..."));
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate NodeIds
|
||||
var seenNodeIds = new HashSet<NodeId>();
|
||||
foreach (var node in graph.Nodes)
|
||||
{
|
||||
if (!seenNodeIds.Add(node.Id))
|
||||
{
|
||||
violations.Add(new ImplicitDataViolation(
|
||||
"DuplicateNodeId",
|
||||
node.Id,
|
||||
$"Duplicate NodeId: {node.Id.Value[..8]}..."));
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate EdgeIds
|
||||
var seenEdgeIds = new HashSet<EdgeId>();
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
if (!seenEdgeIds.Add(edge.Id))
|
||||
{
|
||||
violations.Add(new ImplicitDataViolation(
|
||||
"DuplicateEdgeId",
|
||||
null,
|
||||
$"Duplicate EdgeId: {edge.Id.Value[..8]}..."));
|
||||
}
|
||||
}
|
||||
|
||||
return violations.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default graph validator combining all validation checks.
|
||||
/// </summary>
|
||||
public sealed class DefaultGraphValidator : IGraphValidator
|
||||
{
|
||||
private readonly ICycleDetector _cycleDetector;
|
||||
private readonly IImplicitDataDetector _implicitDataDetector;
|
||||
|
||||
public DefaultGraphValidator(
|
||||
ICycleDetector? cycleDetector = null,
|
||||
IImplicitDataDetector? implicitDataDetector = null)
|
||||
{
|
||||
_cycleDetector = cycleDetector ?? new TarjanCycleDetector();
|
||||
_implicitDataDetector = implicitDataDetector ?? new DefaultImplicitDataDetector();
|
||||
}
|
||||
|
||||
public GraphValidationResult Validate(EvidenceGraph graph)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
var errors = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
// Detect cycles
|
||||
var cycles = _cycleDetector.DetectCycles(graph);
|
||||
|
||||
// Check that all cycles have cut edges
|
||||
foreach (var cycle in cycles)
|
||||
{
|
||||
if (cycle.CutEdge is null)
|
||||
{
|
||||
var nodeIdsStr = string.Join(", ", cycle.CycleNodes.Select(n => n.Value[..8] + "..."));
|
||||
errors.Add($"Cycle detected without IsCycleCut edge: [{nodeIdsStr}]");
|
||||
}
|
||||
}
|
||||
|
||||
// Detect implicit data violations
|
||||
var implicitViolations = _implicitDataDetector.Detect(graph);
|
||||
|
||||
// All implicit data violations are errors
|
||||
foreach (var violation in implicitViolations)
|
||||
{
|
||||
errors.Add(violation.Description);
|
||||
}
|
||||
|
||||
var isValid = errors.Count == 0;
|
||||
|
||||
return new GraphValidationResult(
|
||||
isValid,
|
||||
cycles,
|
||||
errors.ToImmutableArray(),
|
||||
warnings.ToImmutableArray(),
|
||||
implicitViolations);
|
||||
}
|
||||
}
|
||||
82
src/__Libraries/StellaOps.Resolver/IDeterministicResolver.cs
Normal file
82
src/__Libraries/StellaOps.Resolver/IDeterministicResolver.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* IDeterministicResolver - Resolver Interface
|
||||
* Sprint: SPRINT_9100_0001_0001 (Core Resolver Package)
|
||||
* Task: RESOLVER-9100-009
|
||||
*
|
||||
* Single entry point for deterministic resolution:
|
||||
* resolver.Run(graph) → ResolutionResult
|
||||
*/
|
||||
|
||||
namespace StellaOps.Resolver;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic resolver interface.
|
||||
/// Guarantees: same inputs → same traversal → same verdicts → same digest.
|
||||
/// </summary>
|
||||
public interface IDeterministicResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs deterministic resolution on the evidence graph.
|
||||
/// </summary>
|
||||
/// <param name="graph">The evidence graph to resolve.</param>
|
||||
/// <returns>Complete resolution result with traversal, verdicts, and digests.</returns>
|
||||
ResolutionResult Run(EvidenceGraph graph);
|
||||
|
||||
/// <summary>
|
||||
/// Runs deterministic resolution with a specific timestamp (for testing/replay).
|
||||
/// </summary>
|
||||
/// <param name="graph">The evidence graph to resolve.</param>
|
||||
/// <param name="resolvedAt">The timestamp to use for resolution.</param>
|
||||
/// <returns>Complete resolution result with traversal, verdicts, and digests.</returns>
|
||||
ResolutionResult Run(EvidenceGraph graph, DateTimeOffset resolvedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Graph orderer for deterministic traversal.
|
||||
/// </summary>
|
||||
public interface IGraphOrderer
|
||||
{
|
||||
/// <summary>
|
||||
/// Orders nodes for deterministic traversal.
|
||||
/// </summary>
|
||||
/// <param name="graph">The evidence graph.</param>
|
||||
/// <returns>Ordered sequence of node IDs.</returns>
|
||||
IReadOnlyList<NodeId> OrderNodes(EvidenceGraph graph);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trust lattice evaluator for computing verdicts.
|
||||
/// </summary>
|
||||
public interface ITrustLatticeEvaluator
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates a node given its inbound evidence.
|
||||
/// Pure function: no IO, deterministic output.
|
||||
/// </summary>
|
||||
/// <param name="node">The node to evaluate.</param>
|
||||
/// <param name="inboundEdges">Edges pointing to this node.</param>
|
||||
/// <param name="policy">Policy rules for evaluation.</param>
|
||||
/// <param name="predecessorVerdicts">Verdicts for predecessor nodes.</param>
|
||||
/// <returns>Verdict for the node.</returns>
|
||||
Verdict Evaluate(
|
||||
Node node,
|
||||
IReadOnlyList<Edge> inboundEdges,
|
||||
Policy policy,
|
||||
IReadOnlyDictionary<NodeId, Verdict> predecessorVerdicts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical serializer for deterministic JSON output.
|
||||
/// </summary>
|
||||
public interface ICanonicalSerializer
|
||||
{
|
||||
/// <summary>
|
||||
/// Serializes an object to canonical JSON.
|
||||
/// </summary>
|
||||
string Serialize<T>(T value);
|
||||
|
||||
/// <summary>
|
||||
/// Serializes an object and returns both JSON and SHA256 digest.
|
||||
/// </summary>
|
||||
(string Json, string Digest) SerializeWithDigest<T>(T value);
|
||||
}
|
||||
56
src/__Libraries/StellaOps.Resolver/NfcStringNormalizer.cs
Normal file
56
src/__Libraries/StellaOps.Resolver/NfcStringNormalizer.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* NFC String Normalizer
|
||||
* Sprint: SPRINT_9100_0003_0002 (Graph Validation & NFC)
|
||||
* Tasks: VALID-9100-001 through VALID-9100-006
|
||||
*
|
||||
* Provides Unicode NFC normalization for deterministic string handling.
|
||||
*/
|
||||
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Resolver;
|
||||
|
||||
/// <summary>
|
||||
/// String normalizer interface.
|
||||
/// </summary>
|
||||
public interface IStringNormalizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Normalizes a string.
|
||||
/// </summary>
|
||||
string Normalize(string input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NFC (Canonical Decomposition, followed by Canonical Composition) string normalizer.
|
||||
/// Ensures consistent Unicode representation for deterministic hashing.
|
||||
/// </summary>
|
||||
public sealed class NfcStringNormalizer : IStringNormalizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton instance.
|
||||
/// </summary>
|
||||
public static NfcStringNormalizer Instance { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes the input string to NFC form.
|
||||
/// </summary>
|
||||
public string Normalize(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
return input;
|
||||
|
||||
return input.Normalize(NormalizationForm.FormC);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the input string is already in NFC form.
|
||||
/// </summary>
|
||||
public static bool IsNormalized(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
return true;
|
||||
|
||||
return input.IsNormalized(NormalizationForm.FormC);
|
||||
}
|
||||
}
|
||||
65
src/__Libraries/StellaOps.Resolver/Node.cs
Normal file
65
src/__Libraries/StellaOps.Resolver/Node.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Node - Graph Node Model
|
||||
* Sprint: SPRINT_9100_0001_0001 (Core Resolver Package)
|
||||
* Task: RESOLVER-9100-003
|
||||
*
|
||||
* Represents a node in the evidence graph.
|
||||
* Nodes have:
|
||||
* - A content-addressed NodeId
|
||||
* - A kind (type of node)
|
||||
* - Optional attributes as JSON
|
||||
*/
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Resolver;
|
||||
|
||||
/// <summary>
|
||||
/// A node in the evidence graph with content-addressed identity.
|
||||
/// </summary>
|
||||
/// <param name="Id">Content-addressed node identifier.</param>
|
||||
/// <param name="Kind">Node kind (e.g., "package", "file", "symbol", "vulnerability").</param>
|
||||
/// <param name="Key">Original key used to compute NodeId.</param>
|
||||
/// <param name="Attrs">Optional node attributes as JSON.</param>
|
||||
public sealed record Node(
|
||||
NodeId Id,
|
||||
string Kind,
|
||||
string Key,
|
||||
JsonElement? Attrs = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a node from kind and key, computing NodeId automatically.
|
||||
/// </summary>
|
||||
public static Node Create(string kind, string key, JsonElement? attrs = null)
|
||||
{
|
||||
var id = NodeId.From(kind, key);
|
||||
return new Node(id, kind, key, attrs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an attribute value by key path.
|
||||
/// </summary>
|
||||
public T? GetAttr<T>(string path)
|
||||
{
|
||||
if (Attrs is null || Attrs.Value.ValueKind == JsonValueKind.Undefined)
|
||||
return default;
|
||||
|
||||
try
|
||||
{
|
||||
var current = Attrs.Value;
|
||||
foreach (var segment in path.Split('.'))
|
||||
{
|
||||
if (current.ValueKind != JsonValueKind.Object)
|
||||
return default;
|
||||
if (!current.TryGetProperty(segment, out current))
|
||||
return default;
|
||||
}
|
||||
return current.Deserialize<T>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
||||
93
src/__Libraries/StellaOps.Resolver/NodeId.cs
Normal file
93
src/__Libraries/StellaOps.Resolver/NodeId.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* NodeId - Content-Addressed Node Identifier
|
||||
* Sprint: SPRINT_9100_0001_0001 (Core Resolver Package)
|
||||
* Task: RESOLVER-9100-002
|
||||
*
|
||||
* A content-addressed identifier for graph nodes.
|
||||
* NodeId = sha256(normalize(kind + ":" + key))
|
||||
*
|
||||
* Guarantees:
|
||||
* - Same (kind, key) → same NodeId
|
||||
* - Different (kind, key) → different NodeId (collision resistant)
|
||||
* - Deterministic ordering via ordinal string comparison
|
||||
*/
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Resolver;
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed node identifier computed as SHA256 of normalized kind:key.
|
||||
/// Immutable value type for deterministic graph operations.
|
||||
/// </summary>
|
||||
public readonly record struct NodeId : IComparable<NodeId>, IEquatable<NodeId>
|
||||
{
|
||||
private readonly string _value;
|
||||
|
||||
/// <summary>
|
||||
/// The SHA256 hex digest (lowercase, 64 characters).
|
||||
/// </summary>
|
||||
public string Value => _value ?? string.Empty;
|
||||
|
||||
private NodeId(string value) => _value = value;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a NodeId from a pre-computed digest value.
|
||||
/// Use <see cref="From(string, string)"/> for computing from kind/key.
|
||||
/// </summary>
|
||||
/// <param name="digest">A valid SHA256 hex digest (64 lowercase hex chars).</param>
|
||||
public static NodeId FromDigest(string digest)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
|
||||
if (digest.Length != 64)
|
||||
throw new ArgumentException("NodeId digest must be 64 hex characters", nameof(digest));
|
||||
|
||||
return new NodeId(digest.ToLowerInvariant());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a NodeId from kind and key.
|
||||
/// Applies NFC normalization before hashing.
|
||||
/// </summary>
|
||||
/// <param name="kind">Node kind (e.g., "package", "file", "symbol").</param>
|
||||
/// <param name="key">Node key (e.g., PURL, file path, symbol name).</param>
|
||||
/// <returns>Content-addressed NodeId.</returns>
|
||||
public static NodeId From(string kind, string key)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(kind);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
|
||||
// NFC normalize inputs for Unicode consistency
|
||||
var normalizedKind = kind.Normalize(NormalizationForm.FormC);
|
||||
var normalizedKey = key.Normalize(NormalizationForm.FormC);
|
||||
|
||||
var input = $"{normalizedKind}:{normalizedKey}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
var digest = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
|
||||
return new NodeId(digest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ordinal comparison for deterministic ordering.
|
||||
/// </summary>
|
||||
public int CompareTo(NodeId other)
|
||||
=> string.Compare(Value, other.Value, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Equality is based on digest value.
|
||||
/// </summary>
|
||||
public bool Equals(NodeId other)
|
||||
=> string.Equals(Value, other.Value, StringComparison.Ordinal);
|
||||
|
||||
public override int GetHashCode()
|
||||
=> Value.GetHashCode(StringComparison.Ordinal);
|
||||
|
||||
public override string ToString() => Value;
|
||||
|
||||
public static bool operator <(NodeId left, NodeId right) => left.CompareTo(right) < 0;
|
||||
public static bool operator >(NodeId left, NodeId right) => left.CompareTo(right) > 0;
|
||||
public static bool operator <=(NodeId left, NodeId right) => left.CompareTo(right) <= 0;
|
||||
public static bool operator >=(NodeId left, NodeId right) => left.CompareTo(right) >= 0;
|
||||
}
|
||||
54
src/__Libraries/StellaOps.Resolver/Policy.cs
Normal file
54
src/__Libraries/StellaOps.Resolver/Policy.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Policy - Policy Model for Resolver
|
||||
* Sprint: SPRINT_9100_0001_0001 (Core Resolver Package)
|
||||
* Task: RESOLVER-9100-005
|
||||
*
|
||||
* Represents the policy used for verdict evaluation.
|
||||
* Policy digest is included in FinalDigest for reproducibility.
|
||||
*/
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Resolver;
|
||||
|
||||
/// <summary>
|
||||
/// Policy configuration for deterministic resolution.
|
||||
/// </summary>
|
||||
/// <param name="Version">Policy version string.</param>
|
||||
/// <param name="Rules">Policy rules as JSON.</param>
|
||||
/// <param name="ConstantsDigest">SHA256 digest of policy constants.</param>
|
||||
public sealed record Policy(
|
||||
string Version,
|
||||
JsonElement Rules,
|
||||
string ConstantsDigest)
|
||||
{
|
||||
private string? _digest;
|
||||
|
||||
/// <summary>
|
||||
/// SHA256 digest of the policy (version + rules + constants).
|
||||
/// </summary>
|
||||
public string Digest => _digest ??= ComputeDigest();
|
||||
|
||||
private string ComputeDigest()
|
||||
{
|
||||
var input = $"{Version}:{Rules.GetRawText()}:{ConstantsDigest}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a policy from version and rules JSON.
|
||||
/// </summary>
|
||||
public static Policy Create(string version, JsonElement rules, string constantsDigest = "")
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(version);
|
||||
return new Policy(version, rules, constantsDigest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty policy for testing.
|
||||
/// </summary>
|
||||
public static Policy Empty => new("1.0.0", JsonDocument.Parse("{}").RootElement, "");
|
||||
}
|
||||
221
src/__Libraries/StellaOps.Resolver/Purity/RuntimePurity.cs
Normal file
221
src/__Libraries/StellaOps.Resolver/Purity/RuntimePurity.cs
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Runtime Purity Enforcement
|
||||
* Sprint: SPRINT_9100_0003_0001 (Runtime Purity Enforcement)
|
||||
* Tasks: PURITY-9100-001 through PURITY-9100-020
|
||||
*
|
||||
* Provides runtime guards preventing evaluation functions from accessing
|
||||
* ambient state (time, network, filesystem, environment).
|
||||
*/
|
||||
|
||||
namespace StellaOps.Resolver.Purity;
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when evaluation code attempts to access ambient state.
|
||||
/// </summary>
|
||||
public sealed class AmbientAccessViolationException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Category of ambient access attempted.
|
||||
/// </summary>
|
||||
public string Category { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of the attempted operation.
|
||||
/// </summary>
|
||||
public string AttemptedOperation { get; }
|
||||
|
||||
public AmbientAccessViolationException(string category, string attemptedOperation)
|
||||
: base($"Ambient access violation: {category} - {attemptedOperation}")
|
||||
{
|
||||
Category = category;
|
||||
AttemptedOperation = attemptedOperation;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for ambient time access.
|
||||
/// </summary>
|
||||
public interface IAmbientTimeProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current time.
|
||||
/// </summary>
|
||||
DateTimeOffset Now { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for ambient network access (marker interface for detection).
|
||||
/// </summary>
|
||||
public interface IAmbientNetworkAccessor
|
||||
{
|
||||
// Marker interface - implementations should throw on any method
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for ambient filesystem access (marker interface for detection).
|
||||
/// </summary>
|
||||
public interface IAmbientFileSystemAccessor
|
||||
{
|
||||
// Marker interface - implementations should throw on any method
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for ambient environment variable access.
|
||||
/// </summary>
|
||||
public interface IAmbientEnvironmentAccessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets an environment variable value.
|
||||
/// </summary>
|
||||
string? GetVariable(string name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Time provider that throws on any access.
|
||||
/// Use in evaluation contexts to enforce purity.
|
||||
/// </summary>
|
||||
public sealed class ProhibitedTimeProvider : IAmbientTimeProvider
|
||||
{
|
||||
public DateTimeOffset Now => throw new AmbientAccessViolationException(
|
||||
"Time",
|
||||
"Attempted to access DateTime.Now during evaluation. Use injected timestamp instead.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Network accessor that throws on any access.
|
||||
/// </summary>
|
||||
public sealed class ProhibitedNetworkAccessor : IAmbientNetworkAccessor
|
||||
{
|
||||
// Any methods added here should throw
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filesystem accessor that throws on any access.
|
||||
/// </summary>
|
||||
public sealed class ProhibitedFileSystemAccessor : IAmbientFileSystemAccessor
|
||||
{
|
||||
// Any methods added here should throw
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Environment accessor that throws on any access.
|
||||
/// </summary>
|
||||
public sealed class ProhibitedEnvironmentAccessor : IAmbientEnvironmentAccessor
|
||||
{
|
||||
public string? GetVariable(string name) => throw new AmbientAccessViolationException(
|
||||
"Environment",
|
||||
$"Attempted to access environment variable '{name}' during evaluation.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Time provider that returns a fixed, injected time.
|
||||
/// Use for deterministic evaluation.
|
||||
/// </summary>
|
||||
public sealed class InjectedTimeProvider : IAmbientTimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _injectedNow;
|
||||
|
||||
public InjectedTimeProvider(DateTimeOffset injectedNow)
|
||||
{
|
||||
_injectedNow = injectedNow;
|
||||
}
|
||||
|
||||
public DateTimeOffset Now => _injectedNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Environment accessor that returns values from a fixed dictionary.
|
||||
/// Use for deterministic evaluation.
|
||||
/// </summary>
|
||||
public sealed class InjectedEnvironmentAccessor : IAmbientEnvironmentAccessor
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, string> _variables;
|
||||
|
||||
public InjectedEnvironmentAccessor(IReadOnlyDictionary<string, string>? variables = null)
|
||||
{
|
||||
_variables = variables ?? new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
public string? GetVariable(string name)
|
||||
{
|
||||
return _variables.TryGetValue(name, out var value) ? value : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluation context with controlled ambient service access.
|
||||
/// </summary>
|
||||
public sealed class PureEvaluationContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Time provider (injected or prohibited).
|
||||
/// </summary>
|
||||
public IAmbientTimeProvider TimeProvider { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Network accessor (always prohibited in pure context).
|
||||
/// </summary>
|
||||
public IAmbientNetworkAccessor NetworkAccessor { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Filesystem accessor (always prohibited in pure context).
|
||||
/// </summary>
|
||||
public IAmbientFileSystemAccessor FileSystemAccessor { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment accessor (injected or prohibited).
|
||||
/// </summary>
|
||||
public IAmbientEnvironmentAccessor EnvironmentAccessor { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The injected timestamp for this evaluation.
|
||||
/// </summary>
|
||||
public DateTimeOffset InjectedNow => TimeProvider.Now;
|
||||
|
||||
private PureEvaluationContext(
|
||||
IAmbientTimeProvider timeProvider,
|
||||
IAmbientNetworkAccessor networkAccessor,
|
||||
IAmbientFileSystemAccessor fileSystemAccessor,
|
||||
IAmbientEnvironmentAccessor environmentAccessor)
|
||||
{
|
||||
TimeProvider = timeProvider;
|
||||
NetworkAccessor = networkAccessor;
|
||||
FileSystemAccessor = fileSystemAccessor;
|
||||
EnvironmentAccessor = environmentAccessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a strict pure context where all ambient access throws.
|
||||
/// </summary>
|
||||
public static PureEvaluationContext CreateStrict()
|
||||
{
|
||||
return new PureEvaluationContext(
|
||||
new ProhibitedTimeProvider(),
|
||||
new ProhibitedNetworkAccessor(),
|
||||
new ProhibitedFileSystemAccessor(),
|
||||
new ProhibitedEnvironmentAccessor());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a pure context with injected values.
|
||||
/// </summary>
|
||||
public static PureEvaluationContext Create(
|
||||
DateTimeOffset injectedNow,
|
||||
IReadOnlyDictionary<string, string>? environmentVariables = null)
|
||||
{
|
||||
return new PureEvaluationContext(
|
||||
new InjectedTimeProvider(injectedNow),
|
||||
new ProhibitedNetworkAccessor(),
|
||||
new ProhibitedFileSystemAccessor(),
|
||||
new InjectedEnvironmentAccessor(environmentVariables));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a purity violation is detected.
|
||||
/// </summary>
|
||||
public sealed record PurityViolationEvent(
|
||||
string Category,
|
||||
string Operation,
|
||||
string? StackTrace,
|
||||
DateTimeOffset Timestamp);
|
||||
147
src/__Libraries/StellaOps.Resolver/ResolutionResult.cs
Normal file
147
src/__Libraries/StellaOps.Resolver/ResolutionResult.cs
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* ResolutionResult - Complete Resolution Output
|
||||
* Sprint: SPRINT_9100_0001_0001 (Core Resolver Package)
|
||||
* Task: RESOLVER-9100-008
|
||||
*
|
||||
* Extended in Sprint: SPRINT_9100_0002_0001 (FinalDigest Implementation)
|
||||
* Task: DIGEST-9100-001 through DIGEST-9100-005
|
||||
*
|
||||
* Contains the complete output of a deterministic resolution run:
|
||||
* - TraversalSequence: ordered list of node IDs as traversed
|
||||
* - Verdicts: verdict for each node
|
||||
* - GraphDigest: content-addressed graph hash
|
||||
* - PolicyDigest: content-addressed policy hash
|
||||
* - FinalDigest: composite digest for complete verification
|
||||
*/
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Canonicalization.Json;
|
||||
|
||||
namespace StellaOps.Resolver;
|
||||
|
||||
/// <summary>
|
||||
/// Complete result of a deterministic resolution run.
|
||||
/// </summary>
|
||||
public sealed record ResolutionResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Ordered sequence of node IDs as traversed during resolution.
|
||||
/// </summary>
|
||||
public ImmutableArray<NodeId> TraversalSequence { get; init; } = ImmutableArray<NodeId>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Verdicts for each node, in traversal order.
|
||||
/// </summary>
|
||||
public ImmutableArray<Verdict> Verdicts { get; init; } = ImmutableArray<Verdict>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed digest of the input graph.
|
||||
/// </summary>
|
||||
public required string GraphDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressed digest of the policy used.
|
||||
/// </summary>
|
||||
public required string PolicyDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Composite digest: sha256(canonical({graphDigest, policyDigest, verdicts[]}))
|
||||
/// Single value for complete verification.
|
||||
/// </summary>
|
||||
public required string FinalDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when resolution was performed (injected, not ambient).
|
||||
/// </summary>
|
||||
public DateTimeOffset ResolvedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Resolver version used.
|
||||
/// </summary>
|
||||
public string? ResolverVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the verdict for a specific node.
|
||||
/// </summary>
|
||||
public Verdict? GetVerdict(NodeId nodeId)
|
||||
=> Verdicts.FirstOrDefault(v => v.Node == nodeId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all passing verdicts.
|
||||
/// </summary>
|
||||
public ImmutableArray<Verdict> PassingVerdicts
|
||||
=> Verdicts.Where(v => v.IsPassing).ToImmutableArray();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all failing verdicts.
|
||||
/// </summary>
|
||||
public ImmutableArray<Verdict> FailingVerdicts
|
||||
=> Verdicts.Where(v => v.IsFailing).ToImmutableArray();
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if all verdicts are passing.
|
||||
/// </summary>
|
||||
public bool AllPassing => Verdicts.All(v => v.IsPassing);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if any verdict is failing.
|
||||
/// </summary>
|
||||
public bool AnyFailing => Verdicts.Any(v => v.IsFailing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input structure for FinalDigest computation.
|
||||
/// </summary>
|
||||
public sealed record DigestInput(
|
||||
string GraphDigest,
|
||||
string PolicyDigest,
|
||||
ImmutableArray<VerdictDigestEntry> Verdicts);
|
||||
|
||||
/// <summary>
|
||||
/// Minimal verdict entry for digest computation.
|
||||
/// </summary>
|
||||
public sealed record VerdictDigestEntry(
|
||||
string NodeId,
|
||||
string VerdictDigest);
|
||||
|
||||
/// <summary>
|
||||
/// Computes FinalDigest from resolution components.
|
||||
/// </summary>
|
||||
public interface IFinalDigestComputer
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the FinalDigest from digest input.
|
||||
/// </summary>
|
||||
string Compute(DigestInput input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SHA256-based FinalDigest computer.
|
||||
/// </summary>
|
||||
public sealed class Sha256FinalDigestComputer : IFinalDigestComputer
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes FinalDigest as SHA256 of canonical JSON representation.
|
||||
/// Verdicts are sorted by NodeId before serialization.
|
||||
/// </summary>
|
||||
public string Compute(DigestInput input)
|
||||
{
|
||||
// Sort verdicts by NodeId for determinism
|
||||
var sortedVerdicts = input.Verdicts
|
||||
.OrderBy(v => v.NodeId, StringComparer.Ordinal)
|
||||
.Select(v => new { nodeId = v.NodeId, verdictDigest = v.VerdictDigest })
|
||||
.ToArray();
|
||||
|
||||
var digestData = new
|
||||
{
|
||||
graphDigest = input.GraphDigest,
|
||||
policyDigest = input.PolicyDigest,
|
||||
verdicts = sortedVerdicts
|
||||
};
|
||||
|
||||
var (_, digest) = CanonicalJsonSerializer.SerializeWithDigest(digestData);
|
||||
return digest;
|
||||
}
|
||||
}
|
||||
125
src/__Libraries/StellaOps.Resolver/ResolutionVerifier.cs
Normal file
125
src/__Libraries/StellaOps.Resolver/ResolutionVerifier.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Resolution Verification
|
||||
* Sprint: SPRINT_9100_0002_0001 (FinalDigest Implementation)
|
||||
* Tasks: DIGEST-9100-011 through DIGEST-9100-014
|
||||
*
|
||||
* Provides verification of resolution results.
|
||||
*/
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Resolver;
|
||||
|
||||
/// <summary>
|
||||
/// Result of verifying two resolution results.
|
||||
/// </summary>
|
||||
/// <param name="Match">True if FinalDigests match.</param>
|
||||
/// <param name="ExpectedDigest">Expected FinalDigest.</param>
|
||||
/// <param name="ActualDigest">Actual FinalDigest.</param>
|
||||
/// <param name="Differences">List of differences if not matching.</param>
|
||||
public sealed record VerificationResult(
|
||||
bool Match,
|
||||
string ExpectedDigest,
|
||||
string ActualDigest,
|
||||
ImmutableArray<string> Differences)
|
||||
{
|
||||
public static VerificationResult Success(string digest) => new(
|
||||
true,
|
||||
digest,
|
||||
digest,
|
||||
ImmutableArray<string>.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for verifying resolution results.
|
||||
/// </summary>
|
||||
public interface IResolutionVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that actual matches expected.
|
||||
/// </summary>
|
||||
VerificationResult Verify(ResolutionResult expected, ResolutionResult actual);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that actual matches expected digest.
|
||||
/// </summary>
|
||||
VerificationResult Verify(string expectedDigest, ResolutionResult actual);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default resolution verifier.
|
||||
/// </summary>
|
||||
public sealed class DefaultResolutionVerifier : IResolutionVerifier
|
||||
{
|
||||
private readonly IVerdictDeltaDetector _deltaDetector;
|
||||
|
||||
public DefaultResolutionVerifier(IVerdictDeltaDetector? deltaDetector = null)
|
||||
{
|
||||
_deltaDetector = deltaDetector ?? new DefaultVerdictDeltaDetector();
|
||||
}
|
||||
|
||||
public VerificationResult Verify(ResolutionResult expected, ResolutionResult actual)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expected);
|
||||
ArgumentNullException.ThrowIfNull(actual);
|
||||
|
||||
if (expected.FinalDigest == actual.FinalDigest)
|
||||
{
|
||||
return VerificationResult.Success(expected.FinalDigest);
|
||||
}
|
||||
|
||||
// Drill down to find differences
|
||||
var differences = new List<string>();
|
||||
|
||||
if (expected.GraphDigest != actual.GraphDigest)
|
||||
{
|
||||
differences.Add($"GraphDigest mismatch: expected {expected.GraphDigest[..16]}..., got {actual.GraphDigest[..16]}...");
|
||||
}
|
||||
|
||||
if (expected.PolicyDigest != actual.PolicyDigest)
|
||||
{
|
||||
differences.Add($"PolicyDigest mismatch: expected {expected.PolicyDigest[..16]}..., got {actual.PolicyDigest[..16]}...");
|
||||
}
|
||||
|
||||
// Check verdict-level differences
|
||||
var delta = _deltaDetector.Detect(expected, actual);
|
||||
if (!delta.IsEmpty)
|
||||
{
|
||||
foreach (var (old, @new) in delta.ChangedVerdicts)
|
||||
{
|
||||
differences.Add($"Verdict changed for node {old.Node.Value[..16]}...: {old.Status} -> {@new.Status}");
|
||||
}
|
||||
foreach (var added in delta.AddedVerdicts)
|
||||
{
|
||||
differences.Add($"Verdict added for node {added.Node.Value[..16]}...: {added.Status}");
|
||||
}
|
||||
foreach (var removed in delta.RemovedVerdicts)
|
||||
{
|
||||
differences.Add($"Verdict removed for node {removed.Node.Value[..16]}...: {removed.Status}");
|
||||
}
|
||||
}
|
||||
|
||||
return new VerificationResult(
|
||||
false,
|
||||
expected.FinalDigest,
|
||||
actual.FinalDigest,
|
||||
differences.ToImmutableArray());
|
||||
}
|
||||
|
||||
public VerificationResult Verify(string expectedDigest, ResolutionResult actual)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(expectedDigest);
|
||||
ArgumentNullException.ThrowIfNull(actual);
|
||||
|
||||
if (expectedDigest == actual.FinalDigest)
|
||||
{
|
||||
return VerificationResult.Success(expectedDigest);
|
||||
}
|
||||
|
||||
return new VerificationResult(
|
||||
false,
|
||||
expectedDigest,
|
||||
actual.FinalDigest,
|
||||
ImmutableArray.Create($"FinalDigest mismatch: expected {expectedDigest[..16]}..., got {actual.FinalDigest[..16]}..."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* DI Registration Extensions
|
||||
* Sprint: SPRINT_9100_0001_0001 (Core Resolver Package)
|
||||
* Task: RESOLVER-9100-018
|
||||
*
|
||||
* Provides dependency injection registration for resolver services.
|
||||
*/
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Resolver;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering resolver services with DI.
|
||||
/// </summary>
|
||||
public static class ResolverServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds resolver services to the service collection.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddResolver(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IGraphOrderer, TopologicalGraphOrderer>();
|
||||
services.AddSingleton<ITrustLatticeEvaluator, DefaultTrustLatticeEvaluator>();
|
||||
services.AddSingleton<ICanonicalSerializer, CanonicalSerializerAdapter>();
|
||||
services.AddSingleton<IFinalDigestComputer, Sha256FinalDigestComputer>();
|
||||
services.AddSingleton<IGraphValidator, DefaultGraphValidator>();
|
||||
services.AddSingleton<ICycleDetector, TarjanCycleDetector>();
|
||||
services.AddSingleton<IImplicitDataDetector, DefaultImplicitDataDetector>();
|
||||
services.AddSingleton<IVerdictDeltaDetector, DefaultVerdictDeltaDetector>();
|
||||
services.AddSingleton<IVerdictDiffReporter, DefaultVerdictDiffReporter>();
|
||||
services.AddSingleton<IEdgeDeltaDetector, DefaultEdgeDeltaDetector>();
|
||||
services.AddSingleton<IResolutionVerifier, DefaultResolutionVerifier>();
|
||||
services.AddSingleton<IStringNormalizer, NfcStringNormalizer>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a configured deterministic resolver to the service collection.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddDeterministicResolver(
|
||||
this IServiceCollection services,
|
||||
Policy policy,
|
||||
string? version = null)
|
||||
{
|
||||
services.AddResolver();
|
||||
|
||||
services.AddSingleton<IDeterministicResolver>(sp =>
|
||||
new DeterministicResolver(
|
||||
policy,
|
||||
sp.GetRequiredService<IGraphOrderer>(),
|
||||
sp.GetRequiredService<ITrustLatticeEvaluator>(),
|
||||
sp.GetRequiredService<IFinalDigestComputer>(),
|
||||
sp.GetRequiredService<IGraphValidator>(),
|
||||
version));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
21
src/__Libraries/StellaOps.Resolver/StellaOps.Resolver.csproj
Normal file
21
src/__Libraries/StellaOps.Resolver/StellaOps.Resolver.csproj
Normal file
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.Resolver</RootNamespace>
|
||||
<Description>Deterministic Resolver for StellaOps - unified resolver pattern guaranteeing same inputs produce same traversal, verdicts, and digests.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-preview.7.24407.12" />
|
||||
<PackageReference Include="System.Collections.Immutable" Version="10.0.0-preview.7.24407.12" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Canonicalization\StellaOps.Canonicalization.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Topological Graph Orderer
|
||||
* Sprint: SPRINT_9100_0001_0001 (Core Resolver Package)
|
||||
* Tasks: RESOLVER-9100-015
|
||||
*
|
||||
* Provides deterministic topological ordering of graph nodes.
|
||||
* Respects IsCycleCut edges for cycle handling.
|
||||
*/
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Resolver;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic topological graph orderer.
|
||||
/// Uses Kahn's algorithm with lexicographic tie-breaking.
|
||||
/// </summary>
|
||||
public sealed class TopologicalGraphOrderer : IGraphOrderer
|
||||
{
|
||||
/// <summary>
|
||||
/// Orders nodes in topological order with lexicographic tie-breaking.
|
||||
/// Cycle-cut edges are excluded from dependency calculation.
|
||||
/// </summary>
|
||||
public IReadOnlyList<NodeId> OrderNodes(EvidenceGraph graph)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
var nodeIds = graph.Nodes.Select(n => n.Id).ToList();
|
||||
nodeIds.Sort(); // Lexicographic baseline
|
||||
|
||||
// Build adjacency and in-degree, excluding cycle-cut edges
|
||||
var adjacency = new Dictionary<NodeId, List<NodeId>>();
|
||||
var inDegree = new Dictionary<NodeId, int>();
|
||||
|
||||
foreach (var id in nodeIds)
|
||||
{
|
||||
adjacency[id] = new List<NodeId>();
|
||||
inDegree[id] = 0;
|
||||
}
|
||||
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
// Skip cycle-cut edges for ordering (but they're still in the graph)
|
||||
if (edge.IsCycleCut)
|
||||
continue;
|
||||
|
||||
if (adjacency.ContainsKey(edge.Src) && inDegree.ContainsKey(edge.Dst))
|
||||
{
|
||||
adjacency[edge.Src].Add(edge.Dst);
|
||||
inDegree[edge.Dst]++;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort adjacency lists for determinism
|
||||
foreach (var neighbors in adjacency.Values)
|
||||
{
|
||||
neighbors.Sort();
|
||||
}
|
||||
|
||||
// Kahn's algorithm with sorted ready queue
|
||||
var ready = new SortedSet<NodeId>(
|
||||
inDegree.Where(kv => kv.Value == 0).Select(kv => kv.Key));
|
||||
|
||||
var result = new List<NodeId>(nodeIds.Count);
|
||||
|
||||
while (ready.Count > 0)
|
||||
{
|
||||
var next = ready.Min;
|
||||
ready.Remove(next);
|
||||
result.Add(next);
|
||||
|
||||
foreach (var neighbor in adjacency[next])
|
||||
{
|
||||
inDegree[neighbor]--;
|
||||
if (inDegree[neighbor] == 0)
|
||||
{
|
||||
ready.Add(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Any remaining nodes with non-zero in-degree indicate unbroken cycles
|
||||
// (should be caught by validation, but include them at the end)
|
||||
foreach (var id in nodeIds)
|
||||
{
|
||||
if (!result.Contains(id))
|
||||
{
|
||||
result.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
114
src/__Libraries/StellaOps.Resolver/Verdict.cs
Normal file
114
src/__Libraries/StellaOps.Resolver/Verdict.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Verdict - Resolution Verdict Model
|
||||
* Sprint: SPRINT_9100_0001_0001 (Core Resolver Package)
|
||||
* Task: RESOLVER-9100-007
|
||||
*
|
||||
* Extended in Sprint: SPRINT_9100_0002_0002 (Per-Node VerdictDigest)
|
||||
* Task: VDIGEST-9100-001
|
||||
*
|
||||
* Represents the verdict for a single node after evaluation.
|
||||
* Each verdict has its own content-addressed VerdictDigest for drill-down debugging.
|
||||
*/
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Canonicalization.Json;
|
||||
|
||||
namespace StellaOps.Resolver;
|
||||
|
||||
/// <summary>
|
||||
/// Status values for verdicts.
|
||||
/// </summary>
|
||||
public enum VerdictStatus
|
||||
{
|
||||
/// <summary>No determination made.</summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>Node passes policy evaluation.</summary>
|
||||
Pass = 1,
|
||||
|
||||
/// <summary>Node fails policy evaluation.</summary>
|
||||
Fail = 2,
|
||||
|
||||
/// <summary>Node blocked by policy.</summary>
|
||||
Blocked = 3,
|
||||
|
||||
/// <summary>Node produces warning but passes.</summary>
|
||||
Warn = 4,
|
||||
|
||||
/// <summary>Node ignored by policy.</summary>
|
||||
Ignored = 5,
|
||||
|
||||
/// <summary>Evaluation deferred (requires additional information).</summary>
|
||||
Deferred = 6,
|
||||
|
||||
/// <summary>Node escalated for manual review.</summary>
|
||||
Escalated = 7,
|
||||
|
||||
/// <summary>Conflicting evidence (K4 conflict state).</summary>
|
||||
Conflict = 8
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verdict for a single node in the evidence graph.
|
||||
/// </summary>
|
||||
/// <param name="Node">The node this verdict applies to.</param>
|
||||
/// <param name="Status">Verdict status.</param>
|
||||
/// <param name="Evidence">Supporting evidence for the verdict.</param>
|
||||
/// <param name="VerdictDigest">Content-addressed digest of this verdict (computed).</param>
|
||||
/// <param name="Reason">Human-readable reason for the verdict.</param>
|
||||
/// <param name="TraversalIndex">Index in the traversal sequence when this verdict was computed.</param>
|
||||
public sealed record Verdict(
|
||||
NodeId Node,
|
||||
VerdictStatus Status,
|
||||
JsonElement? Evidence,
|
||||
string VerdictDigest,
|
||||
string? Reason = null,
|
||||
int TraversalIndex = 0)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a verdict with automatically computed VerdictDigest.
|
||||
/// </summary>
|
||||
public static Verdict Create(
|
||||
NodeId node,
|
||||
VerdictStatus status,
|
||||
JsonElement? evidence = null,
|
||||
string? reason = null,
|
||||
int traversalIndex = 0)
|
||||
{
|
||||
var digest = ComputeVerdictDigest(node, status, evidence, reason, traversalIndex);
|
||||
return new Verdict(node, status, evidence, digest, reason, traversalIndex);
|
||||
}
|
||||
|
||||
private static string ComputeVerdictDigest(
|
||||
NodeId node,
|
||||
VerdictStatus status,
|
||||
JsonElement? evidence,
|
||||
string? reason,
|
||||
int traversalIndex)
|
||||
{
|
||||
// VerdictDigest excludes itself from computation (no recursion)
|
||||
var verdictData = new
|
||||
{
|
||||
node = node.Value,
|
||||
status = status.ToString(),
|
||||
evidence = evidence?.GetRawText() ?? "null",
|
||||
reason,
|
||||
traversalIndex
|
||||
};
|
||||
|
||||
var (_, digest) = CanonicalJsonSerializer.SerializeWithDigest(verdictData);
|
||||
return digest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this verdict indicates a passing status.
|
||||
/// </summary>
|
||||
public bool IsPassing => Status is VerdictStatus.Pass or VerdictStatus.Ignored or VerdictStatus.Warn;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this verdict indicates a failing status.
|
||||
/// </summary>
|
||||
public bool IsFailing => Status is VerdictStatus.Fail or VerdictStatus.Blocked;
|
||||
}
|
||||
171
src/__Libraries/StellaOps.Resolver/VerdictDelta.cs
Normal file
171
src/__Libraries/StellaOps.Resolver/VerdictDelta.cs
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Verdict Delta Detection
|
||||
* Sprint: SPRINT_9100_0002_0002 (Per-Node VerdictDigest)
|
||||
* Tasks: VDIGEST-9100-006 through VDIGEST-9100-015
|
||||
*
|
||||
* Provides delta detection between resolution results.
|
||||
*/
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Resolver;
|
||||
|
||||
/// <summary>
|
||||
/// Delta between two resolution results at the verdict level.
|
||||
/// </summary>
|
||||
/// <param name="ChangedVerdicts">Verdicts where the digest changed (same node, different verdict).</param>
|
||||
/// <param name="AddedVerdicts">Verdicts for nodes that are only in the new result.</param>
|
||||
/// <param name="RemovedVerdicts">Verdicts for nodes that are only in the old result.</param>
|
||||
public sealed record VerdictDelta(
|
||||
ImmutableArray<(Verdict Old, Verdict New)> ChangedVerdicts,
|
||||
ImmutableArray<Verdict> AddedVerdicts,
|
||||
ImmutableArray<Verdict> RemovedVerdicts)
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if there are no differences.
|
||||
/// </summary>
|
||||
public bool IsEmpty => ChangedVerdicts.IsEmpty && AddedVerdicts.IsEmpty && RemovedVerdicts.IsEmpty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for detecting verdict deltas.
|
||||
/// </summary>
|
||||
public interface IVerdictDeltaDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Detects differences between two resolution results.
|
||||
/// </summary>
|
||||
VerdictDelta Detect(ResolutionResult old, ResolutionResult @new);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default verdict delta detector.
|
||||
/// </summary>
|
||||
public sealed class DefaultVerdictDeltaDetector : IVerdictDeltaDetector
|
||||
{
|
||||
public VerdictDelta Detect(ResolutionResult old, ResolutionResult @new)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(old);
|
||||
ArgumentNullException.ThrowIfNull(@new);
|
||||
|
||||
var oldVerdicts = old.Verdicts.ToDictionary(v => v.Node);
|
||||
var newVerdicts = @new.Verdicts.ToDictionary(v => v.Node);
|
||||
|
||||
var changed = new List<(Verdict Old, Verdict New)>();
|
||||
var added = new List<Verdict>();
|
||||
var removed = new List<Verdict>();
|
||||
|
||||
// Find changed and removed
|
||||
foreach (var (nodeId, oldVerdict) in oldVerdicts)
|
||||
{
|
||||
if (newVerdicts.TryGetValue(nodeId, out var newVerdict))
|
||||
{
|
||||
if (oldVerdict.VerdictDigest != newVerdict.VerdictDigest)
|
||||
{
|
||||
changed.Add((oldVerdict, newVerdict));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
removed.Add(oldVerdict);
|
||||
}
|
||||
}
|
||||
|
||||
// Find added
|
||||
foreach (var (nodeId, newVerdict) in newVerdicts)
|
||||
{
|
||||
if (!oldVerdicts.ContainsKey(nodeId))
|
||||
{
|
||||
added.Add(newVerdict);
|
||||
}
|
||||
}
|
||||
|
||||
return new VerdictDelta(
|
||||
changed.ToImmutableArray(),
|
||||
added.ToImmutableArray(),
|
||||
removed.ToImmutableArray());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable diff report for verdict changes.
|
||||
/// </summary>
|
||||
public sealed record VerdictDiffReport(
|
||||
ImmutableArray<VerdictDiffEntry> Entries);
|
||||
|
||||
/// <summary>
|
||||
/// Single entry in a verdict diff report.
|
||||
/// </summary>
|
||||
/// <param name="NodeId">The node that changed.</param>
|
||||
/// <param name="ChangeType">Type of change (Changed, Added, Removed).</param>
|
||||
/// <param name="OldStatus">Old verdict status (if applicable).</param>
|
||||
/// <param name="NewStatus">New verdict status (if applicable).</param>
|
||||
/// <param name="OldDigest">Old verdict digest.</param>
|
||||
/// <param name="NewDigest">New verdict digest.</param>
|
||||
public sealed record VerdictDiffEntry(
|
||||
string NodeId,
|
||||
string ChangeType,
|
||||
string? OldStatus,
|
||||
string? NewStatus,
|
||||
string? OldDigest,
|
||||
string? NewDigest);
|
||||
|
||||
/// <summary>
|
||||
/// Interface for generating verdict diff reports.
|
||||
/// </summary>
|
||||
public interface IVerdictDiffReporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a diff report from a verdict delta.
|
||||
/// </summary>
|
||||
VerdictDiffReport GenerateReport(VerdictDelta delta);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default verdict diff reporter.
|
||||
/// </summary>
|
||||
public sealed class DefaultVerdictDiffReporter : IVerdictDiffReporter
|
||||
{
|
||||
public VerdictDiffReport GenerateReport(VerdictDelta delta)
|
||||
{
|
||||
var entries = new List<VerdictDiffEntry>();
|
||||
|
||||
foreach (var (old, @new) in delta.ChangedVerdicts)
|
||||
{
|
||||
entries.Add(new VerdictDiffEntry(
|
||||
old.Node.Value,
|
||||
"Changed",
|
||||
old.Status.ToString(),
|
||||
@new.Status.ToString(),
|
||||
old.VerdictDigest,
|
||||
@new.VerdictDigest));
|
||||
}
|
||||
|
||||
foreach (var added in delta.AddedVerdicts)
|
||||
{
|
||||
entries.Add(new VerdictDiffEntry(
|
||||
added.Node.Value,
|
||||
"Added",
|
||||
null,
|
||||
added.Status.ToString(),
|
||||
null,
|
||||
added.VerdictDigest));
|
||||
}
|
||||
|
||||
foreach (var removed in delta.RemovedVerdicts)
|
||||
{
|
||||
entries.Add(new VerdictDiffEntry(
|
||||
removed.Node.Value,
|
||||
"Removed",
|
||||
removed.Status.ToString(),
|
||||
null,
|
||||
removed.VerdictDigest,
|
||||
null));
|
||||
}
|
||||
|
||||
// Sort by NodeId for determinism
|
||||
entries.Sort((a, b) => string.Compare(a.NodeId, b.NodeId, StringComparison.Ordinal));
|
||||
|
||||
return new VerdictDiffReport(entries.ToImmutableArray());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user