Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions src/ILInspector.Decompiler.Tests/CompileBackGateTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using ILInspector.DecompilerHarness;

namespace ILInspector.Decompiler.Tests;

/// <summary>
/// The compile-back gate: decompile every method on <see cref="CfgSampleClass"/>,
/// 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.
/// </summary>
public class CompileBackGateTests
{
const string FixtureType = "ILInspector.Decompiler.Tests.CfgSampleClass";

/// <summary>
/// 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).
/// </summary>
static readonly HashSet<string> KnownDiffs = new(StringComparer.Ordinal)
{
".ctor",
"BothPositive",
"CatchEverything",
"ClassicLock",
"DayNumber",
"ManualDisposeAsyncInFinally",
"NeitherOr",
"PowerOfTwo",
"ReadVolatileFlag",
"ReverseCopy",
"SmallStringSwitch",
"StaleFieldRead",
"SwitchCase",
"TryFinallyAdd",
"TryFinallyTwoReturns",
"WhileLoop",
};

/// <summary>
/// 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).
/// </summary>
static readonly string[] PinnedExact = { "CheckedAdd", "UnsignedShift", "Shadowed" };

static IReadOnlyList<CompileBack.CompileBackResult> 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}");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

<ItemGroup>
<PackageReference Include="xunit.v3" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
</ItemGroup>

<ItemGroup>
Expand All @@ -24,4 +25,15 @@
<ProjectReference Include="..\ILInspector.Decompiler\ILInspector.Decompiler.csproj" />
</ItemGroup>

<!--
The compile-back oracle (decompile -> recompile -> compare IL) lives in the
DecompilerHarness executable. Link the one source file in so the xunit gate
can call CompileBack.Evaluate without taking a project reference on an Exe
(which would pull in a second entry point).
-->
<ItemGroup>
<Compile Include="..\..\tools\DecompilerHarness\CompileBack.cs"
Link="CompileBack.cs" />
</ItemGroup>

</Project>
133 changes: 133 additions & 0 deletions tools/DecompilerHarness/CompileBack.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,139 @@ public static int Run(IReadOnlyList<string> assemblies, int cap, int maxExamples
return 0;
}

/// <summary>The compile-back outcome for one method.</summary>
public enum CompileBackStatus
{
/// <summary>Recompiled to the same canonical opcode stream — the goal.</summary>
Exact,
/// <summary>Rendered at Full fidelity but recompiled to a different stream (a defect).</summary>
OpcodeDiff,
/// <summary>Imported below Full fidelity, so an opcode diff is expected, not a defect.</summary>
NotFull,
/// <summary>The decompiled body did not recompile (e.g. an unbindable construct).</summary>
RecompileFail,
/// <summary>The type skeleton could not be emitted or the original/recompiled method was not found.</summary>
ContextFail,
}

/// <summary>One method's compile-back result, with both opcode streams for diagnostics.</summary>
public sealed record CompileBackResult(
string Type, string Method, int Overload, CompileBackStatus Status,
string OriginalOpcodes, string RecompiledOpcodes, string? Detail);

/// <summary>
/// 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; <see cref="Run"/>
/// is the console-reporting entry point. Shares all of the skeleton-emission and
/// opcode-comparison machinery so the two paths can never drift.
/// </summary>
public static IReadOnlyList<CompileBackResult> 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<CompileBackResult>();
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<MetadataReference> references, CSharpParseOptions parseOptions,
CSharpCompilationOptions compileOptions, List<CompileBackResult> 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<string, int>(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<MetadataReference> references, CSharpParseOptions parseOptions,
Expand Down
2 changes: 2 additions & 0 deletions tools/DecompilerHarness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading