Some checks failed
Reachability Corpus Validation / validate-corpus (push) Waiting to run
Reachability Corpus Validation / validate-ground-truths (push) Waiting to run
Reachability Corpus Validation / determinism-check (push) Blocked by required conditions
Scanner Analyzers / Discover Analyzers (push) Waiting to run
Scanner Analyzers / Build Analyzers (push) Blocked by required conditions
Scanner Analyzers / Test Language Analyzers (push) Blocked by required conditions
Scanner Analyzers / Validate Test Fixtures (push) Waiting to run
Scanner Analyzers / Verify Deterministic Output (push) Blocked by required conditions
Signals CI & Image / signals-ci (push) Waiting to run
Signals Reachability Scoring & Events / reachability-smoke (push) Waiting to run
Signals Reachability Scoring & Events / sign-and-upload (push) Blocked by required conditions
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
- Introduced `all-edge-reasons.json` to test edge resolution reasons in .NET. - Added `all-visibility-levels.json` to validate method visibility levels in .NET. - Created `dotnet-aspnetcore-minimal.json` for a minimal ASP.NET Core application. - Included `go-gin-api.json` for a Go Gin API application structure. - Added `java-spring-boot.json` for the Spring PetClinic application in Java. - Introduced `legacy-no-schema.json` for legacy application structure without schema. - Created `node-express-api.json` for an Express.js API application structure.
397 lines
15 KiB
C#
397 lines
15 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// </summary>
|
|
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<object[]> 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<CallgraphDocument>(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<CallgraphDocument>(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<CallgraphDocument>(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<CallgraphDocument>(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<CallgraphDocument>(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<CallgraphDocument>(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<CallgraphDocument>(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<CallgraphDocument>(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<CallgraphDocument>(json)!;
|
|
|
|
var expectedReasons = Enum.GetValues<EdgeReason>();
|
|
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<CallgraphDocument>(json)!;
|
|
|
|
var expectedKinds = Enum.GetValues<EdgeKind>();
|
|
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<CallgraphDocument>(json)!;
|
|
|
|
var expectedVisibilities = Enum.GetValues<SymbolVisibility>();
|
|
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<CallgraphDocument>(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<CallgraphDocument>(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<CallgraphDocument>(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<CallgraphDocument>(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<CallgraphDocument>(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<CallgraphDocument>(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<CallgraphDocument>(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<CallgraphDocument>(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<CallgraphDocument>(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<CallgraphDocument>(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<CallgraphDocument>(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<CallgraphDocument>(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).");
|
|
}
|
|
}
|