This commit is contained in:
StellaOps Bot
2025-12-09 00:20:52 +02:00
parent 3d01bf9edc
commit bc0762e97d
261 changed files with 14033 additions and 4427 deletions

View File

@@ -0,0 +1,181 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Signals.Models;
using StellaOps.Signals.Options;
using StellaOps.Signals.Parsing;
using StellaOps.Signals.Persistence;
using StellaOps.Signals.Services;
using StellaOps.Signals.Storage;
using StellaOps.Signals.Storage.Models;
using Xunit;
namespace StellaOps.Signals.Tests;
public class CallgraphIngestionServiceTests
{
private readonly InMemoryCallgraphRepository _repository = new();
private readonly InMemoryArtifactStore _artifactStore = new();
private readonly CallgraphNormalizationService _normalizer = new();
private readonly TimeProvider _timeProvider = TimeProvider.System;
[Fact]
public async Task IngestAsync_normalizes_graph_and_persists_manifest_hash()
{
var parser = new StubParser("java");
var resolver = new StubParserResolver(parser);
var options = Microsoft.Extensions.Options.Options.Create(new SignalsOptions());
var service = new CallgraphIngestionService(
resolver,
_artifactStore,
_repository,
_normalizer,
options,
_timeProvider,
NullLogger<CallgraphIngestionService>.Instance);
var artifactJson = @"{""nodes"":[{""id"":""com/example/Foo.bar:(I)V"",""kind"":""fn""}],
""edges"":[{""source"":""com/example/Foo.bar:(I)V"",""target"":""com/example/Foo.bar:(I)V""}]}";
var request = new CallgraphIngestRequest(
Language: "java",
Component: "demo",
Version: "1.0.0",
ArtifactFileName: "graph.json",
ArtifactContentType: "application/json",
ArtifactContentBase64: Convert.ToBase64String(Encoding.UTF8.GetBytes(artifactJson)),
SchemaVersion: null,
Metadata: new Dictionary<string, string?> { ["source"] = "test" },
Analyzer: new Dictionary<string, string?> { ["name"] = "stub" });
var response = await service.IngestAsync(request, CancellationToken.None);
response.CallgraphId.Should().NotBeNullOrWhiteSpace();
response.GraphHash.Should().NotBeNullOrWhiteSpace();
response.NodeCount.Should().Be(1);
response.EdgeCount.Should().Be(1);
response.ManifestCasUri.Should().Be("cas://signals/manifests/graph.json");
var stored = _repository.LastUpserted!;
stored.Artifact.Hash.Should().Be(response.ArtifactHash);
stored.Nodes[0].Namespace.Should().Be("com.example.Foo");
stored.Nodes[0].Language.Should().Be("java");
stored.Metadata!["schemaVersion"].Should().Be("1.0");
stored.Metadata!["analyzer.name"].Should().Be("stub");
stored.Artifact.GraphHash.Should().Be(response.GraphHash);
}
private sealed class StubParser : ICallgraphParser
{
public StubParser(string language) => Language = language;
public string Language { get; }
public Task<CallgraphParseResult> ParseAsync(Stream artifactStream, CancellationToken cancellationToken)
{
artifactStream.Position = 0;
using var doc = JsonDocument.Parse(artifactStream);
var nodes = new List<CallgraphNode>();
foreach (var node in doc.RootElement.GetProperty("nodes").EnumerateArray())
{
nodes.Add(new CallgraphNode(node.GetProperty("id").GetString()!, "", "function", null, null, null));
}
var edges = new List<CallgraphEdge>();
foreach (var edge in doc.RootElement.GetProperty("edges").EnumerateArray())
{
edges.Add(new CallgraphEdge(
edge.GetProperty("source").GetString()!,
edge.GetProperty("target").GetString()!,
"call"));
}
return Task.FromResult(new CallgraphParseResult(nodes, edges, Array.Empty<CallgraphRoot>(), "1.0", "1.0", null));
}
}
private sealed class StubParserResolver : ICallgraphParserResolver
{
private readonly ICallgraphParser _parser;
public StubParserResolver(ICallgraphParser parser) => _parser = parser;
public ICallgraphParser Resolve(string language) => _parser;
}
private sealed class InMemoryArtifactStore : ICallgraphArtifactStore
{
private readonly Dictionary<string, byte[]> artifacts = new(StringComparer.Ordinal);
private readonly Dictionary<string, byte[]> manifests = new(StringComparer.Ordinal);
public Task<StoredCallgraphArtifact> SaveAsync(CallgraphArtifactSaveRequest request, Stream content, CancellationToken cancellationToken)
{
using var ms = new MemoryStream();
content.CopyTo(ms);
artifacts[request.Hash] = ms.ToArray();
if (request.ManifestContent is not null)
{
using var manifestMs = new MemoryStream();
request.ManifestContent.CopyTo(manifestMs);
manifests[request.Hash] = manifestMs.ToArray();
}
var path = $"cas://signals/artifacts/{request.FileName}";
var manifestPath = "cas://signals/manifests/graph.json";
return Task.FromResult(new StoredCallgraphArtifact(
Path: path,
Length: ms.Length,
Hash: request.Hash,
ContentType: request.ContentType,
CasUri: path,
ManifestPath: manifestPath,
ManifestCasUri: manifestPath));
}
public Task<Stream?> GetAsync(string hash, string? fileName = null, CancellationToken cancellationToken = default)
{
if (artifacts.TryGetValue(hash, out var bytes))
{
return Task.FromResult<Stream?>(new MemoryStream(bytes, writable: false));
}
return Task.FromResult<Stream?>(null);
}
public Task<Stream?> GetManifestAsync(string hash, CancellationToken cancellationToken = default)
{
if (manifests.TryGetValue(hash, out var bytes))
{
return Task.FromResult<Stream?>(new MemoryStream(bytes, writable: false));
}
return Task.FromResult<Stream?>(null);
}
public Task<bool> ExistsAsync(string hash, CancellationToken cancellationToken = default)
{
return Task.FromResult(artifacts.ContainsKey(hash));
}
}
private sealed class InMemoryCallgraphRepository : ICallgraphRepository
{
public CallgraphDocument? LastUpserted { get; private set; }
public Task<CallgraphDocument?> GetByIdAsync(string id, CancellationToken cancellationToken)
{
return Task.FromResult(LastUpserted?.Id == id ? LastUpserted : null);
}
public Task<CallgraphDocument> UpsertAsync(CallgraphDocument document, CancellationToken cancellationToken)
{
LastUpserted = document;
return Task.FromResult(document);
}
}
}

View File

@@ -0,0 +1,68 @@
using System;
using System.Collections.Generic;
using FluentAssertions;
using StellaOps.Signals.Models;
using StellaOps.Signals.Parsing;
using StellaOps.Signals.Services;
using Xunit;
namespace StellaOps.Signals.Tests;
public class CallgraphNormalizationServiceTests
{
private readonly CallgraphNormalizationService _service = new();
[Fact]
public void Normalize_adds_language_and_namespace_for_java()
{
var result = new CallgraphParseResult(
Nodes: new[]
{
new CallgraphNode("com/example/Foo.bar:(I)V", "", "", null, null, null)
},
Edges: Array.Empty<CallgraphEdge>(),
Roots: Array.Empty<CallgraphRoot>(),
FormatVersion: "1.0",
SchemaVersion: "1.0",
Analyzer: null);
var normalized = _service.Normalize("java", result);
normalized.Nodes.Should().ContainSingle();
var node = normalized.Nodes[0];
node.Language.Should().Be("java");
node.Namespace.Should().Be("com.example.Foo"); // dotted namespace derived from id
node.Kind.Should().Be("function");
node.Name.Should().Be("com/example/Foo.bar:(I)V");
}
[Fact]
public void Normalize_deduplicates_edges_and_clamps_confidence()
{
var result = new CallgraphParseResult(
Nodes: new[]
{
new CallgraphNode("a", "a", "fn", null, null, null),
new CallgraphNode("b", "b", "fn", null, null, null)
},
Edges: new[]
{
new CallgraphEdge(" a ", "b", "call", Confidence: 2.5, Evidence: new []{"x","x"}),
new CallgraphEdge("a", "b", "call", Confidence: -1)
},
Roots: Array.Empty<CallgraphRoot>(),
FormatVersion: "1.0",
SchemaVersion: "1.0",
Analyzer: null);
var normalized = _service.Normalize("python", result);
normalized.Edges.Should().ContainSingle();
var edge = normalized.Edges[0];
edge.SourceId.Should().Be("a");
edge.TargetId.Should().Be("b");
edge.Type.Should().Be("call");
edge.Confidence.Should().Be(1.0);
edge.Evidence.Should().BeEquivalentTo(new[] { "x" });
}
}

View File

@@ -15,7 +15,13 @@ public class InMemoryEventsPublisherTests
public async Task PublishFactUpdatedAsync_EmitsStructuredEvent()
{
var logger = new TestLogger<InMemoryEventsPublisher>();
var publisher = new InMemoryEventsPublisher(logger, new SignalsOptions());
var options = new SignalsOptions();
options.Events.Driver = "inmemory";
options.Events.Stream = "signals.fact.updated.v1";
options.Events.DefaultTenant = "tenant-default";
var builder = new ReachabilityFactEventBuilder(options, TimeProvider.System);
var publisher = new InMemoryEventsPublisher(logger, builder);
var fact = new ReachabilityFactDocument
{
@@ -33,21 +39,23 @@ public class InMemoryEventsPublisherTests
}
};
var envelope = builder.Build(fact);
await publisher.PublishFactUpdatedAsync(fact, CancellationToken.None);
Assert.Contains("signals.fact.updated", logger.LastMessage);
Assert.Contains("\"subjectKey\":\"tenant:image@sha256:abc\"", logger.LastMessage);
Assert.Contains("\"callgraphId\":\"cg-123\"", logger.LastMessage);
Assert.Contains("\"reachableCount\":1", logger.LastMessage);
Assert.Contains("\"unreachableCount\":1", logger.LastMessage);
Assert.Contains("\"runtimeFactsCount\":1", logger.LastMessage);
Assert.Contains("\"bucket\":\"runtime\"", logger.LastMessage);
Assert.Contains("\"weight\":0.45", logger.LastMessage);
Assert.Contains("\"factScore\":", logger.LastMessage);
Assert.Contains("\"unknownsCount\":0", logger.LastMessage);
Assert.Contains("\"unknownsPressure\":0", logger.LastMessage);
Assert.Contains("\"stateCount\":2", logger.LastMessage);
Assert.Contains("\"targets\":[\"pkg:pypi/django\",\"pkg:pypi/requests\"]", logger.LastMessage);
Assert.Equal("signals.fact.updated.v1", envelope.Topic);
Assert.Equal("signals.fact.updated@v1", envelope.Version);
Assert.False(string.IsNullOrWhiteSpace(envelope.EventId));
Assert.Equal("tenant-default", envelope.Tenant);
Assert.Equal("tenant:image@sha256:abc", envelope.SubjectKey);
Assert.Equal("cg-123", envelope.CallgraphId);
Assert.Equal(1, envelope.Summary.ReachableCount);
Assert.Equal(1, envelope.Summary.UnreachableCount);
Assert.Equal(1, envelope.Summary.RuntimeFactsCount);
Assert.Equal("runtime", envelope.Summary.Bucket);
Assert.Equal(2, envelope.Summary.StateCount);
Assert.Contains("pkg:pypi/django", envelope.Summary.Targets);
Assert.Contains("pkg:pypi/requests", envelope.Summary.Targets);
Assert.Contains("signals.fact.updated.v1", logger.LastMessage);
}
private sealed class TestLogger<T> : ILogger<T>

View File

@@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using StellaOps.Signals.Models;
using StellaOps.Signals.Services;
using Xunit;
public class ReachabilityFactDigestCalculatorTests
{
[Fact]
public void Compute_ReturnsDeterministicDigest_ForEquivalentFacts()
{
var factA = new ReachabilityFactDocument
{
CallgraphId = "cg-1",
Subject = new ReachabilitySubject { Component = "demo", Version = "1.0.0" },
SubjectKey = "demo|1.0.0",
EntryPoints = new List<string> { "svc.main", "app.main" },
States = new List<ReachabilityStateDocument>
{
new() { Target = "a", Reachable = true, Confidence = 0.9, Bucket = "runtime", Weight = 0.45, Path = new List<string> { "app.main", "a" }, Evidence = new ReachabilityEvidenceDocument { RuntimeHits = new List<string> { "a" } } },
new() { Target = "b", Reachable = false, Confidence = 0.3, Bucket = "unreachable", Weight = 0.1, Path = new List<string> { "app.main", "b" } }
},
RuntimeFacts = new List<RuntimeFactDocument>
{
new() { SymbolId = "a", HitCount = 2 }
},
Metadata = new Dictionary<string, string?>(StringComparer.Ordinal) { { "tenant", "tenant-default" } },
ComputedAt = DateTimeOffset.Parse("2025-12-09T00:00:00Z")
};
var factB = new ReachabilityFactDocument
{
CallgraphId = "cg-1",
Subject = new ReachabilitySubject { Component = "demo", Version = "1.0.0" },
SubjectKey = "demo|1.0.0",
EntryPoints = new List<string> { "app.main", "svc.main" }, // reversed
States = new List<ReachabilityStateDocument>
{
new() { Target = "b", Reachable = false, Confidence = 0.3, Bucket = "unreachable", Weight = 0.1, Path = new List<string> { "app.main", "b" } },
new() { Target = "a", Reachable = true, Confidence = 0.9, Bucket = "runtime", Weight = 0.45, Path = new List<string> { "app.main", "a" }, Evidence = new ReachabilityEvidenceDocument { RuntimeHits = new List<string> { "a" } } }
},
RuntimeFacts = new List<RuntimeFactDocument>
{
new() { SymbolId = "a", HitCount = 2 }
},
Metadata = new Dictionary<string, string?>(StringComparer.Ordinal) { { "tenant", "tenant-default" } },
ComputedAt = DateTimeOffset.Parse("2025-12-09T00:00:00Z")
};
var digestA = ReachabilityFactDigestCalculator.Compute(factA);
var digestB = ReachabilityFactDigestCalculator.Compute(factB);
Assert.StartsWith("sha256:", digestA, StringComparison.Ordinal);
Assert.Equal(digestA, digestB);
}
}

View File

@@ -82,6 +82,8 @@ public class ReachabilityScoringServiceTests
Assert.Contains("target", state.Evidence.RuntimeHits);
Assert.Equal(0.405, fact.Score, 3);
Assert.Equal("1", fact.Metadata?["fact.version"]);
Assert.False(string.IsNullOrWhiteSpace(fact.Metadata?["fact.digest"]));
}
private sealed class InMemoryCallgraphRepository : ICallgraphRepository

View File

@@ -43,38 +43,41 @@ public class ReachabilityUnionIngestionServiceTests
private static MemoryStream BuildSampleUnionZip()
{
var ms = new MemoryStream();
using var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true);
var nodes = archive.CreateEntry("nodes.ndjson");
using (var writer = new StreamWriter(nodes.Open()))
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
writer.WriteLine("{\"symbol_id\":\"sym:dotnet:abc\",\"lang\":\"dotnet\",\"kind\":\"function\",\"display\":\"abc\"}");
}
var edges = archive.CreateEntry("edges.ndjson");
using (var writer = new StreamWriter(edges.Open()))
{
writer.WriteLine("{\"from\":\"sym:dotnet:abc\",\"to\":\"sym:dotnet:def\",\"edge_type\":\"call\",\"source\":{\"origin\":\"static\",\"provenance\":\"il\"}}");
}
// facts_runtime optional left out
var meta = archive.CreateEntry("meta.json");
using (var writer = new StreamWriter(meta.Open()))
{
var files = new[]
var nodes = archive.CreateEntry("nodes.ndjson");
using (var writer = new StreamWriter(nodes.Open()))
{
new { path = "nodes.ndjson", sha256 = ComputeSha("{\"symbol_id\":\"sym:dotnet:abc\",\"lang\":\"dotnet\",\"kind\":\"function\",\"display\":\"abc\"}\n"), records = 1 },
new { path = "edges.ndjson", sha256 = ComputeSha("{\"from\":\"sym:dotnet:abc\",\"to\":\"sym:dotnet:def\",\"edge_type\":\"call\",\"source\":{\"origin\":\"static\",\"provenance\":\"il\"}}\n"), records = 1 }
};
var metaObj = new
writer.NewLine = "\n";
writer.WriteLine("{\"symbol_id\":\"sym:dotnet:abc\",\"lang\":\"dotnet\",\"kind\":\"function\",\"display\":\"abc\"}");
}
var edges = archive.CreateEntry("edges.ndjson");
using (var writer = new StreamWriter(edges.Open()))
{
schema = "reachability-union@0.1",
generated_at = "2025-11-23T00:00:00Z",
produced_by = new { tool = "test", version = "0.0.1" },
files
};
writer.Write(JsonSerializer.Serialize(metaObj));
writer.NewLine = "\n";
writer.WriteLine("{\"from\":\"sym:dotnet:abc\",\"to\":\"sym:dotnet:def\",\"edge_type\":\"call\",\"source\":{\"origin\":\"static\",\"provenance\":\"il\"}}");
}
// facts_runtime optional left out
var meta = archive.CreateEntry("meta.json");
using (var writer = new StreamWriter(meta.Open()))
{
var files = new[]
{
new { path = "nodes.ndjson", sha256 = ComputeSha("{\"symbol_id\":\"sym:dotnet:abc\",\"lang\":\"dotnet\",\"kind\":\"function\",\"display\":\"abc\"}\n"), records = 1 },
new { path = "edges.ndjson", sha256 = ComputeSha("{\"from\":\"sym:dotnet:abc\",\"to\":\"sym:dotnet:def\",\"edge_type\":\"call\",\"source\":{\"origin\":\"static\",\"provenance\":\"il\"}}\n"), records = 1 }
};
var metaObj = new
{
schema = "reachability-union@0.1",
generated_at = "2025-11-23T00:00:00Z",
produced_by = new { tool = "test", version = "0.0.1" },
files
};
writer.Write(JsonSerializer.Serialize(metaObj));
}
}
ms.Position = 0;

View File

@@ -11,6 +11,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />