From f2f2d5ee6062fcb610ae50e95dfc7c9db35ac730 Mon Sep 17 00:00:00 2001 From: Rich Lander Date: Wed, 17 Jun 2026 23:31:19 -0700 Subject: [PATCH] Add an xunit compile-back gate over the fixture green set The compile-back oracle (decompile -> recompile -> compare IL) only ran as a console exploration mode in the harness. Wire it into CI as a durable regression guard. Expose CompileBack.Evaluate: a non-printing, structured-result entry point that shares all of the skeleton-emission and opcode-comparison machinery with the existing console Run, so the two paths cannot drift. The test project links the single CompileBack.cs source file (rather than taking a project reference on the harness Exe, which would pull in a second entry point) and adds the Roslyn package it needs. CompileBackGateTests asserts two properties over CfgSampleClass: - no method newly recompiles to a different opcode stream beyond the documented KnownDiffs docket (catches a fix in one method silently degrading another); - previously-fixed methods (CheckedAdd #604, UnsignedShift #606, Shadowed #607) stay opcode-exact. Shrink KnownDiffs as docket entries are fixed; StaleFieldRead remains a tracked defect (issue #605). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CompileBackGateTests.cs | 96 +++++++++++++ .../ILInspector.Decompiler.Tests.csproj | 12 ++ tools/DecompilerHarness/CompileBack.cs | 133 ++++++++++++++++++ tools/DecompilerHarness/README.md | 2 + 4 files changed, 243 insertions(+) create mode 100644 src/ILInspector.Decompiler.Tests/CompileBackGateTests.cs diff --git a/src/ILInspector.Decompiler.Tests/CompileBackGateTests.cs b/src/ILInspector.Decompiler.Tests/CompileBackGateTests.cs new file mode 100644 index 00000000..cf24478b --- /dev/null +++ b/src/ILInspector.Decompiler.Tests/CompileBackGateTests.cs @@ -0,0 +1,96 @@ +using ILInspector.DecompilerHarness; + +namespace ILInspector.Decompiler.Tests; + +/// +/// The compile-back gate: decompile every method on , +/// recompile it inside a reconstructed shape of its type, and compare the canonical +/// opcode stream against the originally compiled fixture. A method that recompiles +/// to a different stream changed the program — the worst decompiler failure class, +/// invisible to parse/bind checks. This pins the green set so a regression that turns +/// an exact method into a diff fails CI, while the documented baseline records the +/// methods that still diverge (the open docket) so the gate stays green on main. +/// +public class CompileBackGateTests +{ + const string FixtureType = "ILInspector.Decompiler.Tests.CfgSampleClass"; + + /// + /// Methods that still recompile to a different opcode stream — the open + /// decompiler docket. Each is a tracked defect or a benign over-render; the gate + /// tolerates these but fails if a NEW method joins the set. Shrink this list as + /// fixes land. Tracked defects include StaleFieldRead (issue #605), Shadowed + /// (a dropped this.field load), the .ctor field-initializer ordering, and the + /// definite-assignment default-init over-render (TryFinallyAdd, PowerOfTwo, + /// CatchEverything, SwitchCase, WhileLoop, ClassicLock, TryFinallyTwoReturns, + /// ManualDisposeAsyncInFinally). + /// + static readonly HashSet KnownDiffs = new(StringComparer.Ordinal) + { + ".ctor", + "BothPositive", + "CatchEverything", + "ClassicLock", + "DayNumber", + "ManualDisposeAsyncInFinally", + "NeitherOr", + "PowerOfTwo", + "ReadVolatileFlag", + "ReverseCopy", + "SmallStringSwitch", + "StaleFieldRead", + "SwitchCase", + "TryFinallyAdd", + "TryFinallyTwoReturns", + "WhileLoop", + }; + + /// + /// Methods a prior compile-back fix turned opcode-exact. Pinning them guards the + /// fix durably: CheckedAdd must keep the overflow check (#604), UnsignedShift + /// must keep dropping the redundant width mask (#606), and Shadowed must keep + /// qualifying the shadowed this.field load (#607). + /// + static readonly string[] PinnedExact = { "CheckedAdd", "UnsignedShift", "Shadowed" }; + + static IReadOnlyList EvaluateFixtures() + { + var assembly = typeof(CfgSampleClass).Assembly.Location; + return CompileBack.Evaluate(assembly) + .Where(r => r.Type == FixtureType) + .ToList(); + } + + [Fact] + public void NoNewOpcodeDiffsBeyondKnownDocket() + { + var diffs = EvaluateFixtures() + .Where(r => r.Status == CompileBack.CompileBackStatus.OpcodeDiff) + .Select(r => r.Method) + .OrderBy(m => m, StringComparer.Ordinal) + .ToList(); + + var unexpected = diffs.Where(m => !KnownDiffs.Contains(m)).ToList(); + + Assert.True(unexpected.Count == 0, + $"New compile-back opcode diffs (decompiled C# recompiles to different IL): " + + $"{string.Join(", ", unexpected)}. Full current diff set: {string.Join(", ", diffs)}"); + } + + [Fact] + public void PinnedFixesStayOpcodeExact() + { + var results = EvaluateFixtures(); + + foreach (var method in PinnedExact) + { + var matches = results.Where(r => r.Method == method).ToList(); + Assert.True(matches.Count > 0, + $"Expected compile-back to evaluate {method}, but it was not rendered."); + foreach (var result in matches) + Assert.True(result.Status == CompileBack.CompileBackStatus.Exact, + $"{method} regressed to {result.Status}: a prior compile-back fix no longer holds.\n" + + $" original : {result.OriginalOpcodes}\n recompiled: {result.RecompiledOpcodes}"); + } + } +} diff --git a/src/ILInspector.Decompiler.Tests/ILInspector.Decompiler.Tests.csproj b/src/ILInspector.Decompiler.Tests/ILInspector.Decompiler.Tests.csproj index 98038d97..b4f9edc3 100644 --- a/src/ILInspector.Decompiler.Tests/ILInspector.Decompiler.Tests.csproj +++ b/src/ILInspector.Decompiler.Tests/ILInspector.Decompiler.Tests.csproj @@ -14,6 +14,7 @@ + @@ -24,4 +25,15 @@ + + + + + diff --git a/tools/DecompilerHarness/CompileBack.cs b/tools/DecompilerHarness/CompileBack.cs index 506d48f2..6f8356e0 100644 --- a/tools/DecompilerHarness/CompileBack.cs +++ b/tools/DecompilerHarness/CompileBack.cs @@ -82,6 +82,139 @@ public static int Run(IReadOnlyList assemblies, int cap, int maxExamples return 0; } + /// The compile-back outcome for one method. + public enum CompileBackStatus + { + /// Recompiled to the same canonical opcode stream — the goal. + Exact, + /// Rendered at Full fidelity but recompiled to a different stream (a defect). + OpcodeDiff, + /// Imported below Full fidelity, so an opcode diff is expected, not a defect. + NotFull, + /// The decompiled body did not recompile (e.g. an unbindable construct). + RecompileFail, + /// The type skeleton could not be emitted or the original/recompiled method was not found. + ContextFail, + } + + /// One method's compile-back result, with both opcode streams for diagnostics. + public sealed record CompileBackResult( + string Type, string Method, int Overload, CompileBackStatus Status, + string OriginalOpcodes, string RecompiledOpcodes, string? Detail); + + /// + /// Runs the compile-back loop over one assembly and returns a structured result + /// per rendered method, without printing. This is the testable entry point the + /// xunit gate uses to assert the green set stays opcode-exact; + /// is the console-reporting entry point. Shares all of the skeleton-emission and + /// opcode-comparison machinery so the two paths can never drift. + /// + public static IReadOnlyList Evaluate(string assemblyPath) + { + var parseOptions = new CSharpParseOptions(LanguageVersion.Preview); + var compileOptions = new CSharpCompilationOptions( + OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true, + optimizationLevel: OptimizationLevel.Release, + nullableContextOptions: NullableContextOptions.Disable); + + var results = new List(); + using var pe = new PEReader(File.OpenRead(assemblyPath)); + if (!pe.HasMetadata) + return results; + var reader = pe.GetMetadataReader(); + using var source = MetadataSource.Open(assemblyPath); + var references = RuntimeReferences(assemblyPath); + + foreach (var typeHandle in reader.TypeDefinitions) + EvaluateType(reader, pe, source, typeHandle, references, parseOptions, compileOptions, results); + + return results; + } + + static void EvaluateType( + MetadataReader reader, PEReader pe, MetadataSource source, TypeDefinitionHandle typeHandle, + ImmutableArray references, CSharpParseOptions parseOptions, + CSharpCompilationOptions compileOptions, List results) + { + var typeDef = reader.GetTypeDefinition(typeHandle); + if (!typeDef.GetDeclaringType().IsNil) + return; // nested types are emitted by their enclosing type + var kind = ShapeOf(reader, typeDef); + if (kind is not (TypeKind.Class or TypeKind.Struct)) + return; + + string ns = reader.GetString(typeDef.Namespace); + string tn = reader.GetString(typeDef.Name); + string fullType = ns.Length == 0 ? tn : $"{ns}.{tn}"; + if (fullType.Contains('<')) + return; + + var overloads = new Dictionary(StringComparer.Ordinal); + foreach (var mh in typeDef.GetMethods()) + { + var method = reader.GetMethodDefinition(mh); + string name = reader.GetString(method.Name); + string key = $"{fullType}::{name}"; + int overload = overloads.GetValueOrDefault(key); + overloads[key] = overload + 1; + if (method.RelativeVirtualAddress == 0) + continue; + if (name.Contains('<')) + continue; + + var function = IrImporter.Import(source, fullType, name, overload); + if (function is null) + continue; + string? body; + try { body = CSharpPrinter.PrintRaised(function).Output; } + catch { continue; } + if (body is null) + continue; + + bool isFull = function.Fidelity == DecompilationFidelity.Full; + var original = ILDisassembler.Disassemble(pe, reader, method); + if (original is null) + continue; + var origOps = original.Select(i => CanonicalOpcode(i.OpCodeName)).ToList(); + string origText = string.Join(" ", origOps); + + string unit; + try { unit = BuildUnit(reader, mh, body); } + catch + { + results.Add(new(fullType, name, overload, CompileBackStatus.ContextFail, origText, "", "skeleton-emit")); + continue; + } + + var tree = CSharpSyntaxTree.ParseText(unit, parseOptions); + var comp = CSharpCompilation.Create("cb", [tree], references, compileOptions); + using var ms = new MemoryStream(); + var emit = comp.Emit(ms); + if (!emit.Success) + { + var err = emit.Diagnostics.FirstOrDefault(d => d.Severity == DiagnosticSeverity.Error); + results.Add(new(fullType, name, overload, CompileBackStatus.RecompileFail, origText, "", err?.Id)); + continue; + } + + ms.Position = 0; + using var rpe = new PEReader(ms); + var rOps = FindAndDisassemble(rpe, fullType, name, overload) + ?.Select(i => CanonicalOpcode(i.OpCodeName)).ToList(); + if (rOps is null) + { + results.Add(new(fullType, name, overload, CompileBackStatus.ContextFail, origText, "", "method-not-found")); + continue; + } + + string recompText = string.Join(" ", rOps); + var status = origOps.SequenceEqual(rOps) ? CompileBackStatus.Exact + : isFull ? CompileBackStatus.OpcodeDiff + : CompileBackStatus.NotFull; + results.Add(new(fullType, name, overload, status, origText, recompText, null)); + } + } + static void RunType( MetadataReader reader, PEReader pe, MetadataSource source, TypeDefinitionHandle typeHandle, ImmutableArray references, CSharpParseOptions parseOptions, diff --git a/tools/DecompilerHarness/README.md b/tools/DecompilerHarness/README.md index ffddb6b7..1218af35 100644 --- a/tools/DecompilerHarness/README.md +++ b/tools/DecompilerHarness/README.md @@ -29,6 +29,8 @@ The similarity is a coarse token-bag measure and is deliberately understood to b *When to use it.* Reach for compile-back when the question is **"is this decompilation faithful,"** not "does it compile." Run it after any change to the importer, a raising pass, or the printer that could alter emitted semantics — branch sense, checked/unchecked, conversions, field/local ordering, shift masking — and read the `Full` opcode-diff bucket as a regression docket. It is the tool that catches a fix in one method silently degrading another. Prefer the small, fast, purpose-built fixture corpus (`CfgSampleClass` in `ILInspector.Decompiler.Tests`) for a tight loop; sweep a real assembly (BCL) for breadth once the fixtures are clean. Use `--compile-check` first when you only need to know the output is valid C#, and `--candidate next` when comparing the two pipelines' text rather than meaning. +*The CI gate.* The console mode above is for exploration; the durable regression guard is `CompileBackGateTests` in `ILInspector.Decompiler.Tests`, which calls the same machinery through `CompileBack.Evaluate` (the non-printing, structured-result entry point) over `CfgSampleClass`. It fails CI when a method newly recompiles to a different opcode stream (a regression beyond the documented `KnownDiffs` docket) and when a previously-fixed method (`PinnedExact`) regresses. Shrink `KnownDiffs` as you fix docket entries; add the fixed method to `PinnedExact` to pin the fix. + **Stage dump** (`--dump 'Type::Method'`): JitDump for the decompiler — prints every stage of one method's analysis as projections of the shared `MethodAnalysis`: raw IL, typed IL (per-instruction stack states), structured IL (blocks and exception regions), then C#. The IL projections come from pre-transform stages, so the output is exactly what each pipeline layer saw. ## Usage