using System; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using FluentAssertions; using StellaOps.Scanner.Reachability; using StellaOps.Scanner.Reachability.Lifters; using Xunit; namespace StellaOps.Reachability.FixtureTests; public sealed class ReachabilityLifterTests : IDisposable { private readonly string _tempDir; public ReachabilityLifterTests() { _tempDir = Path.Combine(Path.GetTempPath(), $"lifter-test-{Guid.NewGuid():N}"); Directory.CreateDirectory(_tempDir); } public void Dispose() { try { if (Directory.Exists(_tempDir)) { Directory.Delete(_tempDir, true); } } catch { // Best effort cleanup } } [Fact] public async Task NodeLifter_ExtractsPackageInfo() { // Arrange var packageJson = """ { "name": "my-app", "version": "1.0.0", "main": "index.js", "dependencies": { "express": "^4.18.0", "lodash": "^4.17.0" } } """; await File.WriteAllTextAsync(Path.Combine(_tempDir, "package.json"), packageJson); var lifter = new NodeReachabilityLifter(); var context = new ReachabilityLifterContext { RootPath = _tempDir, AnalysisId = "test-analysis-001" }; var builder = new ReachabilityGraphBuilder(); // Act await lifter.LiftAsync(context, builder, CancellationToken.None); var graph = builder.ToUnionGraph("node"); // Assert graph.Nodes.Should().NotBeEmpty(); graph.Nodes.Should().Contain(n => n.Display == "my-app"); graph.Nodes.Should().Contain(n => n.Display == "express"); graph.Nodes.Should().Contain(n => n.Display == "lodash"); graph.Edges.Should().NotBeEmpty(); graph.Edges.Should().Contain(e => e.EdgeType == "import"); } [Fact] public async Task NodeLifter_ExtractsEntrypoints() { // Arrange var packageJson = """ { "name": "my-cli", "version": "2.0.0", "main": "lib/index.js", "module": "lib/index.mjs", "bin": { "mycli": "bin/cli.js" } } """; await File.WriteAllTextAsync(Path.Combine(_tempDir, "package.json"), packageJson); var lifter = new NodeReachabilityLifter(); var context = new ReachabilityLifterContext { RootPath = _tempDir, AnalysisId = "test-analysis-002" }; var builder = new ReachabilityGraphBuilder(); // Act await lifter.LiftAsync(context, builder, CancellationToken.None); var graph = builder.ToUnionGraph("node"); // Assert graph.Nodes.Should().Contain(n => n.Kind == "entrypoint"); graph.Nodes.Should().Contain(n => n.Kind == "binary"); graph.Edges.Should().Contain(e => e.EdgeType == "loads"); graph.Edges.Should().Contain(e => e.EdgeType == "spawn"); } [Fact] public async Task NodeLifter_ExtractsImportsFromSource() { // Arrange var packageJson = """ { "name": "my-app", "version": "1.0.0" } """; await File.WriteAllTextAsync(Path.Combine(_tempDir, "package.json"), packageJson); var sourceCode = """ import express from 'express'; const lodash = require('lodash'); import('./dynamic-module.js'); """; await File.WriteAllTextAsync(Path.Combine(_tempDir, "index.js"), sourceCode); var lifter = new NodeReachabilityLifter(); var context = new ReachabilityLifterContext { RootPath = _tempDir, AnalysisId = "test-analysis-003" }; var builder = new ReachabilityGraphBuilder(); // Act await lifter.LiftAsync(context, builder, CancellationToken.None); var graph = builder.ToUnionGraph("node"); // Assert graph.Nodes.Should().Contain(n => n.Display == "express"); graph.Nodes.Should().Contain(n => n.Display == "lodash"); graph.Edges.Count(e => e.EdgeType == "import").Should().BeGreaterOrEqualTo(2); } [Fact] public async Task DotNetLifter_ExtractsProjectInfo() { // Arrange var csproj = """ net8.0 MyApp MyCompany.MyApp """; await File.WriteAllTextAsync(Path.Combine(_tempDir, "MyApp.csproj"), csproj); var lifter = new DotNetReachabilityLifter(); var context = new ReachabilityLifterContext { RootPath = _tempDir, AnalysisId = "test-analysis-004" }; var builder = new ReachabilityGraphBuilder(); // Act await lifter.LiftAsync(context, builder, CancellationToken.None); var graph = builder.ToUnionGraph("dotnet"); // Assert graph.Nodes.Should().NotBeEmpty(); graph.Nodes.Should().Contain(n => n.Display == "MyApp"); graph.Nodes.Should().Contain(n => n.Display == "Newtonsoft.Json"); graph.Nodes.Should().Contain(n => n.Display == "Serilog"); graph.Nodes.Should().Contain(n => n.Kind == "namespace" && n.Display == "MyCompany.MyApp"); graph.Edges.Should().NotBeEmpty(); graph.Edges.Count(e => e.EdgeType == "import").Should().BeGreaterOrEqualTo(2); } [Fact] public async Task DotNetLifter_ExtractsProjectReferences() { // Arrange var libDir = Path.Combine(_tempDir, "Lib"); Directory.CreateDirectory(libDir); var libCsproj = """ net8.0 MyLib """; await File.WriteAllTextAsync(Path.Combine(libDir, "MyLib.csproj"), libCsproj); var appCsproj = """ net8.0 MyApp """; await File.WriteAllTextAsync(Path.Combine(_tempDir, "MyApp.csproj"), appCsproj); var lifter = new DotNetReachabilityLifter(); var context = new ReachabilityLifterContext { RootPath = _tempDir, AnalysisId = "test-analysis-005" }; var builder = new ReachabilityGraphBuilder(); // Act await lifter.LiftAsync(context, builder, CancellationToken.None); var graph = builder.ToUnionGraph("dotnet"); // Assert graph.Nodes.Should().Contain(n => n.Display == "MyApp"); graph.Nodes.Should().Contain(n => n.Display == "MyLib"); graph.Edges.Should().Contain(e => e.EdgeType == "import"); } [Fact] public async Task LifterRegistry_CombinesMultipleLanguages() { // Arrange var packageJson = """ { "name": "hybrid-app", "version": "1.0.0", "dependencies": { "express": "^4.0.0" } } """; await File.WriteAllTextAsync(Path.Combine(_tempDir, "package.json"), packageJson); var csproj = """ net8.0 HybridBackend """; await File.WriteAllTextAsync(Path.Combine(_tempDir, "Backend.csproj"), csproj); var registry = new ReachabilityLifterRegistry(); var context = new ReachabilityLifterContext { RootPath = _tempDir, AnalysisId = "test-analysis-006" }; // Act var graph = await registry.LiftAllAsync(context, CancellationToken.None); // Assert registry.Lifters.Should().HaveCountGreaterOrEqualTo(2); graph.Nodes.Should().Contain(n => n.Lang == "node"); graph.Nodes.Should().Contain(n => n.Lang == "dotnet"); } [Fact] public async Task LifterRegistry_SelectsSpecificLanguages() { // Arrange var packageJson = """ { "name": "node-only", "version": "1.0.0" } """; await File.WriteAllTextAsync(Path.Combine(_tempDir, "package.json"), packageJson); var registry = new ReachabilityLifterRegistry(); var context = new ReachabilityLifterContext { RootPath = _tempDir, AnalysisId = "test-analysis-007" }; // Act var graph = await registry.LiftAsync(context, new[] { "node" }, CancellationToken.None); // Assert graph.Nodes.Should().OnlyContain(n => n.Lang == "node"); } [Fact] public async Task LifterRegistry_LiftAndWrite_CreatesOutputFiles() { // Arrange var packageJson = """ { "name": "write-test", "version": "1.0.0", "dependencies": { "lodash": "^4.0.0" } } """; await File.WriteAllTextAsync(Path.Combine(_tempDir, "package.json"), packageJson); var outputDir = Path.Combine(_tempDir, "output"); Directory.CreateDirectory(outputDir); var registry = new ReachabilityLifterRegistry(); var context = new ReachabilityLifterContext { RootPath = _tempDir, AnalysisId = "test-write-001" }; // Act var result = await registry.LiftAndWriteAsync(context, outputDir, CancellationToken.None); // Assert result.Should().NotBeNull(); File.Exists(result.MetaPath).Should().BeTrue(); File.Exists(result.Nodes.Path).Should().BeTrue(); File.Exists(result.Edges.Path).Should().BeTrue(); result.Nodes.RecordCount.Should().BeGreaterThan(0); } [Fact] public void GraphBuilder_AddsRichNodes() { // Arrange var builder = new ReachabilityGraphBuilder(); // Act builder.AddNode( symbolId: "sym:test:abc123", lang: "test", kind: "function", display: "myFunction", sourceFile: "src/main.ts", sourceLine: 42, attributes: new System.Collections.Generic.Dictionary { ["visibility"] = "public", ["async"] = "true" }); var graph = builder.ToUnionGraph("test"); // Assert graph.Nodes.Should().HaveCount(1); var node = graph.Nodes.First(); node.SymbolId.Should().Be("sym:test:abc123"); node.Lang.Should().Be("test"); node.Kind.Should().Be("function"); node.Display.Should().Be("myFunction"); node.Attributes.Should().ContainKey("visibility"); node.Source.Should().NotBeNull(); node.Source!.Evidence.Should().Contain("src/main.ts:42"); } [Fact] public void GraphBuilder_AddsRichEdges() { // Arrange var builder = new ReachabilityGraphBuilder(); // Act builder.AddEdge( from: "sym:test:from", to: "sym:test:to", edgeType: EdgeTypes.Call, confidence: EdgeConfidence.High, origin: "static", provenance: Provenance.Il, evidence: "file:src/main.cs:100"); var graph = builder.ToUnionGraph("test"); // Assert graph.Edges.Should().HaveCount(1); var edge = graph.Edges.First(); edge.From.Should().Be("sym:test:from"); edge.To.Should().Be("sym:test:to"); edge.EdgeType.Should().Be("call"); edge.Confidence.Should().Be("high"); edge.Source.Should().NotBeNull(); edge.Source!.Origin.Should().Be("static"); edge.Source.Provenance.Should().Be("il"); } }