up
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user