using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; using FluentAssertions; using StellaOps.Signals.Models; using StellaOps.Signals.Parsing; using Xunit; namespace StellaOps.Signals.Reachability.Tests; /// /// Determinism tests for the stella.callgraph.v1 schema. /// These tests validate: /// - Round-trip serialization produces identical output /// - Schema migration from legacy formats /// - Enum values serialize as expected strings /// - Arrays maintain stable ordering /// public sealed class CallgraphSchemaV1DeterminismTests { private static readonly string RepoRoot = LocateRepoRoot(); private static readonly string FixtureRoot = Path.Combine(RepoRoot, "tests", "reachability", "fixtures", "callgraph-schema-v1"); private static readonly JsonSerializerOptions DeterministicOptions = new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } }; public static IEnumerable GoldenFixtures() { if (!Directory.Exists(FixtureRoot)) { yield break; } foreach (var file in Directory.GetFiles(FixtureRoot, "*.json").OrderBy(f => f, StringComparer.Ordinal)) { yield return new object[] { Path.GetFileNameWithoutExtension(file) }; } } [Theory] [MemberData(nameof(GoldenFixtures))] public void GoldenFixture_DeserializesWithoutError(string fixtureName) { var json = File.ReadAllText(Path.Combine(FixtureRoot, $"{fixtureName}.json")); var document = JsonSerializer.Deserialize(json); document.Should().NotBeNull(); document!.Id.Should().NotBeNullOrEmpty(); } [Theory] [MemberData(nameof(GoldenFixtures))] public void GoldenFixture_NodesHaveRequiredFields(string fixtureName) { var json = File.ReadAllText(Path.Combine(FixtureRoot, $"{fixtureName}.json")); var document = JsonSerializer.Deserialize(json)!; foreach (var node in document.Nodes) { node.Id.Should().NotBeNullOrEmpty($"Node in {fixtureName} must have Id"); node.Name.Should().NotBeNullOrEmpty($"Node {node.Id} in {fixtureName} must have Name"); } } [Theory] [MemberData(nameof(GoldenFixtures))] public void GoldenFixture_EdgesReferenceValidNodes(string fixtureName) { var json = File.ReadAllText(Path.Combine(FixtureRoot, $"{fixtureName}.json")); var document = JsonSerializer.Deserialize(json)!; var nodeIds = document.Nodes.Select(n => n.Id).ToHashSet(StringComparer.Ordinal); foreach (var edge in document.Edges) { nodeIds.Should().Contain(edge.SourceId, $"Edge source {edge.SourceId} in {fixtureName} must reference existing node"); nodeIds.Should().Contain(edge.TargetId, $"Edge target {edge.TargetId} in {fixtureName} must reference existing node"); } } [Theory] [MemberData(nameof(GoldenFixtures))] public void GoldenFixture_EntrypointsReferenceValidNodes(string fixtureName) { var json = File.ReadAllText(Path.Combine(FixtureRoot, $"{fixtureName}.json")); var document = JsonSerializer.Deserialize(json)!; var nodeIds = document.Nodes.Select(n => n.Id).ToHashSet(StringComparer.Ordinal); foreach (var entrypoint in document.Entrypoints) { nodeIds.Should().Contain(entrypoint.NodeId, $"Entrypoint {entrypoint.NodeId} in {fixtureName} must reference existing node"); } } [Fact] public void DotNetFixture_HasCorrectLanguageEnum() { var json = File.ReadAllText(Path.Combine(FixtureRoot, "dotnet-aspnetcore-minimal.json")); var document = JsonSerializer.Deserialize(json)!; document.LanguageType.Should().Be(CallgraphLanguage.DotNet); } [Fact] public void JavaFixture_HasCorrectLanguageEnum() { var json = File.ReadAllText(Path.Combine(FixtureRoot, "java-spring-boot.json")); var document = JsonSerializer.Deserialize(json)!; document.LanguageType.Should().Be(CallgraphLanguage.Java); } [Fact] public void NodeFixture_HasCorrectLanguageEnum() { var json = File.ReadAllText(Path.Combine(FixtureRoot, "node-express-api.json")); var document = JsonSerializer.Deserialize(json)!; document.LanguageType.Should().Be(CallgraphLanguage.Node); } [Fact] public void GoFixture_HasCorrectLanguageEnum() { var json = File.ReadAllText(Path.Combine(FixtureRoot, "go-gin-api.json")); var document = JsonSerializer.Deserialize(json)!; document.LanguageType.Should().Be(CallgraphLanguage.Go); } [Fact] public void AllEdgeReasonsFixture_ContainsAllEdgeReasons() { var json = File.ReadAllText(Path.Combine(FixtureRoot, "all-edge-reasons.json")); var document = JsonSerializer.Deserialize(json)!; var expectedReasons = Enum.GetValues(); var actualReasons = document.Edges.Select(e => e.Reason).Distinct().ToHashSet(); foreach (var expected in expectedReasons) { actualReasons.Should().Contain(expected, $"EdgeReason.{expected} should be covered by fixture"); } } [Fact] public void AllEdgeReasonsFixture_ContainsAllEdgeKinds() { var json = File.ReadAllText(Path.Combine(FixtureRoot, "all-edge-reasons.json")); var document = JsonSerializer.Deserialize(json)!; var expectedKinds = Enum.GetValues(); var actualKinds = document.Edges.Select(e => e.Kind).Distinct().ToHashSet(); foreach (var expected in expectedKinds) { actualKinds.Should().Contain(expected, $"EdgeKind.{expected} should be covered by fixture"); } } [Fact] public void AllVisibilityFixture_ContainsAllVisibilityLevels() { var json = File.ReadAllText(Path.Combine(FixtureRoot, "all-visibility-levels.json")); var document = JsonSerializer.Deserialize(json)!; var expectedVisibilities = Enum.GetValues(); var actualVisibilities = document.Nodes.Select(n => n.Visibility).Distinct().ToHashSet(); foreach (var expected in expectedVisibilities) { actualVisibilities.Should().Contain(expected, $"SymbolVisibility.{expected} should be covered by fixture"); } } [Fact] public void LegacyFixture_HasNoSchemaField() { var json = File.ReadAllText(Path.Combine(FixtureRoot, "legacy-no-schema.json")); var document = JsonSerializer.Deserialize(json)!; // Legacy fixture should deserialize but have default schema (v1) due to property default document.Schema.Should().Be(CallgraphSchemaVersions.V1); } [Fact] public void LegacyFixture_MigratesToV1Schema() { var json = File.ReadAllText(Path.Combine(FixtureRoot, "legacy-no-schema.json")); var document = JsonSerializer.Deserialize(json)!; var migrated = CallgraphSchemaMigrator.EnsureV1(document); migrated.Schema.Should().Be(CallgraphSchemaVersions.V1); // Verify that nodes have visibility inferred (may be Unknown for some cases) migrated.Nodes.Should().AllSatisfy(n => Enum.IsDefined(n.Visibility).Should().BeTrue()); // Verify that edges have reason inferred (defaults to DirectCall for legacy 'call' type) migrated.Edges.Should().AllSatisfy(e => Enum.IsDefined(e.Reason).Should().BeTrue()); } [Theory] [InlineData("dotnet-aspnetcore-minimal")] [InlineData("java-spring-boot")] [InlineData("node-express-api")] [InlineData("go-gin-api")] public void V1Fixture_MigrationIsIdempotent(string fixtureName) { var json = File.ReadAllText(Path.Combine(FixtureRoot, $"{fixtureName}.json")); var document = JsonSerializer.Deserialize(json)!; var migrated1 = CallgraphSchemaMigrator.EnsureV1(document); var migrated2 = CallgraphSchemaMigrator.EnsureV1(migrated1); migrated2.Schema.Should().Be(migrated1.Schema); migrated2.Nodes.Should().HaveCount(migrated1.Nodes.Count); migrated2.Edges.Should().HaveCount(migrated1.Edges.Count); migrated2.Entrypoints.Should().HaveCount(migrated1.Entrypoints.Count); } [Fact] public void EdgeReason_SerializesAsCamelCaseString() { var edge = new CallgraphEdge { SourceId = "s1", TargetId = "t1", Type = "call", Reason = EdgeReason.DirectCall }; var json = JsonSerializer.Serialize(edge, DeterministicOptions); json.Should().Contain("\"reason\": \"directCall\""); } [Fact] public void SymbolVisibility_SerializesAsCamelCaseString() { var node = new CallgraphNode { Id = "n1", Name = "Test", Kind = "method", Visibility = SymbolVisibility.Public }; var json = JsonSerializer.Serialize(node, DeterministicOptions); json.Should().Contain("\"visibility\": \"public\""); } [Fact] public void EntrypointKind_SerializesAsCamelCaseString() { var entrypoint = new CallgraphEntrypoint { NodeId = "n1", Kind = EntrypointKind.Http, Framework = EntrypointFramework.AspNetCore }; var json = JsonSerializer.Serialize(entrypoint, DeterministicOptions); json.Should().Contain("\"kind\": \"http\""); json.Should().Contain("\"framework\": \"aspNetCore\""); } [Theory] [MemberData(nameof(GoldenFixtures))] public void GoldenFixture_NodesSortedById(string fixtureName) { var json = File.ReadAllText(Path.Combine(FixtureRoot, $"{fixtureName}.json")); var document = JsonSerializer.Deserialize(json)!; var nodeIds = document.Nodes.Select(n => n.Id).ToList(); var sortedIds = nodeIds.OrderBy(id => id, StringComparer.Ordinal).ToList(); nodeIds.Should().Equal(sortedIds, $"Nodes in {fixtureName} should be sorted by Id for determinism"); } [Theory] [MemberData(nameof(GoldenFixtures))] public void GoldenFixture_EntrypointsSortedByOrder(string fixtureName) { var json = File.ReadAllText(Path.Combine(FixtureRoot, $"{fixtureName}.json")); var document = JsonSerializer.Deserialize(json)!; var orders = document.Entrypoints.Select(e => e.Order).ToList(); var sortedOrders = orders.OrderBy(o => o).ToList(); orders.Should().Equal(sortedOrders, $"Entrypoints in {fixtureName} should be sorted by Order for determinism"); } [Fact] public void DotNetFixture_HasCorrectAspNetCoreEntrypoints() { var json = File.ReadAllText(Path.Combine(FixtureRoot, "dotnet-aspnetcore-minimal.json")); var document = JsonSerializer.Deserialize(json)!; document.Entrypoints.Should().Contain(e => e.Kind == EntrypointKind.Main && e.Framework == EntrypointFramework.AspNetCore); document.Entrypoints.Should().Contain(e => e.Kind == EntrypointKind.Http && e.Route == "/weatherforecast"); } [Fact] public void JavaFixture_HasCorrectSpringEntrypoints() { var json = File.ReadAllText(Path.Combine(FixtureRoot, "java-spring-boot.json")); var document = JsonSerializer.Deserialize(json)!; document.Entrypoints.Should().Contain(e => e.Kind == EntrypointKind.Main && e.Framework == EntrypointFramework.SpringBoot); document.Entrypoints.Should().Contain(e => e.Kind == EntrypointKind.Http && e.Route == "/owners/{ownerId}"); } [Fact] public void GoFixture_HasModuleInitEntrypoint() { var json = File.ReadAllText(Path.Combine(FixtureRoot, "go-gin-api.json")); var document = JsonSerializer.Deserialize(json)!; document.Entrypoints.Should().Contain(e => e.Kind == EntrypointKind.ModuleInit && e.Phase == EntrypointPhase.ModuleInit); } [Fact] public void AllEdgeReasonsFixture_ReflectionEdgeIsUnresolved() { var json = File.ReadAllText(Path.Combine(FixtureRoot, "all-edge-reasons.json")); var document = JsonSerializer.Deserialize(json)!; var reflectionEdge = document.Edges.Single(e => e.Reason == EdgeReason.ReflectionString); reflectionEdge.IsResolved.Should().BeFalse("Reflection edges are typically unresolved"); reflectionEdge.Weight.Should().BeLessThan(1.0, "Reflection edges should have lower confidence"); } [Fact] public void AllEdgeReasonsFixture_DiBindingHasProvenance() { var json = File.ReadAllText(Path.Combine(FixtureRoot, "all-edge-reasons.json")); var document = JsonSerializer.Deserialize(json)!; var diEdge = document.Edges.Single(e => e.Reason == EdgeReason.DiBinding); diEdge.Provenance.Should().NotBeNullOrEmpty("DI binding edges should include provenance"); } [Fact] public void Artifacts_HaveRequiredFields() { var json = File.ReadAllText(Path.Combine(FixtureRoot, "dotnet-aspnetcore-minimal.json")); var document = JsonSerializer.Deserialize(json)!; document.Artifacts.Should().NotBeEmpty(); foreach (var artifact in document.Artifacts) { artifact.ArtifactKey.Should().NotBeNullOrEmpty(); artifact.Kind.Should().NotBeNullOrEmpty(); artifact.Sha256.Should().NotBeNullOrEmpty().And.HaveLength(64); } } [Fact] public void Metadata_HasRequiredToolInfo() { var json = File.ReadAllText(Path.Combine(FixtureRoot, "dotnet-aspnetcore-minimal.json")); var document = JsonSerializer.Deserialize(json)!; document.GraphMetadata.Should().NotBeNull(); document.GraphMetadata!.ToolId.Should().NotBeNullOrEmpty(); document.GraphMetadata!.ToolVersion.Should().NotBeNullOrEmpty(); document.GraphMetadata!.AnalysisTimestamp.Should().NotBe(default); } private static string LocateRepoRoot() { var current = new DirectoryInfo(AppContext.BaseDirectory); while (current != null) { if (File.Exists(Path.Combine(current.FullName, "Directory.Build.props"))) { return current.FullName; } current = current.Parent; } throw new InvalidOperationException("Cannot locate repository root (missing Directory.Build.props)."); } }