diff --git a/tests/ClrLens.Tests.IL/AssemblyReaderInvalidInputTests.cs b/tests/ClrLens.Tests.IL/AssemblyReaderInvalidInputTests.cs new file mode 100644 index 0000000..b94de34 --- /dev/null +++ b/tests/ClrLens.Tests.IL/AssemblyReaderInvalidInputTests.cs @@ -0,0 +1,38 @@ +using ClrLens.PE; + +namespace ClrLens.Tests.IL; + +public sealed class AssemblyReaderInvalidInputTests +{ + [Fact] + public async Task ReadAsync_MissingFileReportsFileNotFound() + { + var result = await new AssemblyReader().ReadAsync(Path.Combine(TestSupport.RepoRoot, "does-not-exist.dll")); + + Assert.False(result.IsSuccess); + Assert.Contains(result.Diagnostics, d => d.Code == AssemblyDiagnosticCode.FileNotFound); + } + + [Fact] + public async Task ReadAsync_InvalidMetadataReportsInvalidPeOrMissingMetadata() + { + var result = await new AssemblyReader().ReadAsync(TestSupport.InvalidMetadataFixturePath); + + Assert.False(result.IsSuccess); + Assert.Contains(result.Diagnostics, d => d.Code is AssemblyDiagnosticCode.InvalidPe or AssemblyDiagnosticCode.MissingMetadata or AssemblyDiagnosticCode.MetadataReadFailure); + } + + [Fact] + public async Task ReadAsync_FileTooLargeAndTimeoutAreReported() + { + var reader = new AssemblyReader(); + + var tooLarge = await reader.ReadAsync(TestSupport.FixtureAssemblyPath, new AssemblyIngestionOptions(MaxFileSizeBytes: 1)); + Assert.Contains(tooLarge.Diagnostics, d => d.Code == AssemblyDiagnosticCode.FileTooLarge); + + using var cancelled = new CancellationTokenSource(); + await cancelled.CancelAsync(); + var timeout = await reader.ReadAsync(TestSupport.FixtureAssemblyPath, cancellationToken: cancelled.Token); + Assert.Contains(timeout.Diagnostics, d => d.Code == AssemblyDiagnosticCode.Timeout); + } +} diff --git a/tests/ClrLens.Tests.IL/AssemblyReaderTests.cs b/tests/ClrLens.Tests.IL/AssemblyReaderTests.cs new file mode 100644 index 0000000..ed7bdd8 --- /dev/null +++ b/tests/ClrLens.Tests.IL/AssemblyReaderTests.cs @@ -0,0 +1,21 @@ +using ClrLens.PE; + +namespace ClrLens.Tests.IL; + +public sealed class AssemblyReaderTests +{ + [Fact] + public async Task ReadAsync_ValidAssemblyProducesModelWithMetadata() + { + var result = await new AssemblyReader().ReadAsync(TestSupport.FixtureAssemblyPath); + + Assert.True(result.IsSuccess, string.Join(";", result.Diagnostics.Select(d => d.Message))); + var model = Assert.IsType(result.Model); + Assert.Equal("ClrLens.SampleFixtures", model.Identity.Name); + Assert.Equal(64, model.Identity.Sha256.Length); + Assert.True(model.TypeCount > 0); + Assert.True(model.MethodCount > 0); + Assert.True(model.HasMetadata); + Assert.Equal(model.AssemblyReferences.OrderBy(x => x, StringComparer.Ordinal), model.AssemblyReferences); + } +} diff --git a/tests/ClrLens.Tests.IL/CilDecoderTests.cs b/tests/ClrLens.Tests.IL/CilDecoderTests.cs new file mode 100644 index 0000000..ceb3c2e --- /dev/null +++ b/tests/ClrLens.Tests.IL/CilDecoderTests.cs @@ -0,0 +1,46 @@ +using System.Reflection; +using ClrLens.IL; + +namespace ClrLens.Tests.IL; + +public sealed class CilDecoderTests +{ + [Fact] + public void Decode_FixtureMethodPreservesBranchesAndMetadataTokens() + { + var decoded = TestSupport.DecodeFixtureMethod("ExceptionFlow"); + + Assert.NotEmpty(decoded.Instructions); + Assert.Contains(decoded.Instructions, i => i.BranchTargets.Count > 0); + Assert.Contains(decoded.Instructions, i => i.Operand is int); + } + + [Fact] + public void DecodeInstructions_SwitchOperandPreservesAllTargets() + { + var decodeInstructions = typeof(CilDecoder) + .GetMethod("DecodeInstructions", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new MissingMethodException("CilDecoder.DecodeInstructions"); + var diagnostics = new List(); + + var il = new byte[] + { + 0x45, + 0x02, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x04, 0x00, 0x00, 0x00, + 0x00, + 0x2A, + 0x00, + 0x2A + }; + + var instructions = (List)decodeInstructions.Invoke(null, [il, diagnostics])!; + var first = Assert.Single(instructions, i => i.OpCode.Name == "switch"); + + Assert.Equal(2, first.BranchTargets.Count); + Assert.Equal(13, first.BranchTargets[0]); + Assert.Equal(17, first.BranchTargets[1]); + Assert.Empty(diagnostics); + } +} diff --git a/tests/ClrLens.Tests.IL/CilExceptionRegionTests.cs b/tests/ClrLens.Tests.IL/CilExceptionRegionTests.cs new file mode 100644 index 0000000..cfd015c --- /dev/null +++ b/tests/ClrLens.Tests.IL/CilExceptionRegionTests.cs @@ -0,0 +1,21 @@ +using System.Reflection.Metadata; + +namespace ClrLens.Tests.IL; + +public sealed class CilExceptionRegionTests +{ + [Fact] + public void Decode_PreservesExceptionRegions() + { + var decoded = TestSupport.DecodeFixtureMethod("ExceptionFlow"); + + Assert.NotEmpty(decoded.ExceptionRegions); + Assert.Contains(decoded.ExceptionRegions, r => r.Kind == ExceptionRegionKind.Catch); + Assert.Contains(decoded.ExceptionRegions, r => r.Kind == ExceptionRegionKind.Finally); + Assert.All(decoded.ExceptionRegions, region => + { + Assert.True(region.TryLength > 0); + Assert.True(region.HandlerLength > 0); + }); + } +} diff --git a/tests/ClrLens.Tests.IL/CilStackValidationTests.cs b/tests/ClrLens.Tests.IL/CilStackValidationTests.cs new file mode 100644 index 0000000..0b604d7 --- /dev/null +++ b/tests/ClrLens.Tests.IL/CilStackValidationTests.cs @@ -0,0 +1,58 @@ +using System.Reflection; +using System.Reflection.Emit; +using ClrLens.IL; + +namespace ClrLens.Tests.IL; + +public sealed class CilStackValidationTests +{ + [Fact] + public void ValidateBranchTargets_ReportsInvalidBranchTarget() + { + var method = typeof(CilDecoder).GetMethod("ValidateBranchTargets", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new MissingMethodException("CilDecoder.ValidateBranchTargets"); + + var instructions = new[] + { + new CilInstruction(0, OpCodes.Br_S, 99, 2, [99]), + new CilInstruction(2, OpCodes.Ret, null, 1, []) + }; + var diagnostics = new List(); + + method.Invoke(null, [instructions, instructions.Select(i => i.Offset).ToHashSet(), diagnostics]); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal(CilDiagnosticCode.InvalidBranchTarget, diagnostic.Code); + Assert.Contains("Branch target", diagnostic.Message, StringComparison.Ordinal); + } + + [Fact] + public void ValidateStack_ReportsUnderflowHeightMismatchAndVariableBehavior() + { + var method = typeof(CilDecoder).GetMethod("ValidateStack", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new MissingMethodException("CilDecoder.ValidateStack"); + + var mismatchInstructions = new[] + { + new CilInstruction(0, OpCodes.Ldc_I4_0, null, 1, []), + new CilInstruction(1, OpCodes.Brtrue_S, 5, 2, [5]), + new CilInstruction(3, OpCodes.Ldc_I4_1, null, 1, []), + new CilInstruction(4, OpCodes.Br_S, 5, 2, [5]), + new CilInstruction(5, OpCodes.Pop, null, 1, []), + new CilInstruction(6, OpCodes.Ret, null, 1, []) + }; + var variableInstructions = new[] + { + new CilInstruction(0, OpCodes.Call, 0x06000001, 5, []), + new CilInstruction(5, OpCodes.Ret, null, 1, []) + }; + var diagnostics = new List(); + + method.Invoke(null, [mismatchInstructions, diagnostics]); + method.Invoke(null, [variableInstructions, diagnostics]); + + Assert.Contains(diagnostics, d => d.Code == CilDiagnosticCode.StackUnderflow); + Assert.Contains(diagnostics, d => d.Code == CilDiagnosticCode.StackHeightMismatch); + Assert.Contains(diagnostics, d => d.Code == CilDiagnosticCode.VariableStackBehavior); + } +} diff --git a/tests/ClrLens.Tests.IL/ClrLens.Tests.IL.csproj b/tests/ClrLens.Tests.IL/ClrLens.Tests.IL.csproj index 0b96f45..f7db698 100644 --- a/tests/ClrLens.Tests.IL/ClrLens.Tests.IL.csproj +++ b/tests/ClrLens.Tests.IL/ClrLens.Tests.IL.csproj @@ -3,7 +3,23 @@ Exe ClrLens.Tests.IL ClrLens.Tests.IL + true + $(NoWarn);CA1707;CA1305;CA1861;CS1591 + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + diff --git a/tests/ClrLens.Tests.IL/FixtureManifestTests.cs b/tests/ClrLens.Tests.IL/FixtureManifestTests.cs new file mode 100644 index 0000000..4c72723 --- /dev/null +++ b/tests/ClrLens.Tests.IL/FixtureManifestTests.cs @@ -0,0 +1,81 @@ +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Security.Cryptography; +using System.Text.Json; +using ClrLens.Analysis; +using ClrLens.IR; +using ClrLens.PE; +using ClrLens.SampleFixtures; + +namespace ClrLens.Tests.IL; + +public sealed class FixtureManifestTests +{ + [Fact] + public void Manifest_ExistsAndMatchesFixtureShape() + { + Assert.True(File.Exists(TestSupport.ManifestPath)); + + using var document = JsonDocument.Parse(File.ReadAllText(TestSupport.ManifestPath)); + var assemblyNode = document.RootElement.GetProperty("assemblies").EnumerateArray().First(e => e.GetProperty("project").GetString() == "ClrLens.SampleFixtures"); + + Assert.Equal("0.1", document.RootElement.GetProperty("schemaVersion").GetString()); + Assert.Equal(64, assemblyNode.GetProperty("expectedHash").GetString()!.Length); + + using var stream = File.OpenRead(TestSupport.FixtureAssemblyPath); + using var peReader = new PEReader(stream); + var metadata = peReader.GetMetadataReader(); + var typeNames = metadata.TypeDefinitions + .Select(h => metadata.GetTypeDefinition(h)) + .Select(t => $"{metadata.GetString(t.Namespace)}.{metadata.GetString(t.Name)}") + .ToHashSet(StringComparer.Ordinal); + var methodNames = metadata.MethodDefinitions + .Select(h => metadata.GetMethodDefinition(h)) + .Select(m => metadata.GetString(m.Name)) + .ToHashSet(StringComparer.Ordinal); + + foreach (var requiredType in assemblyNode.GetProperty("mustContainTypes").EnumerateArray().Select(e => e.GetString()!)) + { + Assert.Contains(requiredType, typeNames); + } + + foreach (var requiredMethod in assemblyNode.GetProperty("mustContainMethods").EnumerateArray().Select(e => e.GetString()!)) + { + Assert.Contains(requiredMethod, methodNames); + } + + Assert.True(File.Exists(TestSupport.InvalidMetadataFixturePath)); + } + + [Fact] + public async Task FixtureHash_IsDeterministic_AndStaticInspectionDoesNotExecuteFixtureMethods() + { + Assert.Equal(0, RetentionPatterns.CacheCount); + + var bytes = await File.ReadAllBytesAsync(TestSupport.FixtureAssemblyPath); + var hashA = Convert.ToHexString(SHA256.HashData(bytes)); + var hashB = Convert.ToHexString(SHA256.HashData(bytes)); + + Assert.Equal(hashA, hashB); + + var ingestion = await new AssemblyReader().ReadAsync(TestSupport.FixtureAssemblyPath); + Assert.True(ingestion.IsSuccess); + + using var stream = File.OpenRead(TestSupport.FixtureAssemblyPath); + using var peReader = new PEReader(stream); + var metadata = peReader.GetMetadataReader(); + var bodies = TestSupport.DecodeAllMethods(peReader, metadata); + + var irBuilder = new CilToIrBuilder(); + var cfgBuilder = new ControlFlowGraphBuilder(); + foreach (var body in bodies.Values) + { + _ = irBuilder.Build(body); + _ = cfgBuilder.Build(body); + } + + _ = CallGraphBuilder.Build(metadata, bodies); + + Assert.Equal(0, RetentionPatterns.CacheCount); + } +} diff --git a/tests/ClrLens.Tests.IL/GlobalUsings.cs b/tests/ClrLens.Tests.IL/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/tests/ClrLens.Tests.IL/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/ClrLens.Tests.IL/HarnessIntegrationTests.cs b/tests/ClrLens.Tests.IL/HarnessIntegrationTests.cs new file mode 100644 index 0000000..c06f27a --- /dev/null +++ b/tests/ClrLens.Tests.IL/HarnessIntegrationTests.cs @@ -0,0 +1,12 @@ +namespace ClrLens.Tests.IL; + +public sealed class HarnessIntegrationTests +{ + [Fact] + public async Task RunAsync_Passes() + { + var result = await FixtureHarness.RunAsync(); + + Assert.Contains("PASS", result, StringComparison.Ordinal); + } +} diff --git a/tests/ClrLens.Tests.IL/Program.cs b/tests/ClrLens.Tests.IL/Program.cs index af946f5..d83eb80 100644 --- a/tests/ClrLens.Tests.IL/Program.cs +++ b/tests/ClrLens.Tests.IL/Program.cs @@ -1,99 +1,107 @@ -using ClrLens.PE; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using ClrLens.Analysis; using ClrLens.IL; using ClrLens.IR; -using ClrLens.Analysis; +using ClrLens.PE; using ClrLens.SampleFixtures; -using System.Reflection.Metadata; -using System.Reflection.PortableExecutable; -var fixtureAssemblyPath = typeof(FixtureMarker).Assembly.Location; -var reader = new AssemblyReader(); -var valid = await reader.ReadAsync(fixtureAssemblyPath); -Assert(valid.IsSuccess, string.Join("; ", valid.Diagnostics.Select(d => d.Message))); -var model = valid.Model ?? throw new InvalidOperationException("Valid fixture did not produce an AssemblyModel."); -Assert(model.Identity.Sha256.Length == 64, "Assembly hash was not captured."); -Assert(model.TypeCount >= 6, "Expected fixture types were not discovered."); -Assert(model.MethodCount >= 18, "Expected fixture methods were not discovered."); -Assert(model.HasMetadata, "Managed metadata should be available for offset-based analysis."); -Assert(model.AssemblyReferences.Count > 0, "Assembly references were not captured."); +namespace ClrLens.Tests.IL; -var irNodes = 0; -var allocationNodes = 0; - var irExceptionRegions = 0; - var distinctValues = 0; - var cfgCount = 0; - var loopCount = 0; - var exceptionalEdgeCount = 0; - var callSiteCount = 0; - var externalCallCount = 0; - var callSccCount = 0; -using (var stream = File.OpenRead(fixtureAssemblyPath)) -using (var peReader = new PEReader(stream)) +public static class FixtureHarness { - var metadata = peReader.GetMetadataReader(); - var decoder = new CilDecoder(); - var irBuilder = new CilToIrBuilder(); - var decodedMethods = 0; - var exceptionRegions = 0; - var bodies = new Dictionary(); - foreach (var handle in metadata.MethodDefinitions) + public static async Task RunAsync() { - var method = metadata.GetMethodDefinition(handle); - if (method.RelativeVirtualAddress == 0) continue; - var decoded = decoder.Decode(peReader, method); - bodies[handle] = decoded; - Assert(!decoded.Diagnostics.Any(d => d.Code == CilDiagnosticCode.InvalidBranchTarget), $"Invalid branch in method token {handle.GetHashCode()}."); - var ir = irBuilder.Build(decoded); - var cfg = new ControlFlowGraphBuilder().Build(decoded); - Assert(ir.Nodes.All(node => node.Offset >= 0), "IR node provenance offset was invalid."); - Assert(ir.Nodes.Select(node => node.Offset).Distinct().Count() <= ir.Nodes.Count, "IR node provenance was not preserved."); - Assert(ir.ExceptionRegions.Count == decoded.ExceptionRegions.Count, "IR exception regions were not preserved."); - Assert(cfg.Blocks.Count > 0, "CFG did not create basic blocks."); - Assert(cfg.Edges.All(edge => cfg.Blocks.Any(block => block.Id == edge.From) && cfg.Blocks.Any(block => block.Id == edge.To)), "CFG contains an invalid edge."); - irNodes += ir.Nodes.Count; - allocationNodes += ir.Nodes.Count(node => node.CanAllocate); - distinctValues += ir.Nodes.SelectMany(node => node.Result is { } result ? [result] : Array.Empty()).Distinct().Count(); - irExceptionRegions += ir.ExceptionRegions.Count; - cfgCount++; - loopCount += cfg.NaturalLoops.Count; - exceptionalEdgeCount += cfg.ExceptionalEdges.Count(); - decodedMethods++; - exceptionRegions += decoded.ExceptionRegions.Count; - } + var fixtureAssemblyPath = typeof(FixtureMarker).Assembly.Location; + var reader = new AssemblyReader(); + var valid = await reader.ReadAsync(fixtureAssemblyPath); + Ensure(valid.IsSuccess, string.Join("; ", valid.Diagnostics.Select(d => d.Message))); + var model = valid.Model ?? throw new InvalidOperationException("Valid fixture did not produce an AssemblyModel."); + Ensure(model.Identity.Sha256.Length == 64, "Assembly hash was not captured."); + Ensure(model.TypeCount >= 6, "Expected fixture types were not discovered."); + Ensure(model.MethodCount >= 18, "Expected fixture methods were not discovered."); + Ensure(model.HasMetadata, "Managed metadata should be available for offset-based analysis."); + Ensure(model.AssemblyReferences.Count > 0, "Assembly references were not captured."); - Assert(decodedMethods > 0, "No method bodies were decoded."); - Assert(exceptionRegions > 0, "Exception handling regions were not preserved."); - Assert(irNodes > 0, "No IR nodes were created."); - Assert(allocationNodes > 0, "Allocation instructions were not represented in IR."); - Assert(distinctValues > 0, "SSA-like values were not created."); - Assert(irExceptionRegions > 0, "IR did not retain exception regions."); - Assert(cfgCount > 0, "No CFGs were created."); - Assert(loopCount > 0, "Natural loops were not detected."); - Assert(exceptionalEdgeCount > 0, "Exceptional CFG edges were not created."); + var irNodes = 0; + var allocationNodes = 0; + var irExceptionRegions = 0; + var distinctValues = 0; + var cfgCount = 0; + var loopCount = 0; + var exceptionalEdgeCount = 0; + var callSiteCount = 0; + var externalCallCount = 0; + var callSccCount = 0; + using (var stream = File.OpenRead(fixtureAssemblyPath)) + using (var peReader = new PEReader(stream)) + { + var metadata = peReader.GetMetadataReader(); + var decoder = new CilDecoder(); + var irBuilder = new CilToIrBuilder(); + var decodedMethods = 0; + var exceptionRegions = 0; + var bodies = new Dictionary(); + foreach (var handle in metadata.MethodDefinitions) + { + var method = metadata.GetMethodDefinition(handle); + if (method.RelativeVirtualAddress == 0) continue; + var decoded = decoder.Decode(peReader, method); + bodies[handle] = decoded; + Ensure(!decoded.Diagnostics.Any(d => d.Code == CilDiagnosticCode.InvalidBranchTarget), $"Invalid branch in method token {handle.GetHashCode()}."); + var ir = irBuilder.Build(decoded); + var cfg = new ControlFlowGraphBuilder().Build(decoded); + Ensure(ir.Nodes.All(node => node.Offset >= 0), "IR node provenance offset was invalid."); + Ensure(ir.Nodes.Select(node => node.Offset).Distinct().Count() <= ir.Nodes.Count, "IR node provenance was not preserved."); + Ensure(ir.ExceptionRegions.Count == decoded.ExceptionRegions.Count, "IR exception regions were not preserved."); + Ensure(cfg.Blocks.Count > 0, "CFG did not create basic blocks."); + Ensure(cfg.Edges.All(edge => cfg.Blocks.Any(block => block.Id == edge.From) && cfg.Blocks.Any(block => block.Id == edge.To)), "CFG contains an invalid edge."); + irNodes += ir.Nodes.Count; + allocationNodes += ir.Nodes.Count(node => node.CanAllocate); + distinctValues += ir.Nodes.SelectMany(node => node.Result is { } result ? [result] : Array.Empty()).Distinct().Count(); + irExceptionRegions += ir.ExceptionRegions.Count; + cfgCount++; + loopCount += cfg.NaturalLoops.Count; + exceptionalEdgeCount += cfg.ExceptionalEdges.Count(); + decodedMethods++; + exceptionRegions += decoded.ExceptionRegions.Count; + } - var callGraph = CallGraphBuilder.Build(metadata, bodies); - Assert(callGraph.CallSites.Count > 0, "Call graph did not record call sites."); - Assert(callGraph.CallSites.All(site => site.IlOffset >= 0), "Call site offset provenance was invalid."); - Assert(callGraph.ExternalCalls.Any(), "External calls were not preserved."); - Assert(callGraph.ExternalCalls.All(site => site.Effects != UnknownEffect.None), "External calls have no conservative effects."); - Assert(callGraph.Edges.Count == bodies.Count, "Call graph did not create a node for every method body."); - callSiteCount = callGraph.CallSites.Count; - externalCallCount = callGraph.ExternalCalls.Count(); - callSccCount = callGraph.StronglyConnectedComponents.Count; -} + Ensure(decodedMethods > 0, "No method bodies were decoded."); + Ensure(exceptionRegions > 0, "Exception handling regions were not preserved."); + Ensure(irNodes > 0, "No IR nodes were created."); + Ensure(allocationNodes > 0, "Allocation instructions were not represented in IR."); + Ensure(distinctValues > 0, "SSA-like values were not created."); + Ensure(irExceptionRegions > 0, "IR did not retain exception regions."); + Ensure(cfgCount > 0, "No CFGs were created."); + Ensure(loopCount > 0, "Natural loops were not detected."); + Ensure(exceptionalEdgeCount > 0, "Exceptional CFG edges were not created."); -var invalidMetadataPath = Path.Combine(Directory.GetCurrentDirectory(), "tests", "fixtures", "unsupported", "invalid-metadata.bin"); -Assert(File.Exists(invalidMetadataPath), "Invalid metadata fixture is missing."); -var invalid = await reader.ReadAsync(invalidMetadataPath); -Assert(!invalid.IsSuccess, "Invalid metadata fixture was accepted."); -Assert(invalid.Diagnostics.Any(d => d.Code is AssemblyDiagnosticCode.InvalidPe or AssemblyDiagnosticCode.MissingMetadata), "Invalid input diagnostic was not structured."); + var callGraph = CallGraphBuilder.Build(metadata, bodies); + Ensure(callGraph.CallSites.Count > 0, "Call graph did not record call sites."); + Ensure(callGraph.CallSites.All(site => site.IlOffset >= 0), "Call site offset provenance was invalid."); + Ensure(callGraph.ExternalCalls.Any(), "External calls were not preserved."); + Ensure(callGraph.ExternalCalls.All(site => site.Effects != UnknownEffect.None), "External calls have no conservative effects."); + Ensure(callGraph.Edges.Count == bodies.Count, "Call graph did not create a node for every method body."); + callSiteCount = callGraph.CallSites.Count; + externalCallCount = callGraph.ExternalCalls.Count(); + callSccCount = callGraph.StronglyConnectedComponents.Count; + } -var missing = await reader.ReadAsync(Path.Combine(Directory.GetCurrentDirectory(), "does-not-exist.dll")); -Assert(!missing.IsSuccess && missing.Diagnostics.Any(d => d.Code == AssemblyDiagnosticCode.FileNotFound), "Missing input diagnostic was not structured."); + var invalidMetadataPath = Path.Combine(TestSupport.RepoRoot, "tests", "fixtures", "unsupported", "invalid-metadata.bin"); + Ensure(File.Exists(invalidMetadataPath), "Invalid metadata fixture is missing."); + var invalid = await reader.ReadAsync(invalidMetadataPath); + Ensure(!invalid.IsSuccess, "Invalid metadata fixture was accepted."); + Ensure(invalid.Diagnostics.Any(d => d.Code is AssemblyDiagnosticCode.InvalidPe or AssemblyDiagnosticCode.MissingMetadata), "Invalid input diagnostic was not structured."); -Console.WriteLine($"T08 call graph harness: PASS ({model.Identity.Name}, CFGs={cfgCount}, loops={loopCount}, exceptionalEdges={exceptionalEdgeCount}, callSites={callSiteCount}, externalCalls={externalCallCount}, callSccs={callSccCount})"); + var missing = await reader.ReadAsync(Path.Combine(TestSupport.RepoRoot, "does-not-exist.dll")); + Ensure(!missing.IsSuccess && missing.Diagnostics.Any(d => d.Code == AssemblyDiagnosticCode.FileNotFound), "Missing input diagnostic was not structured."); -static void Assert(bool condition, string message) -{ - if (!condition) throw new InvalidOperationException(message); + return $"T08 call graph harness: PASS ({model.Identity.Name}, CFGs={cfgCount}, loops={loopCount}, exceptionalEdges={exceptionalEdgeCount}, callSites={callSiteCount}, externalCalls={externalCallCount}, callSccs={callSccCount})"; + } + + private static void Ensure(bool condition, string message) + { + if (!condition) throw new InvalidOperationException(message); + } } diff --git a/tests/ClrLens.Tests.IL/TestSupport.cs b/tests/ClrLens.Tests.IL/TestSupport.cs new file mode 100644 index 0000000..71b68f2 --- /dev/null +++ b/tests/ClrLens.Tests.IL/TestSupport.cs @@ -0,0 +1,76 @@ +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using ClrLens.IL; +using ClrLens.SampleFixtures; + +namespace ClrLens.Tests.IL; + +internal static class TestSupport +{ + public static string RepoRoot + { + get + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "ClrLens.sln"))) + { + return current.FullName; + } + + current = current.Parent; + } + + throw new DirectoryNotFoundException("Could not locate repository root."); + } + } + + public static string FixtureAssemblyPath => typeof(FixtureMarker).Assembly.Location; + + public static string ManifestPath => Path.Combine(RepoRoot, "tests", "fixtures", "fixture-manifest.json"); + + public static string InvalidMetadataFixturePath => Path.Combine(RepoRoot, "tests", "fixtures", "unsupported", "invalid-metadata.bin"); + + public static DecodedMethodBody DecodeFixtureMethod(string methodName) + { + using var stream = File.OpenRead(FixtureAssemblyPath); + using var peReader = new PEReader(stream); + var metadata = peReader.GetMetadataReader(); + var decoder = new CilDecoder(); + + foreach (var handle in metadata.MethodDefinitions) + { + var definition = metadata.GetMethodDefinition(handle); + if (definition.RelativeVirtualAddress == 0) + { + continue; + } + + if (metadata.GetString(definition.Name) == methodName) + { + return decoder.Decode(peReader, definition); + } + } + + throw new InvalidOperationException($"Method '{methodName}' not found in fixture assembly."); + } + + public static IReadOnlyDictionary DecodeAllMethods(PEReader peReader, MetadataReader metadata) + { + var decoder = new CilDecoder(); + var bodies = new Dictionary(); + foreach (var handle in metadata.MethodDefinitions) + { + var method = metadata.GetMethodDefinition(handle); + if (method.RelativeVirtualAddress == 0) + { + continue; + } + + bodies[handle] = decoder.Decode(peReader, method); + } + + return bodies; + } +} diff --git a/tests/ClrLens.Tests.Optimization/ClrLens.Tests.Optimization.csproj b/tests/ClrLens.Tests.Optimization/ClrLens.Tests.Optimization.csproj index 02bb76b..802e8fc 100644 --- a/tests/ClrLens.Tests.Optimization/ClrLens.Tests.Optimization.csproj +++ b/tests/ClrLens.Tests.Optimization/ClrLens.Tests.Optimization.csproj @@ -1,4 +1,26 @@ - ClrLens.Tests.OptimizationClrLens.Tests.Optimization - + + ClrLens.Tests.Optimization + ClrLens.Tests.Optimization + true + $(NoWarn);CA1707;CA1305;CA1861;CS1591 + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + diff --git a/tests/ClrLens.Tests.Optimization/GlobalUsings.cs b/tests/ClrLens.Tests.Optimization/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/tests/ClrLens.Tests.Optimization/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/ClrLens.Tests.Regression/ClrLens.Tests.Regression.csproj b/tests/ClrLens.Tests.Regression/ClrLens.Tests.Regression.csproj index 8aaac6e..7897656 100644 --- a/tests/ClrLens.Tests.Regression/ClrLens.Tests.Regression.csproj +++ b/tests/ClrLens.Tests.Regression/ClrLens.Tests.Regression.csproj @@ -1,4 +1,32 @@ - ClrLens.Tests.RegressionClrLens.Tests.Regression - + + ClrLens.Tests.Regression + ClrLens.Tests.Regression + true + $(NoWarn);CA1707;CA1305;CA1861;CS1591 + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + diff --git a/tests/ClrLens.Tests.Regression/FixtureRegressionTests.cs b/tests/ClrLens.Tests.Regression/FixtureRegressionTests.cs new file mode 100644 index 0000000..4f0a1b5 --- /dev/null +++ b/tests/ClrLens.Tests.Regression/FixtureRegressionTests.cs @@ -0,0 +1,47 @@ +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using ClrLens.Analysis; +using ClrLens.IL; +using ClrLens.IR; +using ClrLens.PE; +using ClrLens.SampleFixtures; + +namespace ClrLens.Tests.Regression; + +public sealed class FixtureRegressionTests +{ + [Fact] + public async Task HarnessFlow_AnalyzesFixtureWithoutExecutingFixtureMethods() + { + Assert.Equal(0, RetentionPatterns.CacheCount); + + var ingestion = await new AssemblyReader().ReadAsync(TestSupport.FixtureAssemblyPath); + Assert.True(ingestion.IsSuccess, string.Join(";", ingestion.Diagnostics.Select(d => d.Message))); + + using var stream = File.OpenRead(TestSupport.FixtureAssemblyPath); + using var peReader = new PEReader(stream); + var metadata = peReader.GetMetadataReader(); + var decoder = new CilDecoder(); + var irBuilder = new CilToIrBuilder(); + var cfgBuilder = new ControlFlowGraphBuilder(); + var bodies = new Dictionary(); + + foreach (var handle in metadata.MethodDefinitions) + { + var method = metadata.GetMethodDefinition(handle); + if (method.RelativeVirtualAddress == 0) + { + continue; + } + + var decoded = decoder.Decode(peReader, method); + bodies[handle] = decoded; + _ = irBuilder.Build(decoded); + _ = cfgBuilder.Build(decoded); + } + + var graph = CallGraphBuilder.Build(metadata, bodies); + Assert.NotEmpty(graph.CallSites); + Assert.Equal(0, RetentionPatterns.CacheCount); + } +} diff --git a/tests/ClrLens.Tests.Regression/GlobalUsings.cs b/tests/ClrLens.Tests.Regression/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/tests/ClrLens.Tests.Regression/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/ClrLens.Tests.Regression/ReportCompatibilityTests.cs b/tests/ClrLens.Tests.Regression/ReportCompatibilityTests.cs new file mode 100644 index 0000000..658baf6 --- /dev/null +++ b/tests/ClrLens.Tests.Regression/ReportCompatibilityTests.cs @@ -0,0 +1,32 @@ +using System.Text.Json; +using ClrLens.Core.Serialization; +using ClrLens.Core.Validation; + +namespace ClrLens.Tests.Regression; + +public sealed class ReportCompatibilityTests +{ + [Fact] + public void Serializer_OutputContainsStableContractFields() + { + var report = TestSupport.CreateReport(); + + var json = AnalysisReportSerializer.Serialize(report); + using var document = JsonDocument.Parse(json); + + Assert.Equal("0.1", document.RootElement.GetProperty("schemaVersion").GetString()); + Assert.Equal("regression", document.RootElement.GetProperty("assembly").GetProperty("name").GetString()); + Assert.Equal("externalContract", document.RootElement.GetProperty("contracts")[0].GetProperty("evidence").GetString()); + } + + [Fact] + public void Serializer_RoundTripRemainsValidForCurrentContractValidator() + { + var report = TestSupport.CreateReport(); + + var roundTrip = AnalysisReportSerializer.Deserialize(AnalysisReportSerializer.Serialize(report)); + var errors = DomainContractValidator.Validate(roundTrip, DateOnly.Parse("2026-01-01")); + + Assert.Empty(errors); + } +} diff --git a/tests/ClrLens.Tests.Regression/TestSupport.cs b/tests/ClrLens.Tests.Regression/TestSupport.cs new file mode 100644 index 0000000..a78f3d6 --- /dev/null +++ b/tests/ClrLens.Tests.Regression/TestSupport.cs @@ -0,0 +1,30 @@ +using ClrLens.Core.Domain; +using ClrLens.SampleFixtures; + +namespace ClrLens.Tests.Regression; + +internal static class TestSupport +{ + public static string FixtureAssemblyPath => typeof(FixtureMarker).Assembly.Location; + + public static AnalysisReport CreateReport() => new( + "0.1", + new AssemblyIdentity("regression", new string('C', 64), "net8.0", "Amd64", "1.0.0.0"), + new Provenance("1.0.0", "runtime", DateTimeOffset.Parse("2026-01-01T00:00:00Z"), new string('D', 64)), + new AnalysisScenarioConfiguration(AnalysisScenario.Baseline, AnalysisCostMode.Typical), + new AnalysisSummary(10, 50, 2, 1, 0.5, 0.3, 1), + [ + new Finding("F-1", "cpu", Severity.Medium, 0.9, + new AnalysisLocation("Type", "Method", 123, 0, 10, ["B0"], ["Type.Method"]), + EvidenceKind.Inferred, + RemediationType.Review, + "Title", + "Description", + "Why", + new CostModel("O(n)", "linear", "ops", new Dictionary { ["n"] = "size" }), + [], + ["Do X"]) + ], + [new BoundContract("S", 10, "bytes", "spec", "scope", ContractTrust.External, DateOnly.Parse("2099-01-01"), "owner", "ticket", EvidenceKind.ExternalContract)], + [new Suppression("SUP-1", "F-1", "reason", "owner", "scope", DateOnly.Parse("2099-01-01"), "ticket")]); +} diff --git a/tests/ClrLens.Tests.Unit/CallGraphTests.cs b/tests/ClrLens.Tests.Unit/CallGraphTests.cs new file mode 100644 index 0000000..d4f5082 --- /dev/null +++ b/tests/ClrLens.Tests.Unit/CallGraphTests.cs @@ -0,0 +1,55 @@ +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Reflection.PortableExecutable; +using ClrLens.Analysis; +using ClrLens.IL; + +namespace ClrLens.Tests.Unit; + +public sealed class CallGraphTests +{ + [Fact] + public void Build_PreservesCallSitesResolutionsAndGraphNodes() + { + using var stream = File.OpenRead(TestSupport.FixtureAssemblyPath); + using var peReader = new PEReader(stream); + var metadata = peReader.GetMetadataReader(); + var decoder = new CilDecoder(); + var bodies = new Dictionary(); + + foreach (var handle in metadata.MethodDefinitions) + { + var method = metadata.GetMethodDefinition(handle); + if (method.RelativeVirtualAddress == 0) + { + continue; + } + + bodies[handle] = decoder.Decode(peReader, method); + } + + var graph = CallGraphBuilder.Build(metadata, bodies); + + Assert.NotEmpty(graph.CallSites); + Assert.All(graph.CallSites, site => + { + Assert.True(site.IlOffset >= 0); + Assert.NotEqual(CallResolutionKind.Unknown, site.Resolution); + }); + Assert.NotEmpty(graph.InternalCalls); + Assert.NotEmpty(graph.ExternalCalls); + Assert.All(graph.ExternalCalls, site => Assert.NotEqual(UnknownEffect.None, site.Effects)); + Assert.Equal(bodies.Count, graph.Edges.Count); + + var methodSpecTokens = bodies.Values + .SelectMany(body => body.Instructions) + .Where(i => i.Operand is int) + .Select(i => MetadataTokens.EntityHandle((int)i.Operand!)) + .Count(handle => handle.Kind == HandleKind.MethodSpecification); + + if (methodSpecTokens > 0) + { + Assert.True(graph.CallSites.Count(site => site.Resolution == CallResolutionKind.ExternalMethodSpecification) >= methodSpecTokens); + } + } +} diff --git a/tests/ClrLens.Tests.Unit/CilToIrBuilderTests.cs b/tests/ClrLens.Tests.Unit/CilToIrBuilderTests.cs new file mode 100644 index 0000000..c789f07 --- /dev/null +++ b/tests/ClrLens.Tests.Unit/CilToIrBuilderTests.cs @@ -0,0 +1,64 @@ +using System.Reflection.Emit; +using ClrLens.IL; +using ClrLens.IR; + +namespace ClrLens.Tests.Unit; + +public sealed class CilToIrBuilderTests +{ + [Fact] + public void Build_MapsCoreOperationsAndPreservesOffsets() + { + var body = new DecodedMethodBody( + 1, + 8, + true, + [ + new CilInstruction(0, OpCodes.Ldc_I4_1, null, 1, []), + new CilInstruction(1, OpCodes.Stloc_0, null, 1, []), + new CilInstruction(2, OpCodes.Ldloc_0, null, 1, []), + new CilInstruction(3, OpCodes.Ldc_I4_2, null, 1, []), + new CilInstruction(4, OpCodes.Add, null, 1, []), + new CilInstruction(5, OpCodes.Ret, null, 1, []) + ], + [], + []); + + var ir = new CilToIrBuilder().Build(body); + + Assert.Collection(ir.Nodes, + n => Assert.Equal(IrOperationKind.Constant, n.Operation), + n => Assert.Equal(IrOperationKind.StoreLocal, n.Operation), + n => Assert.Equal(IrOperationKind.LoadLocal, n.Operation), + n => Assert.Equal(IrOperationKind.Constant, n.Operation), + n => Assert.Equal(IrOperationKind.Binary, n.Operation), + n => Assert.Equal(IrOperationKind.Return, n.Operation)); + Assert.Equal(new[] { 0, 1, 2, 3, 4, 5 }, ir.Nodes.Select(n => n.Offset)); + Assert.True(ir.LocalValues.ContainsKey(0)); + Assert.DoesNotContain(ir.Diagnostics, d => d.IsError); + } + + [Fact] + public void Build_ProducesDiagnosticsForSignatureAwareCallsAndMarksAllocation() + { + var body = new DecodedMethodBody( + 1, + 8, + true, + [ + new CilInstruction(0, OpCodes.Newobj, 123, 5, []), + new CilInstruction(5, OpCodes.Box, 456, 5, []), + new CilInstruction(10, OpCodes.Call, 789, 5, []), + new CilInstruction(15, OpCodes.Pop, null, 1, []), + new CilInstruction(16, OpCodes.Ret, null, 1, []) + ], + [], + []); + + var ir = new CilToIrBuilder().Build(body); + + Assert.Contains(ir.Nodes, n => n.Operation == IrOperationKind.Allocate && n.CanAllocate); + Assert.Contains(ir.Nodes, n => n.Operation == IrOperationKind.Box); + Assert.Contains(ir.Diagnostics, d => d.Message.Contains("Signature-aware stack behavior", StringComparison.Ordinal)); + } +} diff --git a/tests/ClrLens.Tests.Unit/ClrLens.Tests.Unit.csproj b/tests/ClrLens.Tests.Unit/ClrLens.Tests.Unit.csproj index 861ad1d..a2ae99a 100644 --- a/tests/ClrLens.Tests.Unit/ClrLens.Tests.Unit.csproj +++ b/tests/ClrLens.Tests.Unit/ClrLens.Tests.Unit.csproj @@ -1,4 +1,30 @@ - ClrLens.Tests.UnitClrLens.Tests.Unit - + + ClrLens.Tests.Unit + ClrLens.Tests.Unit + true + $(NoWarn);CA1707;CA1305;CA1861;CS1591 + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + diff --git a/tests/ClrLens.Tests.Unit/ControlFlowGraphTests.cs b/tests/ClrLens.Tests.Unit/ControlFlowGraphTests.cs new file mode 100644 index 0000000..00e94be --- /dev/null +++ b/tests/ClrLens.Tests.Unit/ControlFlowGraphTests.cs @@ -0,0 +1,23 @@ +using ClrLens.Analysis; + +namespace ClrLens.Tests.Unit; + +public sealed class ControlFlowGraphTests +{ + [Fact] + public void Build_CreatesBlocksAndValidEdges() + { + var decoded = TestSupport.DecodeFixtureMethod("ExceptionFlow"); + + var cfg = new ControlFlowGraphBuilder().Build(decoded); + + Assert.NotEmpty(cfg.Blocks); + Assert.Contains(cfg.Blocks, b => b.IsEntry); + Assert.All(cfg.Edges, edge => + { + Assert.Contains(cfg.Blocks, block => block.Id == edge.From); + Assert.Contains(cfg.Blocks, block => block.Id == edge.To); + }); + Assert.NotEmpty(cfg.ExceptionalEdges); + } +} diff --git a/tests/ClrLens.Tests.Unit/DomainContractTests.cs b/tests/ClrLens.Tests.Unit/DomainContractTests.cs new file mode 100644 index 0000000..c854a95 --- /dev/null +++ b/tests/ClrLens.Tests.Unit/DomainContractTests.cs @@ -0,0 +1,31 @@ +using ClrLens.Core.Domain; +using ClrLens.Core.Serialization; + +namespace ClrLens.Tests.Unit; + +public sealed class DomainContractTests +{ + [Fact] + public void AssemblyIdentity_PreservesValues() + { + var identity = new AssemblyIdentity("Name", new string('A', 64), "net8.0", "Amd64", "1.2.3", true, true, true); + + Assert.Equal("Name", identity.Name); + Assert.Equal(new string('A', 64), identity.Sha256); + Assert.Equal("1.2.3", identity.Version); + Assert.Equal("net8.0", identity.TargetFramework); + Assert.True(identity.HasPdb); + Assert.True(identity.IsStrongNamed); + Assert.True(identity.IsReadyToRun); + } + + [Fact] + public void EvidenceKindExternalContract_IsSerializedAsCamelCase() + { + var report = TestSupport.CreateValidReport(); + + var json = AnalysisReportSerializer.Serialize(report); + + Assert.Contains("\"externalContract\"", json, StringComparison.Ordinal); + } +} diff --git a/tests/ClrLens.Tests.Unit/DomainValidationTests.cs b/tests/ClrLens.Tests.Unit/DomainValidationTests.cs new file mode 100644 index 0000000..54b11b7 --- /dev/null +++ b/tests/ClrLens.Tests.Unit/DomainValidationTests.cs @@ -0,0 +1,54 @@ +using ClrLens.Core.Domain; +using ClrLens.Core.Validation; + +namespace ClrLens.Tests.Unit; + +public sealed class DomainValidationTests +{ + [Fact] + public void Validator_RejectsInvalidSha256() + { + var report = TestSupport.CreateValidReport() with { Assembly = new AssemblyIdentity("sample", "123", "net8.0", "Amd64") }; + + var errors = DomainContractValidator.Validate(report, DateOnly.Parse("2026-01-01")); + + Assert.Contains(errors, e => e.Contains("assembly.sha256", StringComparison.Ordinal)); + } + + [Fact] + public void Validator_RejectsNegativeContractAndWrongEvidenceAndExpiredContract() + { + var report = TestSupport.CreateValidReport() with + { + Contracts = + [ + new BoundContract("X", -1, "bytes", "spec", "scope", ContractTrust.External, DateOnly.Parse("2025-12-31"), "owner", "ticket", EvidenceKind.Inferred) + ] + }; + + var errors = DomainContractValidator.Validate(report, DateOnly.Parse("2026-01-01")); + + Assert.Contains(errors, e => e.Contains("max cannot be negative", StringComparison.Ordinal)); + Assert.Contains(errors, e => e.Contains("must use ExternalContract", StringComparison.Ordinal)); + Assert.Contains(errors, e => e.Contains("is expired", StringComparison.Ordinal)); + } + + [Fact] + public void Validator_RejectsIncompleteSuppression() + { + var report = TestSupport.CreateValidReport() with + { + Suppressions = + [ + new Suppression("sup", "target", "", "", "", DateOnly.Parse("2025-12-31")) + ] + }; + + var errors = DomainContractValidator.Validate(report, DateOnly.Parse("2026-01-01")); + + Assert.Contains(errors, e => e.Contains("reason is required", StringComparison.Ordinal)); + Assert.Contains(errors, e => e.Contains("owner is required", StringComparison.Ordinal)); + Assert.Contains(errors, e => e.Contains("scope is required", StringComparison.Ordinal)); + Assert.Contains(errors, e => e.Contains("is expired", StringComparison.Ordinal)); + } +} diff --git a/tests/ClrLens.Tests.Unit/DominanceTests.cs b/tests/ClrLens.Tests.Unit/DominanceTests.cs new file mode 100644 index 0000000..3e0678e --- /dev/null +++ b/tests/ClrLens.Tests.Unit/DominanceTests.cs @@ -0,0 +1,19 @@ +using ClrLens.Analysis; + +namespace ClrLens.Tests.Unit; + +public sealed class DominanceTests +{ + [Fact] + public void Build_ComputesDominatorsAndPostDominators() + { + var decoded = TestSupport.DecodeFixtureMethod("LinearLoop"); + + var cfg = new ControlFlowGraphBuilder().Build(decoded); + var entry = Assert.Single(cfg.Blocks, b => b.IsEntry); + + Assert.Contains(entry.Id, cfg.Dominators[entry.Id]); + Assert.All(cfg.Blocks, block => Assert.Contains(block.Id, cfg.Dominators[block.Id])); + Assert.All(cfg.Blocks.Where(b => b.IsExit), block => Assert.Equal(new[] { block.Id }, cfg.PostDominators[block.Id])); + } +} diff --git a/tests/ClrLens.Tests.Unit/GlobalUsings.cs b/tests/ClrLens.Tests.Unit/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/tests/ClrLens.Tests.Unit/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/ClrLens.Tests.Unit/IrModelTests.cs b/tests/ClrLens.Tests.Unit/IrModelTests.cs new file mode 100644 index 0000000..19ef10a --- /dev/null +++ b/tests/ClrLens.Tests.Unit/IrModelTests.cs @@ -0,0 +1,30 @@ +using System.Reflection.Emit; +using ClrLens.IL; +using ClrLens.IR; + +namespace ClrLens.Tests.Unit; + +public sealed class IrModelTests +{ + [Fact] + public void MethodIr_PreservesExceptionRegions() + { + var region = new CilExceptionRegion(System.Reflection.Metadata.ExceptionRegionKind.Catch, 0, 4, 4, 4, 0, default); + var methodIr = new MethodIr([], new Dictionary(), [], [region]); + + Assert.Single(methodIr.ExceptionRegions); + Assert.Equal(region, methodIr.ExceptionRegions[0]); + } + + [Fact] + public void IrNode_PreservesOffsetOperationAndEffects() + { + var node = new IrNode(12, IrOperationKind.Allocate, new ValueId(1), [new ValueId(0)], 123, "System.Object", OpCodes.Newobj, true, true, true); + + Assert.Equal(12, node.Offset); + Assert.Equal(IrOperationKind.Allocate, node.Operation); + Assert.True(node.CanThrow); + Assert.True(node.HasSideEffect); + Assert.True(node.CanAllocate); + } +} diff --git a/tests/ClrLens.Tests.Unit/LoopAnalysisTests.cs b/tests/ClrLens.Tests.Unit/LoopAnalysisTests.cs new file mode 100644 index 0000000..86a3edc --- /dev/null +++ b/tests/ClrLens.Tests.Unit/LoopAnalysisTests.cs @@ -0,0 +1,51 @@ +using System.Reflection.Emit; +using ClrLens.Analysis; +using ClrLens.IL; + +namespace ClrLens.Tests.Unit; + +public sealed class LoopAnalysisTests +{ + [Fact] + public void Build_DetectsNaturalLoopsForFixtureMethod() + { + var decoded = TestSupport.DecodeFixtureMethod("NestedLoop"); + + var cfg = new ControlFlowGraphBuilder().Build(decoded); + + Assert.NotEmpty(cfg.StronglyConnectedComponents); + Assert.NotEmpty(cfg.BackEdges); + Assert.NotEmpty(cfg.NaturalLoops); + Assert.All(cfg.NaturalLoops, loop => + { + Assert.Contains(loop.Header, loop.Nodes); + Assert.Contains(loop.Latch, loop.Nodes); + Assert.True(loop.Depth >= 1); + }); + } + + [Fact] + public void Build_KeepsIrreducibleCycleAsSccWithoutNaturalLoopClassification() + { + var body = new DecodedMethodBody( + 1, + 8, + true, + [ + new CilInstruction(0, OpCodes.Ldc_I4_0, null, 1, []), + new CilInstruction(1, OpCodes.Brtrue_S, 10, 2, [10]), + new CilInstruction(3, OpCodes.Br_S, 20, 2, [20]), + new CilInstruction(10, OpCodes.Br_S, 30, 2, [30]), + new CilInstruction(20, OpCodes.Br_S, 30, 2, [30]), + new CilInstruction(30, OpCodes.Switch, new[] { 10, 20 }, 9, [10, 20]), + new CilInstruction(40, OpCodes.Ret, null, 1, []) + ], + [], + []); + + var cfg = new ControlFlowGraphBuilder().Build(body); + + Assert.Contains(cfg.StronglyConnectedComponents, scc => scc.SetEquals(new HashSet { 2, 3, 4 })); + Assert.Empty(cfg.NaturalLoops); + } +} diff --git a/tests/ClrLens.Tests.Unit/SerializationTests.cs b/tests/ClrLens.Tests.Unit/SerializationTests.cs new file mode 100644 index 0000000..facd0c7 --- /dev/null +++ b/tests/ClrLens.Tests.Unit/SerializationTests.cs @@ -0,0 +1,32 @@ +using ClrLens.Core.Serialization; + +namespace ClrLens.Tests.Unit; + +public sealed class SerializationTests +{ + [Fact] + public void Serialize_IsDeterministic() + { + var report = TestSupport.CreateValidReport(); + + var first = AnalysisReportSerializer.Serialize(report); + var second = AnalysisReportSerializer.Serialize(report); + + Assert.Equal(first, second); + } + + [Fact] + public void Deserialize_RoundTripsValues() + { + var report = TestSupport.CreateValidReport(); + var json = AnalysisReportSerializer.Serialize(report); + + var result = AnalysisReportSerializer.Deserialize(json); + + Assert.Equal(report.SchemaVersion, result.SchemaVersion); + Assert.Equal(report.Assembly, result.Assembly); + Assert.Equal(report.Provenance, result.Provenance); + Assert.Equal(report.Contracts.Single(), result.Contracts.Single()); + Assert.Equal(report.Suppressions.Single(), result.Suppressions.Single()); + } +} diff --git a/tests/ClrLens.Tests.Unit/SolutionStructureTests.cs b/tests/ClrLens.Tests.Unit/SolutionStructureTests.cs new file mode 100644 index 0000000..c3fd9f1 --- /dev/null +++ b/tests/ClrLens.Tests.Unit/SolutionStructureTests.cs @@ -0,0 +1,41 @@ +using System.Xml.Linq; + +namespace ClrLens.Tests.Unit; + +public sealed class SolutionStructureTests +{ + [Fact] + public void CoreProject_DoesNotReferenceForbiddenModules() + { + var coreProject = Path.Combine(TestSupport.RepoRoot, "src", "ClrLens.Core", "ClrLens.Core.csproj"); + var text = File.ReadAllText(coreProject); + + Assert.DoesNotContain("ClrLens.Rewriter", text, StringComparison.Ordinal); + Assert.DoesNotContain("ClrLens.Runtime", text, StringComparison.Ordinal); + Assert.DoesNotContain("ClrLens.Cli", text, StringComparison.Ordinal); + } + + [Fact] + public void DotnetTestProjects_AreDiscoverableInSolution_AndBenchmarksRemainSeparate() + { + var solution = File.ReadAllText(Path.Combine(TestSupport.RepoRoot, "ClrLens.sln")); + + Assert.Contains("ClrLens.Tests.Unit", solution, StringComparison.Ordinal); + Assert.Contains("ClrLens.Tests.IL", solution, StringComparison.Ordinal); + Assert.Contains("ClrLens.Tests.Optimization", solution, StringComparison.Ordinal); + Assert.Contains("ClrLens.Tests.Regression", solution, StringComparison.Ordinal); + + var benchmarkProject = XDocument.Load(Path.Combine(TestSupport.RepoRoot, "tests", "ClrLens.Tests.Benchmarks", "ClrLens.Tests.Benchmarks.csproj")); + Assert.DoesNotContain( + benchmarkProject.Descendants("PackageReference"), + p => string.Equals((string?)p.Attribute("Include"), "xunit", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void SolutionDefaults_UseNet8AndNullableEnabled() + { + var props = File.ReadAllText(Path.Combine(TestSupport.RepoRoot, "Directory.Build.props")); + Assert.Contains("net8.0", props, StringComparison.Ordinal); + Assert.Contains("enable", props, StringComparison.Ordinal); + } +} diff --git a/tests/ClrLens.Tests.Unit/TestSupport.cs b/tests/ClrLens.Tests.Unit/TestSupport.cs new file mode 100644 index 0000000..5597a8d --- /dev/null +++ b/tests/ClrLens.Tests.Unit/TestSupport.cs @@ -0,0 +1,71 @@ +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using ClrLens.Core.Domain; +using ClrLens.SampleFixtures; + +namespace ClrLens.Tests.Unit; + +internal static class TestSupport +{ + public static string FixtureAssemblyPath => typeof(FixtureMarker).Assembly.Location; + + public static string RepoRoot + { + get + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "ClrLens.sln"))) + return current.FullName; + current = current.Parent; + } + + throw new DirectoryNotFoundException("Could not locate repository root."); + } + } + + + public static AnalysisReport CreateValidReport() => new( + "0.1", + new AssemblyIdentity("sample", new string('A', 64), "net8.0", "Amd64", "1.0.0.0"), + new Provenance("1.0.0", "runtime", DateTimeOffset.Parse("2026-01-01T00:00:00Z"), new string('B', 64)), + new AnalysisScenarioConfiguration(AnalysisScenario.Baseline, AnalysisCostMode.Typical), + new AnalysisSummary(1, 2, 0, 0, 0.1, 0.1, 0), + [], + [new BoundContract("S", 1, "bytes", "spec", "global", ContractTrust.External, DateOnly.Parse("2099-12-31"), "team", "T-1", EvidenceKind.ExternalContract)], + [new Suppression("sup-1", "F001", "known", "team", "repo", DateOnly.Parse("2099-12-31"), "T-2")]); + + public static (PEReader peReader, MetadataReader metadata) OpenFixtureMetadata() + { + var stream = File.OpenRead(FixtureAssemblyPath); + var peReader = new PEReader(stream); + return (peReader, peReader.GetMetadataReader()); + } + + + public static ClrLens.IL.DecodedMethodBody DecodeFixtureMethod(string methodName) + { + using var stream = File.OpenRead(FixtureAssemblyPath); + using var peReader = new PEReader(stream); + var metadata = peReader.GetMetadataReader(); + var decoder = new ClrLens.IL.CilDecoder(); + + foreach (var handle in metadata.MethodDefinitions) + { + var definition = metadata.GetMethodDefinition(handle); + if (definition.RelativeVirtualAddress == 0) + { + continue; + } + + if (metadata.GetString(definition.Name) == methodName) + { + return decoder.Decode(peReader, definition); + } + } + + throw new InvalidOperationException($"Method '{methodName}' not found in fixture assembly."); + } + +} diff --git a/tests/ClrLens.Tests.Unit/UnknownEffectTests.cs b/tests/ClrLens.Tests.Unit/UnknownEffectTests.cs new file mode 100644 index 0000000..6399ee2 --- /dev/null +++ b/tests/ClrLens.Tests.Unit/UnknownEffectTests.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Reflection.Emit; +using ClrLens.Analysis; + +namespace ClrLens.Tests.Unit; + +public sealed class UnknownEffectTests +{ + [Fact] + public void EffectsFor_NewObjIncludesAllocation() + { + var effects = InvokeEffectsFor(OpCodes.Newobj, ".ctor"); + + Assert.True(effects.HasFlag(UnknownEffect.MayAllocate)); + Assert.True(effects.HasFlag(UnknownEffect.MayThrow)); + } + + [Fact] + public void EffectsFor_WaitAndRunAddSpecificFlags() + { + var wait = InvokeEffectsFor(OpCodes.Call, "Wait"); + var run = InvokeEffectsFor(OpCodes.Call, "Run"); + var unknown = InvokeEffectsFor(OpCodes.Calli, null); + + Assert.True(wait.HasFlag(UnknownEffect.MayBlock)); + Assert.True(run.HasFlag(UnknownEffect.MaySpawnWork)); + Assert.True(unknown.HasFlag(UnknownEffect.MayRetainArguments)); + } + + private static UnknownEffect InvokeEffectsFor(OpCode opCode, string? methodName) + { + var method = typeof(CallGraphBuilder).GetMethod("EffectsFor", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new MissingMethodException("CallGraphBuilder.EffectsFor"); + return (UnknownEffect)method.Invoke(null, [opCode, methodName])!; + } +}