consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

View File

@@ -0,0 +1,16 @@
# Archived Libraries
Libraries in this directory are **dormant** — they had zero production consumers at the time of archival.
## Archive Policy
- Libraries are archived (moved here), not deleted, to preserve code history and enable easy reactivation.
- Each library retains its tests for verification if restored.
- To reactivate: move back to `src/__Libraries/`, re-add to the relevant `.sln`, and verify builds.
## Archived Libraries
| Library | Archived Date | Reason |
|---------|---------------|--------|
| `StellaOps.AdvisoryLens` | 2026-03-04 | Zero consumers; not in main solution. Research/PoC code. |
| `StellaOps.Resolver` | 2026-03-04 | Zero consumers; research/PoC code with extensive SOLID review docs. |

View File

@@ -0,0 +1,113 @@
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Time.Testing;
using StellaOps.AdvisoryLens.DependencyInjection;
using StellaOps.AdvisoryLens.Models;
using StellaOps.AdvisoryLens.Services;
using Xunit;
namespace StellaOps.AdvisoryLens.Tests;
public sealed class AdvisoryLensIntegrationTests
{
[Fact]
public void DI_Registration_Resolves_Service()
{
var services = new ServiceCollection();
services.AddAdvisoryLens();
using var provider = services.BuildServiceProvider();
var service = provider.GetService<IAdvisoryLensService>();
service.Should().NotBeNull();
}
[Fact]
public void Tenant_Scoping_Flows_Through_Context()
{
var service = new AdvisoryLensService(CreatePatterns(), new FakeTimeProvider(new DateTimeOffset(2026, 2, 8, 0, 0, 0, TimeSpan.Zero)));
var tenantA = service.Evaluate(CreateContext("tenant-a"));
var tenantB = service.Evaluate(CreateContext("tenant-b"));
tenantA.Should().NotBeNull();
tenantA.InputHash.Should().StartWith("sha256:");
tenantA.InputHash.Should().NotBe(tenantB.InputHash);
}
[Fact]
public void Error_Mapping_For_Invalid_Input()
{
var service = new AdvisoryLensService(Array.Empty<CasePattern>());
Action evaluate = () => service.Evaluate(null!);
evaluate.Should().Throw<ArgumentNullException>();
}
[Fact]
public void Offline_Execution_No_Network()
{
var service = new AdvisoryLensService(CreatePatterns());
var result = service.Evaluate(CreateContext("tenant-offline"));
result.Should().NotBeNull();
result.MatchedPatterns.Should().ContainSingle().Which.Should().Be("custom-pattern");
}
[Fact]
public void DI_Registration_With_Custom_Patterns()
{
var services = new ServiceCollection();
services.AddAdvisoryLens(
CreatePatterns(),
new FakeTimeProvider(new DateTimeOffset(2026, 2, 8, 1, 0, 0, TimeSpan.Zero)));
using var provider = services.BuildServiceProvider();
var service = provider.GetRequiredService<IAdvisoryLensService>();
var result = service.Evaluate(CreateContext("tenant-custom"));
result.MatchedPatterns.Should().ContainSingle().Which.Should().Be("custom-pattern");
result.Suggestions.Should().ContainSingle();
result.Suggestions[0].Title.Should().Be("Custom escalation");
result.EvaluatedAtUtc.Should().Be(new DateTime(2026, 2, 8, 1, 0, 0, DateTimeKind.Utc));
}
private static LensContext CreateContext(string tenantId)
{
return new LensContext
{
AdvisoryCase = new AdvisoryCase
{
AdvisoryId = "ADV-INT-01",
Cve = "CVE-2026-7777",
Purl = "pkg:nuget/integration.demo@1.2.3",
Severity = AdvisorySeverity.High,
Source = "NVD"
},
TenantId = tenantId,
VexStatements = ImmutableArray.Create("vex-int-1"),
ReachabilityData = ImmutableArray.Create("reach-int-1"),
PolicyTraces = ImmutableArray.Create("policy-int-1")
};
}
private static IReadOnlyList<CasePattern> CreatePatterns()
{
return
[
new CasePattern
{
PatternId = "custom-pattern",
SeverityRange = new SeverityRange { Min = AdvisorySeverity.Medium, Max = AdvisorySeverity.Critical },
EcosystemMatch = "nuget",
CvePattern = "CVE-2026",
DefaultAction = SuggestionAction.Escalate,
SuggestionTitle = "Custom escalation",
SuggestionRationale = "Integration-registered pattern should be selected"
}
];
}
}

View File

@@ -0,0 +1,116 @@
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using StellaOps.AdvisoryLens.Models;
using Xunit;
namespace StellaOps.AdvisoryLens.Tests;
public sealed class AdvisoryLensModelTests
{
[Fact]
public void Models_SerializeRoundTrip_AllTypes_ShouldRemainEquivalent()
{
var advisoryCase = new AdvisoryCase
{
AdvisoryId = "ADV-001",
Cve = "CVE-2026-1234",
Purl = "pkg:nuget/test.pkg@1.2.3",
Severity = AdvisorySeverity.High,
Source = "NVD",
Title = "Sample advisory",
Description = "Sample description",
Metadata = ImmutableDictionary<string, string>.Empty.Add("region", "us")
};
var suggestion = new LensSuggestion
{
Rank = 1,
Title = "Patch now",
Rationale = "Exploitability is high",
Confidence = 0.95,
Action = SuggestionAction.Mitigate,
PatternId = "pat-critical"
};
var hint = new LensHint
{
Text = "Reachability data available",
Category = HintCategory.Reachability,
EvidenceRefs = ImmutableArray.Create("reach-1")
};
var pattern = new CasePattern
{
PatternId = "pat-critical",
Description = "Critical nuget pattern",
SeverityRange = new SeverityRange { Min = AdvisorySeverity.High, Max = AdvisorySeverity.Critical },
EcosystemMatch = "nuget",
CvePattern = "CVE-2026",
RequiredVexStatus = ImmutableArray.Create("affected"),
DefaultAction = SuggestionAction.Mitigate,
SuggestionTitle = "Patch now",
SuggestionRationale = "Critical package issue"
};
var context = new LensContext
{
AdvisoryCase = advisoryCase,
TenantId = "tenant-a",
VexStatements = ImmutableArray.Create("vex-1"),
PolicyTraces = ImmutableArray.Create("policy-1"),
ReachabilityData = ImmutableArray.Create("reach-1"),
EvaluationTimestampUtc = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)
};
var result = new LensResult
{
Suggestions = ImmutableArray.Create(suggestion),
Hints = ImmutableArray.Create(hint),
MatchedPatterns = ImmutableArray.Create(pattern.PatternId),
EvaluatedAtUtc = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
InputHash = "sha256:abc"
};
RoundTrip(advisoryCase).Should().BeEquivalentTo(advisoryCase);
RoundTrip(suggestion).Should().BeEquivalentTo(suggestion);
RoundTrip(hint).Should().BeEquivalentTo(hint);
RoundTrip(pattern).Should().BeEquivalentTo(pattern);
RoundTrip(pattern.SeverityRange!).Should().BeEquivalentTo(pattern.SeverityRange);
RoundTrip(context).Should().BeEquivalentTo(context);
RoundTrip(result).Should().BeEquivalentTo(result);
}
[Fact]
public void Models_SerializeTwice_SameInput_ShouldProduceSameJson()
{
var payload = new LensContext
{
AdvisoryCase = new AdvisoryCase
{
AdvisoryId = "ADV-002",
Cve = "CVE-2026-2222",
Purl = "pkg:nuget/pkg@2.0.0",
Severity = AdvisorySeverity.Medium,
Source = "OSV",
Metadata = ImmutableDictionary<string, string>.Empty.Add("k", "v")
},
TenantId = "tenant-deterministic",
VexStatements = ImmutableArray.Create("vex-a"),
PolicyTraces = ImmutableArray.Create("policy-a"),
ReachabilityData = ImmutableArray.Create("reach-a"),
EvaluationTimestampUtc = new DateTime(2026, 2, 2, 0, 0, 0, DateTimeKind.Utc)
};
var first = JsonSerializer.Serialize(payload);
var second = JsonSerializer.Serialize(payload);
second.Should().Be(first);
}
private static T RoundTrip<T>(T instance)
{
var json = JsonSerializer.Serialize(instance);
return JsonSerializer.Deserialize<T>(json)!;
}
}

View File

@@ -0,0 +1,134 @@
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Time.Testing;
using StellaOps.AdvisoryLens.DependencyInjection;
using StellaOps.AdvisoryLens.Models;
using StellaOps.AdvisoryLens.Services;
using Xunit;
namespace StellaOps.AdvisoryLens.Tests;
public sealed class AdvisoryLensServiceTests
{
[Fact]
public void Evaluate_FullFlow_ReturnsExpectedResult()
{
var context = CreateContext();
var service = new AdvisoryLensService(CreatePatterns(), new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)));
var result = service.Evaluate(context);
result.Suggestions.Should().NotBeEmpty();
result.Hints.Should().HaveCount(4);
result.MatchedPatterns.Should().ContainSingle().Which.Should().Be("pat-core");
result.EvaluatedAtUtc.Should().Be(context.EvaluationTimestampUtc);
result.InputHash.Should().StartWith("sha256:");
}
[Fact]
public void Evaluate_SameFrozenInput_IsDeterministic()
{
var context = CreateContext();
var service = new AdvisoryLensService(CreatePatterns(), new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)));
var first = service.Evaluate(context);
var second = service.Evaluate(context);
JsonSerializer.Serialize(second).Should().Be(JsonSerializer.Serialize(first));
}
[Fact]
public void Evaluate_HintsGeneration_ContainsSeverityVexReachabilityPolicy()
{
var context = CreateContext();
var service = new AdvisoryLensService(Array.Empty<CasePattern>(), new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)));
var result = service.Evaluate(context);
result.Hints.Select(h => h.Category).Should().Equal(
HintCategory.Severity,
HintCategory.Reachability,
HintCategory.Vex,
HintCategory.Policy);
}
[Fact]
public void Evaluate_EmptyPatterns_ReturnsEmptySuggestionsWithValidResult()
{
var context = CreateContext();
var service = new AdvisoryLensService(Array.Empty<CasePattern>(), new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)));
var result = service.Evaluate(context);
result.Suggestions.Should().BeEmpty();
result.MatchedPatterns.Should().BeEmpty();
result.InputHash.Should().StartWith("sha256:");
}
[Fact]
public void Evaluate_InputHashStability_SameContextProducesSameHash()
{
var context = CreateContext();
var service = new AdvisoryLensService(Array.Empty<CasePattern>(), new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)));
var first = service.Evaluate(context);
var second = service.Evaluate(context);
second.InputHash.Should().Be(first.InputHash);
}
[Fact]
public void AddAdvisoryLens_RegistersResolvableService()
{
var services = new ServiceCollection();
services.AddAdvisoryLens(CreatePatterns(), new FakeTimeProvider(new DateTimeOffset(2026, 3, 1, 0, 0, 0, TimeSpan.Zero)));
using var provider = services.BuildServiceProvider();
var service = provider.GetService<IAdvisoryLensService>();
service.Should().NotBeNull();
var result = service!.Evaluate(CreateContext(withEvaluationTimestamp: false));
result.EvaluatedAtUtc.Should().Be(new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc));
}
private static LensContext CreateContext(bool withEvaluationTimestamp = true)
{
return new LensContext
{
AdvisoryCase = new AdvisoryCase
{
AdvisoryId = "ADV-900",
Cve = "CVE-2026-9000",
Purl = "pkg:nuget/test.lib@9.0.0",
Severity = AdvisorySeverity.Critical,
Source = "NVD"
},
TenantId = "tenant-1",
VexStatements = ImmutableArray.Create("vex-1"),
PolicyTraces = ImmutableArray.Create("policy-1"),
ReachabilityData = ImmutableArray.Create("reach-1"),
EvaluationTimestampUtc = withEvaluationTimestamp
? new DateTime(2026, 1, 5, 12, 0, 0, DateTimeKind.Utc)
: null
};
}
private static IReadOnlyList<CasePattern> CreatePatterns()
{
return new[]
{
new CasePattern
{
PatternId = "pat-core",
SeverityRange = new SeverityRange { Min = AdvisorySeverity.High, Max = AdvisorySeverity.Critical },
EcosystemMatch = "nuget",
CvePattern = "CVE-2026",
DefaultAction = SuggestionAction.Escalate,
SuggestionTitle = "Escalate review",
SuggestionRationale = "Critical advisory in primary ecosystem"
}
};
}
}

View File

@@ -0,0 +1,152 @@
using FluentAssertions;
using StellaOps.AdvisoryLens.Matching;
using StellaOps.AdvisoryLens.Models;
using Xunit;
namespace StellaOps.AdvisoryLens.Tests;
public sealed class CaseMatcherTests
{
private static AdvisoryCase CreateCase(AdvisorySeverity severity = AdvisorySeverity.High)
=> new()
{
AdvisoryId = "ADV-101",
Cve = "CVE-2026-1001",
Purl = "pkg:nuget/demo.package@1.0.0",
Severity = severity
};
[Fact]
public void Match_HappyPath_ReturnsPositiveScore()
{
var matcher = new CaseMatcher();
var patterns = new[]
{
new CasePattern
{
PatternId = "pat-1",
SeverityRange = new SeverityRange { Min = AdvisorySeverity.Medium, Max = AdvisorySeverity.Critical },
EcosystemMatch = "nuget",
DefaultAction = SuggestionAction.Mitigate,
SuggestionTitle = "Mitigate",
SuggestionRationale = "Matching pattern"
}
};
var results = matcher.Match(CreateCase(), patterns);
results.Should().HaveCount(1);
results[0].PatternId.Should().Be("pat-1");
results[0].Score.Should().BeGreaterThan(0.0);
}
[Fact]
public void Match_SeverityOutsideRange_ReturnsEmpty()
{
var matcher = new CaseMatcher();
var patterns = new[]
{
new CasePattern
{
PatternId = "pat-2",
SeverityRange = new SeverityRange { Min = AdvisorySeverity.Critical, Max = AdvisorySeverity.Critical },
DefaultAction = SuggestionAction.Escalate,
SuggestionTitle = "Escalate",
SuggestionRationale = "Severity mismatch"
}
};
var results = matcher.Match(CreateCase(AdvisorySeverity.Low), patterns);
results.Should().BeEmpty();
}
[Fact]
public void Match_MultiplePatterns_OrdersByScoreThenPatternId()
{
var matcher = new CaseMatcher();
var patterns = new[]
{
new CasePattern
{
PatternId = "b-pattern",
SeverityRange = new SeverityRange { Min = AdvisorySeverity.Medium, Max = AdvisorySeverity.Critical },
DefaultAction = SuggestionAction.Mitigate,
SuggestionTitle = "B",
SuggestionRationale = "B"
},
new CasePattern
{
PatternId = "a-pattern",
SeverityRange = new SeverityRange { Min = AdvisorySeverity.Medium, Max = AdvisorySeverity.Critical },
DefaultAction = SuggestionAction.Mitigate,
SuggestionTitle = "A",
SuggestionRationale = "A"
},
new CasePattern
{
PatternId = "c-pattern",
DefaultAction = SuggestionAction.Defer,
SuggestionTitle = "C",
SuggestionRationale = "C"
}
};
var results = matcher.Match(CreateCase(), patterns);
results.Select(r => r.PatternId).Should().Equal("a-pattern", "b-pattern", "c-pattern");
results[0].Score.Should().Be(1.0);
results[2].Score.Should().Be(0.5);
}
[Fact]
public void Match_SameInputRepeated_IsDeterministic()
{
var matcher = new CaseMatcher();
var patterns = new[]
{
new CasePattern
{
PatternId = "pat-det-1",
SeverityRange = new SeverityRange { Min = AdvisorySeverity.Low, Max = AdvisorySeverity.Critical },
DefaultAction = SuggestionAction.Accept,
SuggestionTitle = "Det",
SuggestionRationale = "Det"
},
new CasePattern
{
PatternId = "pat-det-2",
DefaultAction = SuggestionAction.Defer,
SuggestionTitle = "Det2",
SuggestionRationale = "Det2"
}
};
var first = matcher.Match(CreateCase(), patterns);
var second = matcher.Match(CreateCase(), patterns);
second.Should().Equal(first);
}
[Fact]
public void Match_EmptyPatterns_ReturnsEmpty()
{
var matcher = new CaseMatcher();
var results = matcher.Match(CreateCase(), Array.Empty<CasePattern>());
results.Should().BeEmpty();
}
[Fact]
public void Match_NullArguments_ThrowArgumentNullException()
{
var matcher = new CaseMatcher();
Action nullCase = () => matcher.Match(null!, Array.Empty<CasePattern>());
Action nullPatterns = () => matcher.Match(CreateCase(), null!);
nullCase.Should().Throw<ArgumentNullException>();
nullPatterns.Should().Throw<ArgumentNullException>();
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.AdvisoryLens\StellaOps.AdvisoryLens.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeTestCollections": false,
"maxParallelThreads": 1
}

View File

@@ -0,0 +1,19 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.AdvisoryLens.Models;
using StellaOps.AdvisoryLens.Services;
namespace StellaOps.AdvisoryLens.DependencyInjection;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddAdvisoryLens(
this IServiceCollection services,
IReadOnlyList<CasePattern>? patterns = null,
TimeProvider? timeProvider = null)
{
var resolvedPatterns = patterns ?? Array.Empty<CasePattern>();
services.AddSingleton<IAdvisoryLensService>(
new AdvisoryLensService(resolvedPatterns, timeProvider));
return services;
}
}

View File

@@ -0,0 +1,92 @@
using System.Collections.Immutable;
using StellaOps.AdvisoryLens.Models;
namespace StellaOps.AdvisoryLens.Matching;
public sealed class CaseMatcher
{
public ImmutableArray<CaseMatchResult> Match(AdvisoryCase advisoryCase, IReadOnlyList<CasePattern> patterns)
{
ArgumentNullException.ThrowIfNull(advisoryCase);
ArgumentNullException.ThrowIfNull(patterns);
var results = new List<CaseMatchResult>();
foreach (var pattern in patterns)
{
var score = ComputeScore(advisoryCase, pattern);
if (score > 0.0)
{
results.Add(new CaseMatchResult
{
PatternId = pattern.PatternId,
Score = score,
Pattern = pattern
});
}
}
return results
.OrderByDescending(r => r.Score)
.ThenBy(r => r.PatternId, StringComparer.Ordinal)
.ToImmutableArray();
}
private static double ComputeScore(AdvisoryCase advisoryCase, CasePattern pattern)
{
var factors = new List<double>();
if (pattern.SeverityRange is not null)
{
if (advisoryCase.Severity >= pattern.SeverityRange.Min &&
advisoryCase.Severity <= pattern.SeverityRange.Max)
{
factors.Add(1.0);
}
else
{
return 0.0;
}
}
if (!string.IsNullOrEmpty(pattern.EcosystemMatch))
{
if (!string.IsNullOrEmpty(advisoryCase.Purl) &&
advisoryCase.Purl.StartsWith($"pkg:{pattern.EcosystemMatch}/", StringComparison.OrdinalIgnoreCase))
{
factors.Add(1.0);
}
else if (!string.IsNullOrEmpty(advisoryCase.Purl))
{
return 0.0;
}
}
if (!string.IsNullOrEmpty(pattern.CvePattern))
{
if (!string.IsNullOrEmpty(advisoryCase.Cve) &&
advisoryCase.Cve.Contains(pattern.CvePattern, StringComparison.OrdinalIgnoreCase))
{
factors.Add(1.0);
}
else if (!string.IsNullOrEmpty(advisoryCase.Cve))
{
factors.Add(0.0);
}
}
if (factors.Count == 0)
{
return 0.5;
}
return factors.Average();
}
}
public sealed record CaseMatchResult
{
public required string PatternId { get; init; }
public required double Score { get; init; }
public required CasePattern Pattern { get; init; }
}

View File

@@ -0,0 +1,42 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryLens.Models;
public sealed record AdvisoryCase
{
[JsonPropertyName("advisoryId")]
public required string AdvisoryId { get; init; }
[JsonPropertyName("cve")]
public string? Cve { get; init; }
[JsonPropertyName("purl")]
public string? Purl { get; init; }
[JsonPropertyName("severity")]
public required AdvisorySeverity Severity { get; init; }
[JsonPropertyName("source")]
public string? Source { get; init; }
[JsonPropertyName("title")]
public string? Title { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("metadata")]
public ImmutableDictionary<string, string> Metadata { get; init; } = ImmutableDictionary<string, string>.Empty;
}
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum AdvisorySeverity
{
Unknown = 0,
None = 1,
Low = 2,
Medium = 3,
High = 4,
Critical = 5
}

View File

@@ -0,0 +1,43 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryLens.Models;
public sealed record CasePattern
{
[JsonPropertyName("patternId")]
public required string PatternId { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("severityRange")]
public SeverityRange? SeverityRange { get; init; }
[JsonPropertyName("ecosystemMatch")]
public string? EcosystemMatch { get; init; }
[JsonPropertyName("cvePattern")]
public string? CvePattern { get; init; }
[JsonPropertyName("requiredVexStatus")]
public ImmutableArray<string> RequiredVexStatus { get; init; } = ImmutableArray<string>.Empty;
[JsonPropertyName("defaultAction")]
public required SuggestionAction DefaultAction { get; init; }
[JsonPropertyName("suggestionTitle")]
public required string SuggestionTitle { get; init; }
[JsonPropertyName("suggestionRationale")]
public required string SuggestionRationale { get; init; }
}
public sealed record SeverityRange
{
[JsonPropertyName("min")]
public required AdvisorySeverity Min { get; init; }
[JsonPropertyName("max")]
public required AdvisorySeverity Max { get; init; }
}

View File

@@ -0,0 +1,25 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryLens.Models;
public sealed record LensContext
{
[JsonPropertyName("advisoryCase")]
public required AdvisoryCase AdvisoryCase { get; init; }
[JsonPropertyName("tenantId")]
public required string TenantId { get; init; }
[JsonPropertyName("vexStatements")]
public ImmutableArray<string> VexStatements { get; init; } = ImmutableArray<string>.Empty;
[JsonPropertyName("policyTraces")]
public ImmutableArray<string> PolicyTraces { get; init; } = ImmutableArray<string>.Empty;
[JsonPropertyName("reachabilityData")]
public ImmutableArray<string> ReachabilityData { get; init; } = ImmutableArray<string>.Empty;
[JsonPropertyName("evaluationTimestampUtc")]
public DateTime? EvaluationTimestampUtc { get; init; }
}

View File

@@ -0,0 +1,25 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryLens.Models;
public sealed record LensHint
{
[JsonPropertyName("text")]
public required string Text { get; init; }
[JsonPropertyName("category")]
public required HintCategory Category { get; init; }
[JsonPropertyName("evidenceRefs")]
public ImmutableArray<string> EvidenceRefs { get; init; } = ImmutableArray<string>.Empty;
}
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum HintCategory
{
Severity = 0,
Reachability = 1,
Vex = 2,
Policy = 3
}

View File

@@ -0,0 +1,22 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryLens.Models;
public sealed record LensResult
{
[JsonPropertyName("suggestions")]
public required ImmutableArray<LensSuggestion> Suggestions { get; init; }
[JsonPropertyName("hints")]
public required ImmutableArray<LensHint> Hints { get; init; }
[JsonPropertyName("matchedPatterns")]
public required ImmutableArray<string> MatchedPatterns { get; init; }
[JsonPropertyName("evaluatedAtUtc")]
public required DateTime EvaluatedAtUtc { get; init; }
[JsonPropertyName("inputHash")]
public required string InputHash { get; init; }
}

View File

@@ -0,0 +1,33 @@
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryLens.Models;
public sealed record LensSuggestion
{
[JsonPropertyName("rank")]
public required int Rank { get; init; }
[JsonPropertyName("title")]
public required string Title { get; init; }
[JsonPropertyName("rationale")]
public required string Rationale { get; init; }
[JsonPropertyName("confidence")]
public required double Confidence { get; init; }
[JsonPropertyName("action")]
public required SuggestionAction Action { get; init; }
[JsonPropertyName("patternId")]
public string? PatternId { get; init; }
}
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum SuggestionAction
{
Accept = 0,
Mitigate = 1,
Defer = 2,
Escalate = 3
}

View File

@@ -0,0 +1,122 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.AdvisoryLens.Matching;
using StellaOps.AdvisoryLens.Models;
namespace StellaOps.AdvisoryLens.Services;
public sealed class AdvisoryLensService : IAdvisoryLensService
{
private static readonly JsonSerializerOptions s_hashJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
private readonly IReadOnlyList<CasePattern> _patterns;
private readonly CaseMatcher _matcher;
private readonly TimeProvider _timeProvider;
public AdvisoryLensService(IReadOnlyList<CasePattern> patterns, TimeProvider? timeProvider = null)
{
_patterns = patterns ?? throw new ArgumentNullException(nameof(patterns));
_matcher = new CaseMatcher();
_timeProvider = timeProvider ?? TimeProvider.System;
}
public LensResult Evaluate(LensContext context)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(context.AdvisoryCase);
var matches = _matcher.Match(context.AdvisoryCase, _patterns);
var suggestions = BuildSuggestions(matches);
var hints = BuildHints(context);
var inputHash = ComputeInputHash(context);
var timestamp = context.EvaluationTimestampUtc ?? _timeProvider.GetUtcNow().UtcDateTime;
return new LensResult
{
Suggestions = suggestions,
Hints = hints,
MatchedPatterns = matches.Select(m => m.PatternId).ToImmutableArray(),
EvaluatedAtUtc = timestamp,
InputHash = inputHash
};
}
private static ImmutableArray<LensSuggestion> BuildSuggestions(ImmutableArray<CaseMatchResult> matches)
{
return matches
.Select((m, idx) => new LensSuggestion
{
Rank = idx + 1,
Title = m.Pattern.SuggestionTitle,
Rationale = m.Pattern.SuggestionRationale,
Confidence = m.Score,
Action = m.Pattern.DefaultAction,
PatternId = m.PatternId
})
.ToImmutableArray();
}
private static ImmutableArray<LensHint> BuildHints(LensContext context)
{
var hints = new List<LensHint>();
if (context.AdvisoryCase.Severity >= AdvisorySeverity.High)
{
hints.Add(new LensHint
{
Text = $"Advisory severity is {context.AdvisoryCase.Severity}. Prioritize remediation.",
Category = HintCategory.Severity,
EvidenceRefs = ImmutableArray<string>.Empty
});
}
if (!context.VexStatements.IsDefaultOrEmpty && context.VexStatements.Length > 0)
{
hints.Add(new LensHint
{
Text = $"{context.VexStatements.Length} VEX statement(s) available for this advisory.",
Category = HintCategory.Vex,
EvidenceRefs = context.VexStatements
});
}
if (!context.ReachabilityData.IsDefaultOrEmpty && context.ReachabilityData.Length > 0)
{
hints.Add(new LensHint
{
Text = "Reachability data available. Check if vulnerable code paths are exercised.",
Category = HintCategory.Reachability,
EvidenceRefs = context.ReachabilityData
});
}
if (!context.PolicyTraces.IsDefaultOrEmpty && context.PolicyTraces.Length > 0)
{
hints.Add(new LensHint
{
Text = $"{context.PolicyTraces.Length} policy trace(s) apply to this advisory.",
Category = HintCategory.Policy,
EvidenceRefs = context.PolicyTraces
});
}
return hints
.OrderBy(h => (int)h.Category)
.ThenBy(h => h.Text, StringComparer.Ordinal)
.ToImmutableArray();
}
private static string ComputeInputHash(LensContext context)
{
var json = JsonSerializer.Serialize(context, s_hashJsonOptions);
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return $"sha256:{Convert.ToHexStringLower(bytes)}";
}
}

View File

@@ -0,0 +1,8 @@
using StellaOps.AdvisoryLens.Models;
namespace StellaOps.AdvisoryLens.Services;
public interface IAdvisoryLensService
{
LensResult Evaluate(LensContext context);
}

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,24 @@
# Resolver Tests Charter
## Mission
- Validate deterministic resolver behavior, graph validation rules, and digest stability.
## Responsibilities
- Keep test inputs deterministic (fixed IDs, timestamps, and seeded data).
- Avoid culture-dependent parsing and non-ASCII literals in fixtures.
- Keep property-based tests bounded and reproducible.
## Required Reading
- docs/modules/reach-graph/architecture.md
- docs/modules/platform/architecture-overview.md
## Working Directory and Scope
- Primary: src/__Libraries/StellaOps.Resolver.Tests
## Testing Expectations
- Cover cycle detection, graph normalization, and digest invariants.
- Assert deterministic ordering and stable hashes across runs.
## Working Agreement
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
- Keep outputs deterministic and ASCII-only in logs and comments.

View File

@@ -0,0 +1,171 @@
/**
* Cycle Detection Tests
* Sprint: SPRINT_9100_0001_0002 (Cycle-Cut Edge Support)
* Tasks: CYCLE-9100-016 through CYCLE-9100-021
*/
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Resolver.Tests;
public class CycleDetectionTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void GraphWithMarkedCycleCutEdge_IsValid()
{
// CYCLE-9100-016: Graph with marked cycle-cut edge passes validation
var nodeA = Node.Create("package", "a");
var nodeB = Node.Create("package", "b");
var nodeC = Node.Create("package", "c");
// A -> B -> C -> A (cycle)
var edge1 = Edge.Create(nodeA.Id, "depends_on", nodeB.Id);
var edge2 = Edge.Create(nodeB.Id, "depends_on", nodeC.Id);
var edge3 = Edge.CreateCycleCut(nodeC.Id, "depends_on", nodeA.Id); // Marked as cycle-cut
var graph = EvidenceGraph.Create(
new[] { nodeA, nodeB, nodeC },
new[] { edge1, edge2, edge3 });
var validator = new DefaultGraphValidator();
var result = validator.Validate(graph);
Assert.True(result.IsValid, $"Expected valid graph. Errors: {string.Join(", ", result.Errors)}");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void GraphWithUnmarkedCycle_ThrowsInvalidGraphException()
{
// CYCLE-9100-017: Graph with unmarked cycle throws exception
var nodeA = Node.Create("package", "a");
var nodeB = Node.Create("package", "b");
var nodeC = Node.Create("package", "c");
// A -> B -> C -> A (cycle without cut edge)
var edge1 = Edge.Create(nodeA.Id, "depends_on", nodeB.Id);
var edge2 = Edge.Create(nodeB.Id, "depends_on", nodeC.Id);
var edge3 = Edge.Create(nodeC.Id, "depends_on", nodeA.Id); // NOT marked as cycle-cut
var graph = EvidenceGraph.Create(
new[] { nodeA, nodeB, nodeC },
new[] { edge1, edge2, edge3 });
var validator = new DefaultGraphValidator();
var result = validator.Validate(graph);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.Contains("Cycle detected without IsCycleCut edge"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void GraphWithMultipleCycles_AllMarked_IsValid()
{
// CYCLE-9100-018: Multiple cycles, all marked
var nodeA = Node.Create("package", "a");
var nodeB = Node.Create("package", "b");
var nodeC = Node.Create("package", "c");
var nodeD = Node.Create("package", "d");
// Cycle 1: A -> B -> A
var edge1 = Edge.Create(nodeA.Id, "depends_on", nodeB.Id);
var edge2 = Edge.CreateCycleCut(nodeB.Id, "depends_on", nodeA.Id);
// Cycle 2: C -> D -> C
var edge3 = Edge.Create(nodeC.Id, "depends_on", nodeD.Id);
var edge4 = Edge.CreateCycleCut(nodeD.Id, "depends_on", nodeC.Id);
var graph = EvidenceGraph.Create(
new[] { nodeA, nodeB, nodeC, nodeD },
new[] { edge1, edge2, edge3, edge4 });
var validator = new DefaultGraphValidator();
var result = validator.Validate(graph);
Assert.True(result.IsValid);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void GraphWithMultipleCycles_OneUnmarked_HasError()
{
// CYCLE-9100-019: Multiple cycles, one unmarked
var nodeA = Node.Create("package", "a");
var nodeB = Node.Create("package", "b");
var nodeC = Node.Create("package", "c");
var nodeD = Node.Create("package", "d");
// Cycle 1: A -> B -> A (marked)
var edge1 = Edge.Create(nodeA.Id, "depends_on", nodeB.Id);
var edge2 = Edge.CreateCycleCut(nodeB.Id, "depends_on", nodeA.Id);
// Cycle 2: C -> D -> C (NOT marked)
var edge3 = Edge.Create(nodeC.Id, "depends_on", nodeD.Id);
var edge4 = Edge.Create(nodeD.Id, "depends_on", nodeC.Id);
var graph = EvidenceGraph.Create(
new[] { nodeA, nodeB, nodeC, nodeD },
new[] { edge1, edge2, edge3, edge4 });
var validator = new DefaultGraphValidator();
var result = validator.Validate(graph);
Assert.False(result.IsValid);
Assert.Single(result.Errors, e => e.Contains("Cycle detected"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CycleDetection_IsDeterministic()
{
// CYCLE-9100-020: Property test - deterministic detection
var nodeA = Node.Create("package", "a");
var nodeB = Node.Create("package", "b");
var nodeC = Node.Create("package", "c");
var edge1 = Edge.Create(nodeA.Id, "depends_on", nodeB.Id);
var edge2 = Edge.Create(nodeB.Id, "depends_on", nodeC.Id);
var edge3 = Edge.Create(nodeC.Id, "depends_on", nodeA.Id);
var graph = EvidenceGraph.Create(
new[] { nodeA, nodeB, nodeC },
new[] { edge1, edge2, edge3 });
var detector = new TarjanCycleDetector();
var cycles1 = detector.DetectCycles(graph);
var cycles2 = detector.DetectCycles(graph);
Assert.Equal(cycles1.Length, cycles2.Length);
for (int i = 0; i < cycles1.Length; i++)
{
Assert.Equal(
cycles1[i].CycleNodes.OrderBy(n => n).ToArray(),
cycles2[i].CycleNodes.OrderBy(n => n).ToArray());
}
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CycleCutEdge_IncludedInGraphDigest()
{
// CYCLE-9100-021: Cycle-cut edges affect graph digest
var nodeA = Node.Create("package", "a");
var nodeB = Node.Create("package", "b");
var regularEdge = Edge.Create(nodeA.Id, "depends_on", nodeB.Id);
var cycleCutEdge = Edge.CreateCycleCut(nodeA.Id, "depends_on", nodeB.Id);
var graph1 = EvidenceGraph.Create(new[] { nodeA, nodeB }, new[] { regularEdge });
var graph2 = EvidenceGraph.Create(new[] { nodeA, nodeB }, new[] { cycleCutEdge });
// EdgeId is computed from (src, kind, dst), not IsCycleCut
// So the EdgeIds are the same, but the edges are different objects
// The graph digest should be the same since EdgeId is what matters for the digest
Assert.Equal(regularEdge.Id, cycleCutEdge.Id);
Assert.Equal(graph1.GraphDigest, graph2.GraphDigest);
}
}

View File

@@ -0,0 +1,144 @@
/**
* Resolver Tests
* Sprint: SPRINT_9100_0001_0001 (Core Resolver Package)
* Tasks: RESOLVER-9100-019 through RESOLVER-9100-024
*/
using System.Text.Json;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Resolver.Tests;
public class DeterministicResolverTests
{
private readonly Policy _policy = Policy.Empty;
private readonly IGraphOrderer _orderer = new TopologicalGraphOrderer();
private readonly ITrustLatticeEvaluator _evaluator = new DefaultTrustLatticeEvaluator();
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Run_SameInputTwice_IdenticalFinalDigest()
{
// RESOLVER-9100-020: Replay test
var graph = CreateTestGraph();
var resolver = new DeterministicResolver(_policy, _orderer, _evaluator);
var fixedTime = DateTimeOffset.Parse("2025-12-24T00:00:00Z");
var result1 = resolver.Run(graph, fixedTime);
var result2 = resolver.Run(graph, fixedTime);
Assert.Equal(result1.FinalDigest, result2.FinalDigest);
Assert.Equal(result1.GraphDigest, result2.GraphDigest);
Assert.Equal(result1.TraversalSequence.Length, result2.TraversalSequence.Length);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Run_ShuffledNodesAndEdges_IdenticalFinalDigest()
{
// RESOLVER-9100-021: Permutation test
var node1 = Node.Create("package", "pkg:npm/a@1.0.0");
var node2 = Node.Create("package", "pkg:npm/b@1.0.0");
var node3 = Node.Create("package", "pkg:npm/c@1.0.0");
var edge1 = Edge.Create(node1.Id, "depends_on", node2.Id);
var edge2 = Edge.Create(node2.Id, "depends_on", node3.Id);
// Create graphs with different input orders
var graph1 = EvidenceGraph.Create(
new[] { node1, node2, node3 },
new[] { edge1, edge2 });
var graph2 = EvidenceGraph.Create(
new[] { node3, node1, node2 }, // shuffled
new[] { edge2, edge1 }); // shuffled
var resolver = new DeterministicResolver(_policy, _orderer, _evaluator);
var fixedTime = DateTimeOffset.Parse("2025-12-24T00:00:00Z");
var result1 = resolver.Run(graph1, fixedTime);
var result2 = resolver.Run(graph2, fixedTime);
Assert.Equal(result1.FinalDigest, result2.FinalDigest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Run_IsIdempotent()
{
// RESOLVER-9100-022: Idempotency property test
var graph = CreateTestGraph();
var resolver = new DeterministicResolver(_policy, _orderer, _evaluator);
var fixedTime = DateTimeOffset.Parse("2025-12-24T00:00:00Z");
var result1 = resolver.Run(graph, fixedTime);
var result2 = resolver.Run(graph, fixedTime);
var result3 = resolver.Run(graph, fixedTime);
Assert.Equal(result1.FinalDigest, result2.FinalDigest);
Assert.Equal(result2.FinalDigest, result3.FinalDigest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Run_TraversalSequence_MatchesTopologicalOrder()
{
// RESOLVER-9100-023: Traversal order test
var root = Node.Create("package", "root");
var child1 = Node.Create("package", "child1");
var child2 = Node.Create("package", "child2");
var edge1 = Edge.Create(root.Id, "depends_on", child1.Id);
var edge2 = Edge.Create(root.Id, "depends_on", child2.Id);
var graph = EvidenceGraph.Create(
new[] { root, child1, child2 },
new[] { edge1, edge2 });
var resolver = new DeterministicResolver(_policy, _orderer, _evaluator);
var result = resolver.Run(graph);
// Children should come before root in topological order (reverse dependency order)
var rootIndex = result.TraversalSequence.ToList().IndexOf(root.Id);
var child1Index = result.TraversalSequence.ToList().IndexOf(child1.Id);
var child2Index = result.TraversalSequence.ToList().IndexOf(child2.Id);
// Root depends on children, so root should come after children in topological order
// Wait - our edges go root -> child, so root has no incoming edges
// Root should actually be first since it has no dependencies
Assert.True(rootIndex < child1Index || rootIndex < child2Index,
"Root should appear before at least one child in traversal");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ResolutionResult_CanonicalJsonStructure()
{
// RESOLVER-9100-024: Snapshot test for canonical JSON
var graph = CreateTestGraph();
var resolver = new DeterministicResolver(_policy, _orderer, _evaluator);
var fixedTime = DateTimeOffset.Parse("2025-12-24T00:00:00Z");
var result = resolver.Run(graph, fixedTime);
// Verify result structure
Assert.NotNull(result.FinalDigest);
Assert.NotNull(result.GraphDigest);
Assert.NotNull(result.PolicyDigest);
Assert.Equal(64, result.FinalDigest.Length); // SHA256 hex
Assert.Equal(64, result.GraphDigest.Length);
Assert.Equal(64, result.PolicyDigest.Length);
Assert.Equal(fixedTime, result.ResolvedAt);
}
private static EvidenceGraph CreateTestGraph()
{
var node1 = Node.Create("package", "pkg:npm/test@1.0.0");
var node2 = Node.Create("vulnerability", "CVE-2024-1234");
var edge = Edge.Create(node2.Id, "affects", node1.Id);
return EvidenceGraph.Create(new[] { node1, node2 }, new[] { edge });
}
}

View File

@@ -0,0 +1,109 @@
/**
* EdgeId Tests
* Sprint: SPRINT_9100_0001_0003 (Content-Addressed EdgeId)
* Tasks: EDGEID-9100-015 through EDGEID-9100-019
*/
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Resolver.Tests;
public class EdgeIdTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void EdgeId_ComputedDeterministically()
{
// EDGEID-9100-015: EdgeId computed deterministically
var src = NodeId.From("package", "a");
var dst = NodeId.From("package", "b");
var kind = "depends_on";
var edgeId1 = EdgeId.From(src, kind, dst);
var edgeId2 = EdgeId.From(src, kind, dst);
Assert.Equal(edgeId1, edgeId2);
Assert.Equal(64, edgeId1.Value.Length); // SHA256 hex
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void EdgeId_OrderingConsistentWithStringOrdering()
{
// EDGEID-9100-016: EdgeId ordering is consistent
var edgeIds = new List<EdgeId>();
for (int i = 0; i < 10; i++)
{
var src = NodeId.From("package", $"src{i}");
var dst = NodeId.From("package", $"dst{i}");
edgeIds.Add(EdgeId.From(src, "depends_on", dst));
}
var sorted1 = edgeIds.OrderBy(e => e).ToList();
var sorted2 = edgeIds.OrderBy(e => e.Value, StringComparer.Ordinal).ToList();
Assert.Equal(sorted1, sorted2);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void GraphHash_ChangesWhenEdgeAddedOrRemoved()
{
// EDGEID-9100-017: Graph hash changes with edge changes
var nodeA = Node.Create("package", "a");
var nodeB = Node.Create("package", "b");
var nodeC = Node.Create("package", "c");
var edge1 = Edge.Create(nodeA.Id, "depends_on", nodeB.Id);
var edge2 = Edge.Create(nodeB.Id, "depends_on", nodeC.Id);
var graph1 = EvidenceGraph.Create(new[] { nodeA, nodeB, nodeC }, new[] { edge1 });
var graph2 = EvidenceGraph.Create(new[] { nodeA, nodeB, nodeC }, new[] { edge1, edge2 });
var graph3 = EvidenceGraph.Create(new[] { nodeA, nodeB, nodeC }, new[] { edge2 });
Assert.NotEqual(graph1.GraphDigest, graph2.GraphDigest);
Assert.NotEqual(graph1.GraphDigest, graph3.GraphDigest);
Assert.NotEqual(graph2.GraphDigest, graph3.GraphDigest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void EdgeDelta_CorrectlyIdentifiesChanges()
{
// EDGEID-9100-018: Delta detection identifies changes
var nodeA = Node.Create("package", "a");
var nodeB = Node.Create("package", "b");
var nodeC = Node.Create("package", "c");
var edge1 = Edge.Create(nodeA.Id, "depends_on", nodeB.Id);
var edge2 = Edge.Create(nodeB.Id, "depends_on", nodeC.Id);
var edge3 = Edge.Create(nodeA.Id, "depends_on", nodeC.Id);
var oldGraph = EvidenceGraph.Create(new[] { nodeA, nodeB, nodeC }, new[] { edge1, edge2 });
var newGraph = EvidenceGraph.Create(new[] { nodeA, nodeB, nodeC }, new[] { edge1, edge3 });
var detector = new DefaultEdgeDeltaDetector();
var delta = detector.Detect(oldGraph, newGraph);
Assert.Single(delta.AddedEdges); // edge3
Assert.Single(delta.RemovedEdges); // edge2
Assert.Empty(delta.ModifiedEdges);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void EdgeId_IsIdempotent()
{
// EDGEID-9100-019: Property test - idempotent computation
var src = NodeId.From("package", "test-src");
var dst = NodeId.From("package", "test-dst");
var kind = "test-kind";
var results = Enumerable.Range(0, 100)
.Select(_ => EdgeId.From(src, kind, dst))
.ToList();
Assert.All(results, r => Assert.Equal(results[0], r));
}
}

View File

@@ -0,0 +1,176 @@
/**
* FinalDigest Tests
* Sprint: SPRINT_9100_0002_0001 (FinalDigest Implementation)
* Tasks: DIGEST-9100-018 through DIGEST-9100-024
*/
using System.Text.Json;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Resolver.Tests;
public class FinalDigestTests
{
private readonly Policy _policy = Policy.Empty;
private readonly IGraphOrderer _orderer = new TopologicalGraphOrderer();
private readonly ITrustLatticeEvaluator _evaluator = new DefaultTrustLatticeEvaluator();
[Trait("Category", TestCategories.Unit)]
[Fact]
public void FinalDigest_IsDeterministic()
{
// DIGEST-9100-018: Same inputs → same digest
var graph = CreateTestGraph();
var resolver = new DeterministicResolver(_policy, _orderer, _evaluator);
var fixedTime = DateTimeOffset.Parse("2025-12-24T00:00:00Z");
var result1 = resolver.Run(graph, fixedTime);
var result2 = resolver.Run(graph, fixedTime);
Assert.Equal(result1.FinalDigest, result2.FinalDigest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void FinalDigest_ChangesWhenVerdictChanges()
{
// DIGEST-9100-019: FinalDigest changes when any verdict changes
var node1 = Node.Create("package", "a");
var node2 = Node.Create("package", "b");
var edge = Edge.Create(node1.Id, "depends_on", node2.Id);
var graph = EvidenceGraph.Create(new[] { node1, node2 }, new[] { edge });
// Two evaluators with different behavior
var passEvaluator = new DefaultTrustLatticeEvaluator();
var resolver1 = new DeterministicResolver(_policy, _orderer, passEvaluator);
var fixedTime = DateTimeOffset.Parse("2025-12-24T00:00:00Z");
var result1 = resolver1.Run(graph, fixedTime);
// Verdicts exist
Assert.NotEmpty(result1.Verdicts);
Assert.Equal(64, result1.FinalDigest.Length);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void FinalDigest_ChangesWhenGraphChanges()
{
// DIGEST-9100-020: FinalDigest changes when graph changes
var node1 = Node.Create("package", "a");
var node2 = Node.Create("package", "b");
var node3 = Node.Create("package", "c");
var edge1 = Edge.Create(node1.Id, "depends_on", node2.Id);
var edge2 = Edge.Create(node1.Id, "depends_on", node3.Id);
var graph1 = EvidenceGraph.Create(new[] { node1, node2 }, new[] { edge1 });
var graph2 = EvidenceGraph.Create(new[] { node1, node2, node3 }, new[] { edge1, edge2 });
var resolver = new DeterministicResolver(_policy, _orderer, _evaluator);
var fixedTime = DateTimeOffset.Parse("2025-12-24T00:00:00Z");
var result1 = resolver.Run(graph1, fixedTime);
var result2 = resolver.Run(graph2, fixedTime);
Assert.NotEqual(result1.FinalDigest, result2.FinalDigest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void FinalDigest_ChangesWhenPolicyChanges()
{
// DIGEST-9100-021: FinalDigest changes when policy changes
var graph = CreateTestGraph();
var policy1 = Policy.Create("1.0.0", JsonDocument.Parse("{}").RootElement);
var policy2 = Policy.Create("2.0.0", JsonDocument.Parse("{}").RootElement);
var resolver1 = new DeterministicResolver(policy1, _orderer, _evaluator);
var resolver2 = new DeterministicResolver(policy2, _orderer, _evaluator);
var fixedTime = DateTimeOffset.Parse("2025-12-24T00:00:00Z");
var result1 = resolver1.Run(graph, fixedTime);
var result2 = resolver2.Run(graph, fixedTime);
Assert.NotEqual(result1.PolicyDigest, result2.PolicyDigest);
Assert.NotEqual(result1.FinalDigest, result2.FinalDigest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void VerificationApi_CorrectlyIdentifiesMatch()
{
// DIGEST-9100-022: Verification API works
var graph = CreateTestGraph();
var resolver = new DeterministicResolver(_policy, _orderer, _evaluator);
var fixedTime = DateTimeOffset.Parse("2025-12-24T00:00:00Z");
var result1 = resolver.Run(graph, fixedTime);
var result2 = resolver.Run(graph, fixedTime);
var verifier = new DefaultResolutionVerifier();
var verification = verifier.Verify(result1, result2);
Assert.True(verification.Match);
Assert.Equal(result1.FinalDigest, verification.ExpectedDigest);
Assert.Empty(verification.Differences);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void VerificationApi_CorrectlyIdentifiesMismatch()
{
// DIGEST-9100-022 continued: Verification API detects mismatch
var graph1 = CreateTestGraph();
var node3 = Node.Create("package", "c");
var graph2 = graph1.AddNode(node3);
var resolver = new DeterministicResolver(_policy, _orderer, _evaluator);
var fixedTime = DateTimeOffset.Parse("2025-12-24T00:00:00Z");
var result1 = resolver.Run(graph1, fixedTime);
var result2 = resolver.Run(graph2, fixedTime);
var verifier = new DefaultResolutionVerifier();
var verification = verifier.Verify(result1, result2);
Assert.False(verification.Match);
Assert.NotEmpty(verification.Differences);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void FinalDigest_IsCollisionResistant()
{
// DIGEST-9100-024: Property test - different inputs → different digest
var digests = new HashSet<string>();
for (int i = 0; i < 100; i++)
{
var node = Node.Create("package", $"pkg:npm/test-{i}@1.0.0");
var graph = EvidenceGraph.Create(new[] { node }, Array.Empty<Edge>());
var resolver = new DeterministicResolver(_policy, _orderer, _evaluator);
var result = resolver.Run(graph);
// Each unique graph should produce a unique digest
Assert.True(digests.Add(result.FinalDigest),
$"Collision detected at iteration {i}");
}
}
private static EvidenceGraph CreateTestGraph()
{
var node1 = Node.Create("package", "pkg:npm/test@1.0.0");
var node2 = Node.Create("vulnerability", "CVE-2024-1234");
var edge = Edge.Create(node2.Id, "affects", node1.Id);
return EvidenceGraph.Create(new[] { node1, node2 }, new[] { edge });
}
}

View File

@@ -0,0 +1,142 @@
/**
* Graph Validation & NFC Tests
* Sprint: SPRINT_9100_0003_0002 (Graph Validation & NFC Normalization)
* Tasks: VALID-9100-021 through VALID-9100-028
*/
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Resolver.Tests;
public class GraphValidationTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void NfcNormalization_ProducesConsistentNodeIds()
{
// VALID-9100-021: NFC normalization produces consistent NodeIds
// Using different Unicode representations of the same character
// é can be represented as:
// - U+00E9 (precomposed: LATIN SMALL LETTER E WITH ACUTE)
// - U+0065 U+0301 (decomposed: e + COMBINING ACUTE ACCENT)
var precomposed = "caf\u00E9"; // café with precomposed é
var decomposed = "cafe\u0301"; // café with decomposed é
var nodeId1 = NodeId.From("package", precomposed);
var nodeId2 = NodeId.From("package", decomposed);
// After NFC normalization, both should produce the same NodeId
Assert.Equal(nodeId1, nodeId2);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void EdgeReferencingNonExistentNode_Detected()
{
// VALID-9100-022
var node1 = Node.Create("package", "a");
var nonExistentNodeId = NodeId.From("package", "nonexistent");
var edge = Edge.Create(node1.Id, "depends_on", nonExistentNodeId);
var graph = EvidenceGraph.Create(new[] { node1 }, new[] { edge });
var detector = new DefaultImplicitDataDetector();
var violations = detector.Detect(graph);
Assert.Contains(violations, v => v.ViolationType == "DanglingEdgeDestination");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DuplicateNodeIds_Detected()
{
// VALID-9100-023
var node1 = Node.Create("package", "a");
var node2 = new Node(node1.Id, "package", "a-duplicate"); // Same ID, different key
var graph = new EvidenceGraph
{
Nodes = [node1, node2],
Edges = []
};
var detector = new DefaultImplicitDataDetector();
var violations = detector.Detect(graph);
Assert.Contains(violations, v => v.ViolationType == "DuplicateNodeId");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DuplicateEdgeIds_Detected()
{
// VALID-9100-024
var node1 = Node.Create("package", "a");
var node2 = Node.Create("package", "b");
var edge1 = Edge.Create(node1.Id, "depends_on", node2.Id);
var edge2 = Edge.Create(node1.Id, "depends_on", node2.Id); // Same EdgeId
var graph = new EvidenceGraph
{
Nodes = [node1, node2],
Edges = [edge1, edge2]
};
var detector = new DefaultImplicitDataDetector();
var violations = detector.Detect(graph);
Assert.Contains(violations, v => v.ViolationType == "DuplicateEdgeId");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ValidGraph_PassesAllChecks()
{
// VALID-9100-027
var node1 = Node.Create("package", "a");
var node2 = Node.Create("package", "b");
var node3 = Node.Create("package", "c");
var edge1 = Edge.Create(node1.Id, "depends_on", node2.Id);
var edge2 = Edge.Create(node2.Id, "depends_on", node3.Id);
var graph = EvidenceGraph.Create(new[] { node1, node2, node3 }, new[] { edge1, edge2 });
var validator = new DefaultGraphValidator();
var result = validator.Validate(graph);
Assert.True(result.IsValid);
Assert.Empty(result.Errors);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void NfcNormalization_IsIdempotent()
{
// VALID-9100-028: Property test - NFC is idempotent
var normalizer = NfcStringNormalizer.Instance;
var input = "café";
var normalized1 = normalizer.Normalize(input);
var normalized2 = normalizer.Normalize(normalized1);
var normalized3 = normalizer.Normalize(normalized2);
Assert.Equal(normalized1, normalized2);
Assert.Equal(normalized2, normalized3);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void EmptyGraph_IsValid()
{
var graph = EvidenceGraph.Empty;
var validator = new DefaultGraphValidator();
var result = validator.Validate(graph);
Assert.True(result.IsValid);
}
}

View File

@@ -0,0 +1,107 @@
/**
* Runtime Purity Tests
* Sprint: SPRINT_9100_0003_0001 (Runtime Purity Enforcement)
* Tasks: PURITY-9100-021 through PURITY-9100-028
*/
using StellaOps.Resolver.Purity;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Resolver.Tests;
public class RuntimePurityTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ProhibitedTimeProvider_ThrowsOnAccess()
{
// PURITY-9100-021
var provider = new ProhibitedTimeProvider();
Assert.Throws<AmbientAccessViolationException>(() => _ = provider.Now);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ProhibitedEnvironmentAccessor_ThrowsOnAccess()
{
// PURITY-9100-024
var accessor = new ProhibitedEnvironmentAccessor();
Assert.Throws<AmbientAccessViolationException>(() => accessor.GetVariable("PATH"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void InjectedTimeProvider_ReturnsInjectedTime()
{
// PURITY-9100-025
var injectedTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
var provider = new InjectedTimeProvider(injectedTime);
Assert.Equal(injectedTime, provider.Now);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void InjectedEnvironmentAccessor_ReturnsInjectedValues()
{
var vars = new Dictionary<string, string> { { "TEST_VAR", "test_value" } };
var accessor = new InjectedEnvironmentAccessor(vars);
Assert.Equal("test_value", accessor.GetVariable("TEST_VAR"));
Assert.Null(accessor.GetVariable("NONEXISTENT"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void PureEvaluationContext_StrictMode_ThrowsOnAmbientAccess()
{
var context = PureEvaluationContext.CreateStrict();
Assert.Throws<AmbientAccessViolationException>(() => _ = context.InjectedNow);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void PureEvaluationContext_WithInjectedValues_WorksCorrectly()
{
var injectedTime = DateTimeOffset.Parse("2025-12-24T12:00:00Z");
var context = PureEvaluationContext.Create(injectedTime);
Assert.Equal(injectedTime, context.InjectedNow);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AmbientAccessViolationException_ContainsDetails()
{
var ex = new AmbientAccessViolationException("Time", "Attempted DateTime.Now access");
Assert.Equal("Time", ex.Category);
Assert.Equal("Attempted DateTime.Now access", ex.AttemptedOperation);
Assert.Contains("Time", ex.Message);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void FullResolution_CompletesWithoutAmbientAccess()
{
// PURITY-9100-027: Integration test
var node = Node.Create("package", "test");
var graph = EvidenceGraph.Create(new[] { node }, Array.Empty<Edge>());
var policy = Policy.Empty;
var orderer = new TopologicalGraphOrderer();
var evaluator = new DefaultTrustLatticeEvaluator();
var resolver = new DeterministicResolver(policy, orderer, evaluator);
// This should complete without any ambient access violations
var fixedTime = DateTimeOffset.Parse("2025-12-24T00:00:00Z");
var result = resolver.Run(graph, fixedTime);
Assert.NotNull(result);
Assert.Single(result.Verdicts);
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FsCheck.Xunit.v3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Resolver\StellaOps.Resolver.csproj" />
<ProjectReference Include="../StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,11 @@
# Resolver Tests Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0109-M | DONE | Revalidated 2026-01-08; maintainability audit for Resolver tests. |
| AUDIT-0109-T | DONE | Revalidated 2026-01-08; test coverage audit for Resolver tests. |
| AUDIT-0109-A | TODO | Pending approval (revalidated 2026-01-08). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -0,0 +1,160 @@
/**
* VerdictDigest Tests
* Sprint: SPRINT_9100_0002_0002 (Per-Node VerdictDigest)
* Tasks: VDIGEST-9100-016 through VDIGEST-9100-021
*/
using System.Text.Json;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Resolver.Tests;
public class VerdictDigestTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void VerdictDigest_IsDeterministic()
{
// VDIGEST-9100-016: Same verdict → same digest
var nodeId = NodeId.From("package", "test");
var evidence = JsonDocument.Parse("{\"reason\": \"test\"}").RootElement;
var verdict1 = Verdict.Create(nodeId, VerdictStatus.Pass, evidence, "Test reason", 0);
var verdict2 = Verdict.Create(nodeId, VerdictStatus.Pass, evidence, "Test reason", 0);
Assert.Equal(verdict1.VerdictDigest, verdict2.VerdictDigest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void VerdictDigest_ChangesWhenStatusChanges()
{
// VDIGEST-9100-017: Digest changes with status
var nodeId = NodeId.From("package", "test");
var evidence = JsonDocument.Parse("{\"reason\": \"test\"}").RootElement;
var passVerdict = Verdict.Create(nodeId, VerdictStatus.Pass, evidence);
var failVerdict = Verdict.Create(nodeId, VerdictStatus.Fail, evidence);
Assert.NotEqual(passVerdict.VerdictDigest, failVerdict.VerdictDigest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void VerdictDigest_ChangesWhenEvidenceChanges()
{
// VDIGEST-9100-018: Digest changes with evidence
var nodeId = NodeId.From("package", "test");
var evidence1 = JsonDocument.Parse("{\"reason\": \"reason1\"}").RootElement;
var evidence2 = JsonDocument.Parse("{\"reason\": \"reason2\"}").RootElement;
var verdict1 = Verdict.Create(nodeId, VerdictStatus.Pass, evidence1);
var verdict2 = Verdict.Create(nodeId, VerdictStatus.Pass, evidence2);
Assert.NotEqual(verdict1.VerdictDigest, verdict2.VerdictDigest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void VerdictDelta_CorrectlyIdentifiesChangedVerdicts()
{
// VDIGEST-9100-019: Delta detection identifies changed verdicts
var nodeId1 = NodeId.From("package", "a");
var nodeId2 = NodeId.From("package", "b");
var oldVerdicts = new[]
{
Verdict.Create(nodeId1, VerdictStatus.Pass, null),
Verdict.Create(nodeId2, VerdictStatus.Pass, null)
};
var newVerdicts = new[]
{
Verdict.Create(nodeId1, VerdictStatus.Pass, null),
Verdict.Create(nodeId2, VerdictStatus.Fail, null) // Changed
};
var oldResult = new ResolutionResult
{
TraversalSequence = [nodeId1, nodeId2],
Verdicts = [.. oldVerdicts],
GraphDigest = "abc",
PolicyDigest = "def",
FinalDigest = "old"
};
var newResult = new ResolutionResult
{
TraversalSequence = [nodeId1, nodeId2],
Verdicts = [.. newVerdicts],
GraphDigest = "abc",
PolicyDigest = "def",
FinalDigest = "new"
};
var detector = new DefaultVerdictDeltaDetector();
var delta = detector.Detect(oldResult, newResult);
Assert.Single(delta.ChangedVerdicts);
Assert.Equal(nodeId2, delta.ChangedVerdicts[0].Old.Node);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void VerdictDelta_HandlesAddedRemovedNodes()
{
// VDIGEST-9100-020: Delta handles added/removed nodes
var nodeId1 = NodeId.From("package", "a");
var nodeId2 = NodeId.From("package", "b");
var nodeId3 = NodeId.From("package", "c");
var oldResult = new ResolutionResult
{
TraversalSequence = [nodeId1, nodeId2],
Verdicts = [
Verdict.Create(nodeId1, VerdictStatus.Pass, null),
Verdict.Create(nodeId2, VerdictStatus.Pass, null)
],
GraphDigest = "abc",
PolicyDigest = "def",
FinalDigest = "old"
};
var newResult = new ResolutionResult
{
TraversalSequence = [nodeId1, nodeId3],
Verdicts = [
Verdict.Create(nodeId1, VerdictStatus.Pass, null),
Verdict.Create(nodeId3, VerdictStatus.Pass, null)
],
GraphDigest = "abc",
PolicyDigest = "def",
FinalDigest = "new"
};
var detector = new DefaultVerdictDeltaDetector();
var delta = detector.Detect(oldResult, newResult);
Assert.Single(delta.AddedVerdicts);
Assert.Single(delta.RemovedVerdicts);
Assert.Equal(nodeId3, delta.AddedVerdicts[0].Node);
Assert.Equal(nodeId2, delta.RemovedVerdicts[0].Node);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void VerdictDigest_ExcludesItselfFromComputation()
{
// VDIGEST-9100-021: Property test - no recursion
var nodeId = NodeId.From("package", "test");
// Create two verdicts with the same input data
var verdict1 = Verdict.Create(nodeId, VerdictStatus.Pass, null, "reason", 0);
var verdict2 = Verdict.Create(nodeId, VerdictStatus.Pass, null, "reason", 0);
// Digests should be identical and stable (not including themselves)
Assert.Equal(verdict1.VerdictDigest, verdict2.VerdictDigest);
Assert.Equal(64, verdict1.VerdictDigest.Length); // Valid SHA256
}
}

View File

@@ -0,0 +1,25 @@
# Resolver Library Charter
## Mission
- Provide deterministic graph resolution with stable ordering, digests, and validation.
## Responsibilities
- Enforce determinism by avoiding ambient time and culture-sensitive behavior.
- Maintain canonical serialization and stable ordering guarantees.
- Keep outputs ASCII-only in logs and comments.
## Required Reading
- docs/modules/reach-graph/architecture.md
- docs/modules/platform/architecture-overview.md
## Working Directory and Scope
- Primary: src/__Libraries/StellaOps.Resolver
- Tests: src/__Libraries/StellaOps.Resolver.Tests
## Testing Expectations
- Cover graph validation, traversal ordering, digest stability, and resolvedAt handling.
- Validate NFC normalization and deterministic ordering across runs.
## Working Agreement
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
- Keep outputs deterministic and ASCII-only in logs and comments.

View File

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

View File

@@ -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;
}
}

View 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
};
}
}

View 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 };
}

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

View 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;
}

View File

@@ -0,0 +1,126 @@
/**
* 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 StellaOps.Canonicalization.Json;
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
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;
}
}

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

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

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

View 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;
}
}
}

View 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;
}

View 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, "");
}

View 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);

View File

@@ -0,0 +1,148 @@
/**
* 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 StellaOps.Canonicalization.Json;
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
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;
}
}

View 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]}..."));
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<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" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Canonicalization\StellaOps.Canonicalization.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,11 @@
# Resolver Library Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0110-M | DONE | Revalidated 2026-01-08; maintainability audit for Resolver library. |
| AUDIT-0110-T | DONE | Revalidated 2026-01-08; test coverage audit for Resolver library. |
| AUDIT-0110-A | TODO | Pending approval (revalidated 2026-01-08). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -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;
}
}

View File

@@ -0,0 +1,115 @@
/**
* 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 StellaOps.Canonicalization.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.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;
}

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