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