Add call graph fixtures for various languages and scenarios
Some checks failed
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
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (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.
This commit is contained in:
master
2025-12-16 10:44:24 +02:00
parent 4391f35d8a
commit 5a480a3c2a
223 changed files with 19367 additions and 727 deletions

View File

@@ -0,0 +1,732 @@
using FluentAssertions;
using StellaOps.Signals.Models;
using StellaOps.Signals.Parsing;
using Xunit;
namespace StellaOps.Signals.Reachability.Tests;
/// <summary>
/// Unit tests for CallgraphSchemaMigrator.
/// Verifies schema migration from legacy format to stella.callgraph.v1.
/// </summary>
public class CallgraphSchemaMigratorTests
{
#region EnsureV1 - Schema Version Tests
[Fact]
public void EnsureV1_SetsSchemaToV1_WhenNotSet()
{
// Arrange
var document = new CallgraphDocument
{
Schema = string.Empty
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Schema.Should().Be(CallgraphSchemaVersions.V1);
}
[Fact]
public void EnsureV1_PreservesV1Schema_WhenAlreadySet()
{
// Arrange
var document = new CallgraphDocument
{
Schema = CallgraphSchemaVersions.V1
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Schema.Should().Be(CallgraphSchemaVersions.V1);
}
[Fact]
public void EnsureV1_UpdatesLegacySchema_ToV1()
{
// Arrange
var document = new CallgraphDocument
{
Schema = "legacy-schema-1.0"
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Schema.Should().Be(CallgraphSchemaVersions.V1);
}
#endregion
#region EnsureV1 - Language Parsing Tests
[Theory]
[InlineData("dotnet", CallgraphLanguage.DotNet)]
[InlineData(".net", CallgraphLanguage.DotNet)]
[InlineData("csharp", CallgraphLanguage.DotNet)]
[InlineData("c#", CallgraphLanguage.DotNet)]
[InlineData("java", CallgraphLanguage.Java)]
[InlineData("node", CallgraphLanguage.Node)]
[InlineData("nodejs", CallgraphLanguage.Node)]
[InlineData("javascript", CallgraphLanguage.Node)]
[InlineData("typescript", CallgraphLanguage.Node)]
[InlineData("python", CallgraphLanguage.Python)]
[InlineData("go", CallgraphLanguage.Go)]
[InlineData("golang", CallgraphLanguage.Go)]
[InlineData("rust", CallgraphLanguage.Rust)]
[InlineData("ruby", CallgraphLanguage.Ruby)]
[InlineData("php", CallgraphLanguage.Php)]
[InlineData("binary", CallgraphLanguage.Binary)]
[InlineData("native", CallgraphLanguage.Binary)]
[InlineData("elf", CallgraphLanguage.Binary)]
[InlineData("swift", CallgraphLanguage.Swift)]
[InlineData("kotlin", CallgraphLanguage.Kotlin)]
public void EnsureV1_ParsesLanguageString_ToEnum(string languageString, CallgraphLanguage expected)
{
// Arrange
var document = new CallgraphDocument
{
Language = languageString,
LanguageType = CallgraphLanguage.Unknown
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.LanguageType.Should().Be(expected);
}
[Fact]
public void EnsureV1_PreservesLanguageType_WhenAlreadySet()
{
// Arrange
var document = new CallgraphDocument
{
Language = "java",
LanguageType = CallgraphLanguage.DotNet // Already set to something different
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.LanguageType.Should().Be(CallgraphLanguage.DotNet);
}
#endregion
#region EnsureV1 - Node Visibility Inference Tests
[Fact]
public void EnsureV1_InfersPublicVisibility_ForStandardNames()
{
// Arrange
var document = new CallgraphDocument
{
Nodes = new List<CallgraphNode>
{
new() { Id = "node1", Name = "ProcessOrder", Visibility = SymbolVisibility.Unknown }
}
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Nodes.Should().ContainSingle()
.Which.Visibility.Should().Be(SymbolVisibility.Public);
}
[Fact]
public void EnsureV1_InfersPrivateVisibility_ForUnderscorePrefixed()
{
// Arrange
var document = new CallgraphDocument
{
Nodes = new List<CallgraphNode>
{
new() { Id = "node1", Name = "_privateMethod", Visibility = SymbolVisibility.Unknown }
}
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Nodes.Should().ContainSingle()
.Which.Visibility.Should().Be(SymbolVisibility.Private);
}
[Fact]
public void EnsureV1_InfersPrivateVisibility_ForAngleBracketNames()
{
// Arrange
var document = new CallgraphDocument
{
Nodes = new List<CallgraphNode>
{
new() { Id = "node1", Name = "<Main>$", Visibility = SymbolVisibility.Unknown }
}
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Nodes.Should().ContainSingle()
.Which.Visibility.Should().Be(SymbolVisibility.Private);
}
[Fact]
public void EnsureV1_InfersInternalVisibility_ForInternalNamespace()
{
// Arrange
var document = new CallgraphDocument
{
Nodes = new List<CallgraphNode>
{
new() { Id = "node1", Name = "Helper", Namespace = "MyApp.Internal.Utils", Visibility = SymbolVisibility.Unknown }
}
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Nodes.Should().ContainSingle()
.Which.Visibility.Should().Be(SymbolVisibility.Internal);
}
[Fact]
public void EnsureV1_PreservesVisibility_WhenAlreadySet()
{
// Arrange
var document = new CallgraphDocument
{
Nodes = new List<CallgraphNode>
{
new() { Id = "node1", Name = "_privateMethod", Visibility = SymbolVisibility.Protected }
}
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Nodes.Should().ContainSingle()
.Which.Visibility.Should().Be(SymbolVisibility.Protected);
}
#endregion
#region EnsureV1 - Symbol Key Building Tests
[Fact]
public void EnsureV1_BuildsSymbolKey_FromNamespaceAndName()
{
// Arrange
var document = new CallgraphDocument
{
Nodes = new List<CallgraphNode>
{
new() { Id = "node1", Name = "ProcessOrder", Namespace = "MyApp.Services" }
}
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Nodes.Should().ContainSingle()
.Which.SymbolKey.Should().Be("MyApp.Services.ProcessOrder");
}
[Fact]
public void EnsureV1_BuildsSymbolKey_FromNameOnly_WhenNoNamespace()
{
// Arrange
var document = new CallgraphDocument
{
Nodes = new List<CallgraphNode>
{
new() { Id = "node1", Name = "GlobalMethod" }
}
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Nodes.Should().ContainSingle()
.Which.SymbolKey.Should().Be("GlobalMethod");
}
[Fact]
public void EnsureV1_PreservesSymbolKey_WhenAlreadySet()
{
// Arrange
var document = new CallgraphDocument
{
Nodes = new List<CallgraphNode>
{
new() { Id = "node1", Name = "Method", Namespace = "Ns", SymbolKey = "Custom.Key" }
}
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Nodes.Should().ContainSingle()
.Which.SymbolKey.Should().Be("Custom.Key");
}
#endregion
#region EnsureV1 - Entrypoint Candidate Detection Tests
[Theory]
[InlineData("Main")]
[InlineData("main")]
[InlineData("MAIN")]
public void EnsureV1_DetectsEntrypointCandidate_ForMainMethod(string methodName)
{
// Arrange
var document = new CallgraphDocument
{
Nodes = new List<CallgraphNode>
{
new() { Id = "node1", Name = methodName }
}
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Nodes.Should().ContainSingle()
.Which.IsEntrypointCandidate.Should().BeTrue();
}
[Theory]
[InlineData("OrdersController")]
[InlineData("UserController")]
public void EnsureV1_DetectsEntrypointCandidate_ForControllerNames(string name)
{
// Arrange
var document = new CallgraphDocument
{
Nodes = new List<CallgraphNode>
{
new() { Id = "node1", Name = name }
}
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Nodes.Should().ContainSingle()
.Which.IsEntrypointCandidate.Should().BeTrue();
}
[Theory]
[InlineData("RequestHandler")]
[InlineData("EventHandler")]
public void EnsureV1_DetectsEntrypointCandidate_ForHandlerNames(string name)
{
// Arrange
var document = new CallgraphDocument
{
Nodes = new List<CallgraphNode>
{
new() { Id = "node1", Name = name }
}
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Nodes.Should().ContainSingle()
.Which.IsEntrypointCandidate.Should().BeTrue();
}
[Theory]
[InlineData(".cctor")]
[InlineData("ModuleInitializer")]
public void EnsureV1_DetectsEntrypointCandidate_ForModuleInitializers(string name)
{
// Arrange
var document = new CallgraphDocument
{
Nodes = new List<CallgraphNode>
{
new() { Id = "node1", Name = name }
}
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Nodes.Should().ContainSingle()
.Which.IsEntrypointCandidate.Should().BeTrue();
}
#endregion
#region EnsureV1 - Edge Reason Inference Tests
[Theory]
[InlineData("call", EdgeReason.DirectCall)]
[InlineData("direct", EdgeReason.DirectCall)]
[InlineData("virtual", EdgeReason.VirtualCall)]
[InlineData("callvirt", EdgeReason.VirtualCall)]
[InlineData("newobj", EdgeReason.NewObj)]
[InlineData("new", EdgeReason.NewObj)]
[InlineData("ldftn", EdgeReason.DelegateCreate)]
[InlineData("delegate", EdgeReason.DelegateCreate)]
[InlineData("reflection", EdgeReason.ReflectionString)]
[InlineData("di", EdgeReason.DiBinding)]
[InlineData("injection", EdgeReason.DiBinding)]
[InlineData("async", EdgeReason.AsyncContinuation)]
[InlineData("continuation", EdgeReason.AsyncContinuation)]
[InlineData("event", EdgeReason.EventHandler)]
[InlineData("generic", EdgeReason.GenericInstantiation)]
[InlineData("native", EdgeReason.NativeInterop)]
[InlineData("pinvoke", EdgeReason.NativeInterop)]
[InlineData("ffi", EdgeReason.NativeInterop)]
public void EnsureV1_InfersEdgeReason_FromLegacyType(string legacyType, EdgeReason expected)
{
// Arrange
var document = new CallgraphDocument
{
Edges = new List<CallgraphEdge>
{
new() { SourceId = "n1", TargetId = "n2", Type = legacyType, Reason = EdgeReason.Unknown }
}
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Edges.Should().ContainSingle()
.Which.Reason.Should().Be(expected);
}
[Fact]
public void EnsureV1_InfersRuntimeMinted_ForRuntimeKind()
{
// Arrange
var document = new CallgraphDocument
{
Edges = new List<CallgraphEdge>
{
new() { SourceId = "n1", TargetId = "n2", Type = "unknown", Kind = EdgeKind.Runtime, Reason = EdgeReason.Unknown }
}
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Edges.Should().ContainSingle()
.Which.Reason.Should().Be(EdgeReason.RuntimeMinted);
}
[Fact]
public void EnsureV1_InfersDynamicImport_ForHeuristicKind()
{
// Arrange
var document = new CallgraphDocument
{
Edges = new List<CallgraphEdge>
{
new() { SourceId = "n1", TargetId = "n2", Type = "unknown", Kind = EdgeKind.Heuristic, Reason = EdgeReason.Unknown }
}
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Edges.Should().ContainSingle()
.Which.Reason.Should().Be(EdgeReason.DynamicImport);
}
[Fact]
public void EnsureV1_PreservesEdgeReason_WhenAlreadySet()
{
// Arrange
var document = new CallgraphDocument
{
Edges = new List<CallgraphEdge>
{
new() { SourceId = "n1", TargetId = "n2", Type = "call", Reason = EdgeReason.VirtualCall }
}
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Edges.Should().ContainSingle()
.Which.Reason.Should().Be(EdgeReason.VirtualCall);
}
#endregion
#region EnsureV1 - Entrypoint Inference Tests
[Fact]
public void EnsureV1_InfersEntrypoints_FromEntrypointCandidateNodes()
{
// Arrange
var document = new CallgraphDocument
{
LanguageType = CallgraphLanguage.DotNet,
Nodes = new List<CallgraphNode>
{
new() { Id = "main", Name = "Main", IsEntrypointCandidate = true },
new() { Id = "helper", Name = "Helper", IsEntrypointCandidate = false }
},
Entrypoints = new List<CallgraphEntrypoint>()
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Entrypoints.Should().ContainSingle()
.Which.NodeId.Should().Be("main");
}
[Fact]
public void EnsureV1_InfersEntrypoints_FromExplicitRoots()
{
// Arrange
var document = new CallgraphDocument
{
LanguageType = CallgraphLanguage.DotNet,
Nodes = new List<CallgraphNode>
{
new() { Id = "init", Name = "Initialize" }
},
Roots = new List<CallgraphRoot>
{
new("init", "init", "module_init")
},
Entrypoints = new List<CallgraphEntrypoint>()
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Entrypoints.Should().ContainSingle()
.Which.NodeId.Should().Be("init");
}
[Fact]
public void EnsureV1_PreservesEntrypoints_WhenAlreadyPresent()
{
// Arrange
var existingEntrypoint = new CallgraphEntrypoint
{
NodeId = "existing",
Kind = EntrypointKind.Http,
Route = "/api/test"
};
var document = new CallgraphDocument
{
Nodes = new List<CallgraphNode>
{
new() { Id = "main", Name = "Main", IsEntrypointCandidate = true }
},
Entrypoints = new List<CallgraphEntrypoint> { existingEntrypoint }
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Entrypoints.Should().ContainSingle()
.Which.NodeId.Should().Be("existing");
}
#endregion
#region EnsureV1 - Ordering Tests
[Fact]
public void EnsureV1_SortsNodes_ByIdAlphabetically()
{
// Arrange
var document = new CallgraphDocument
{
Nodes = new List<CallgraphNode>
{
new() { Id = "z-node", Name = "Z" },
new() { Id = "a-node", Name = "A" },
new() { Id = "m-node", Name = "M" }
}
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Nodes.Select(n => n.Id).Should().BeInAscendingOrder();
}
[Fact]
public void EnsureV1_SortsEdges_BySourceThenTargetThenTypeThenOffset()
{
// Arrange
var document = new CallgraphDocument
{
Edges = new List<CallgraphEdge>
{
new() { SourceId = "b", TargetId = "x", Type = "call", Offset = 10 },
new() { SourceId = "a", TargetId = "y", Type = "call", Offset = 5 },
new() { SourceId = "a", TargetId = "x", Type = "virtual", Offset = 0 },
new() { SourceId = "a", TargetId = "x", Type = "call", Offset = 20 }
}
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
var sortedEdges = result.Edges.ToList();
sortedEdges[0].SourceId.Should().Be("a");
sortedEdges[0].TargetId.Should().Be("x");
sortedEdges[0].Type.Should().Be("call");
}
[Fact]
public void EnsureV1_SortsEntrypoints_ByPhaseThenOrder()
{
// Arrange
var document = new CallgraphDocument
{
LanguageType = CallgraphLanguage.DotNet,
Nodes = new List<CallgraphNode>
{
new() { Id = "main", Name = "Main", IsEntrypointCandidate = true },
new() { Id = "init", Name = ".cctor", IsEntrypointCandidate = true }
},
Entrypoints = new List<CallgraphEntrypoint>()
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Entrypoints.Should().HaveCount(2);
// ModuleInit phase should come before Runtime
result.Entrypoints.First().NodeId.Should().Be("init");
}
#endregion
#region EnsureV1 - Null Handling Tests
[Fact]
public void EnsureV1_ThrowsArgumentNullException_ForNullDocument()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => CallgraphSchemaMigrator.EnsureV1(null!));
}
[Fact]
public void EnsureV1_HandlesEmptyNodes_Gracefully()
{
// Arrange
var document = new CallgraphDocument
{
Nodes = new List<CallgraphNode>()
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Nodes.Should().BeEmpty();
}
[Fact]
public void EnsureV1_HandlesEmptyEdges_Gracefully()
{
// Arrange
var document = new CallgraphDocument
{
Edges = new List<CallgraphEdge>()
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Edges.Should().BeEmpty();
}
#endregion
#region Framework Inference Tests
[Fact]
public void EnsureV1_InfersAspNetCoreFramework_ForDotNetController()
{
// Arrange
var document = new CallgraphDocument
{
LanguageType = CallgraphLanguage.DotNet,
Nodes = new List<CallgraphNode>
{
new() { Id = "ctrl", Name = "OrdersController", IsEntrypointCandidate = true }
},
Entrypoints = new List<CallgraphEntrypoint>()
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Entrypoints.Should().ContainSingle()
.Which.Framework.Should().Be(EntrypointFramework.AspNetCore);
}
[Fact]
public void EnsureV1_InfersSpringFramework_ForJavaController()
{
// Arrange
var document = new CallgraphDocument
{
LanguageType = CallgraphLanguage.Java,
Nodes = new List<CallgraphNode>
{
new() { Id = "ctrl", Name = "OrderController", IsEntrypointCandidate = true }
},
Entrypoints = new List<CallgraphEntrypoint>()
};
// Act
var result = CallgraphSchemaMigrator.EnsureV1(document);
// Assert
result.Entrypoints.Should().ContainSingle()
.Which.Framework.Should().Be(EntrypointFramework.Spring);
}
#endregion
}

View File

@@ -0,0 +1,396 @@
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).");
}
}

View File

@@ -251,6 +251,21 @@ public sealed class ReachabilityScoringTests
storage[document.SubjectKey] = document;
return Task.FromResult(document);
}
public Task<IReadOnlyList<ReachabilityFactDocument>> GetExpiredAsync(DateTimeOffset olderThan, int limit, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<ReachabilityFactDocument>>(Array.Empty<ReachabilityFactDocument>());
public Task<bool> DeleteAsync(string subjectKey, CancellationToken cancellationToken)
{
var removed = storage.Remove(subjectKey);
return Task.FromResult(removed);
}
public Task<int> GetRuntimeFactsCountAsync(string subjectKey, CancellationToken cancellationToken) =>
Task.FromResult(0);
public Task TrimRuntimeFactsAsync(string subjectKey, int maxCount, CancellationToken cancellationToken) =>
Task.CompletedTask;
}
private sealed class InMemoryReachabilityCache : IReachabilityCache
@@ -286,6 +301,21 @@ public sealed class ReachabilityScoringTests
public Task<int> CountBySubjectAsync(string subjectKey, CancellationToken cancellationToken) =>
Task.FromResult(0);
public Task BulkUpdateAsync(IEnumerable<UnknownSymbolDocument> unknowns, CancellationToken cancellationToken) =>
Task.CompletedTask;
public Task<IReadOnlyList<string>> GetAllSubjectKeysAsync(CancellationToken cancellationToken) =>
Task.FromResult((IReadOnlyList<string>)Array.Empty<string>());
public Task<IReadOnlyList<UnknownSymbolDocument>> GetDueForRescanAsync(UnknownsBand band, int limit, CancellationToken cancellationToken) =>
Task.FromResult((IReadOnlyList<UnknownSymbolDocument>)Array.Empty<UnknownSymbolDocument>());
public Task<IReadOnlyList<UnknownSymbolDocument>> QueryAsync(UnknownsBand? band, int limit, int offset, CancellationToken cancellationToken) =>
Task.FromResult((IReadOnlyList<UnknownSymbolDocument>)Array.Empty<UnknownSymbolDocument>());
public Task<UnknownSymbolDocument?> GetByIdAsync(string id, CancellationToken cancellationToken) =>
Task.FromResult<UnknownSymbolDocument?>(null);
}
private sealed class NullEventsPublisher : IEventsPublisher

View File

@@ -155,6 +155,18 @@ public sealed class RuntimeFactsIngestionServiceTests
public Task<ReachabilityFactDocument?> GetBySubjectAsync(string subjectKey, CancellationToken cancellationToken) =>
Task.FromResult(LastUpsert is { SubjectKey: not null } doc && doc.SubjectKey == subjectKey ? doc : null);
public Task<IReadOnlyList<ReachabilityFactDocument>> GetExpiredAsync(DateTimeOffset olderThan, int limit, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<ReachabilityFactDocument>>(Array.Empty<ReachabilityFactDocument>());
public Task<bool> DeleteAsync(string subjectKey, CancellationToken cancellationToken) =>
Task.FromResult(true);
public Task<int> GetRuntimeFactsCountAsync(string subjectKey, CancellationToken cancellationToken) =>
Task.FromResult(0);
public Task TrimRuntimeFactsAsync(string subjectKey, int maxCount, CancellationToken cancellationToken) =>
Task.CompletedTask;
}
private sealed class FakeReachabilityCache : IReachabilityCache