.NET Decompiler with support for PDB generation, ReadyToRun, Metadata (&more) - cross-platform!
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

1087 lines
45 KiB

// Copyright (c) 2026 Siegfried Pammer
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
#:property PublishAot=false
// decompdiff: decompiles a corpus of assemblies with TWO builds of
// ICSharpCode.Decompiler (loaded side-by-side via AssemblyLoadContext) and
// reports how the output differs, to assess quality/readability of decompiler
// changes across real-world code. The textual complement to the Windows
// round-trip tests, which verify correctness but not output quality.
//
// usage: dotnet run decompdiff.cs -- --old <commit-ish|ILSpy-checkout|Decompiler.dll> --new <...>
// [-o <report-dir>] [--no-build] [--refs <dir>]... <dll|dir|@list>...
//
// - checkout args are built (Release; restore keeps packages.lock.json whole via
// -p:RestoreEnablePackagePruning=false) on every run, because a build older than
// the checkout measures code that is not the one named; pass --no-build to reuse
// the build a checkout already carries.
// - a commit-ish (branch, tag, sha, FETCH_HEAD) is resolved against the repository
// the tool is run from and checked out into a worktree under
// ~/.cache/decompdiff/<repo>/<commit>, kept and reused so a rerun keeps the
// Release build it already contains.
// - corpus dirs are scanned recursively for *.dll (e.g. ~/.cache/nugetfuzz); @list reads
// the entries from a file, one per line, as nugetfuzz does with its package list.
// - changed/errored types are written to <report-dir>/{old,new}/...; inspect with
// `git diff --no-index <report-dir>/old <report-dir>/new`, or open the generated
// <report-dir>/index.html, which carries the same data with inline diffs.
// - exit code 1 when the new version has errors the old one didn't (regressions).
//
// Reference handling: every assembly is decompiled out of a staging directory that
// holds symlinks to itself, its original neighbours, and the transitive closure of
// its references as found in --refs directories, the machine-wide NuGet cache, the
// nugetfuzz package cache, and the reference-assembly pack matching the assembly's
// own target framework. UniversalAssemblyResolver searches that directory first
// (ResolveInternal -> SearchDirectory), so staging fixes both classic failure modes
// - a sibling package that does not sit next to the assembly,
// and the Windows-only mscorlib lookup that throws "Version not supported" on Linux
// - while still driving the stable 2-arg CSharpDecompiler ctor. Both sides read the
// SAME staging directory, so whatever stays unresolved degrades them identically
// and the diffs remain meaningful.
using System.Collections;
using System.Diagnostics;
using System.Reflection;
using System.Reflection.Metadata;
using System.Reflection.PortableExecutable;
using System.Runtime.Loader;
using System.Text;
using System.Text.RegularExpressions;
Trace.Listeners.Clear();
Trace.Listeners.Add(new ThrowOnAssert());
try
{
Debug.Fail("self-test");
Console.Error.WriteLine("FATAL: assert hook not active, Debug.Assert in Debug decompiler builds would kill the process");
return 2;
}
catch (AssertionFailedException)
{
// hook works
}
string? oldSpec = null, newSpec = null, reportDir = null;
bool skipBuild = false;
var corpus = new List<string>();
var refDirs = new List<string>();
for (int i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--old":
oldSpec = args[++i];
break;
case "--new":
newSpec = args[++i];
break;
case "-o":
reportDir = args[++i];
break;
case "--build":
// Building is what happens anyway; accepted so older invocations keep working.
break;
case "--no-build":
skipBuild = true;
break;
case "--refs":
refDirs.Add(args[++i]);
break;
default:
// @file lists corpus entries one per line, the same spelling nugetfuzz uses. Shells
// differ on how (or whether) a file expands into arguments, so the tool reads it.
if (args[i].StartsWith('@'))
{
corpus.AddRange(File.ReadAllLines(args[i][1..])
.Select(l => l.Trim())
.Where(l => l.Length > 0 && !l.StartsWith('#')));
}
else
{
corpus.Add(args[i]);
}
break;
}
}
if (oldSpec == null || newSpec == null || corpus.Count == 0)
{
Console.Error.WriteLine("usage: decompdiff --old <commit-ish|ILSpy-checkout|Decompiler.dll> --new <...> [-o report-dir] [--no-build] [--refs <dir>]... <dll|dir|@list>...");
return 1;
}
reportDir ??= "decompdiff-report";
if (Directory.Exists(reportDir))
{
if (!File.Exists(Path.Combine(reportDir, "summary.md")) && Directory.EnumerateFileSystemEntries(reportDir).Any())
{
Console.Error.WriteLine($"refusing to reuse {reportDir}: exists, non-empty, and not a decompdiff report");
return 1;
}
Directory.Delete(reportDir, true);
}
Directory.CreateDirectory(reportDir);
Side oldSide, newSide;
try
{
oldSide = Side.Create("old", oldSpec, skipBuild);
newSide = Side.Create("new", newSpec, skipBuild);
}
catch (ArgumentException ex)
{
// A mistyped branch name is the easiest way to get here, and its message says more
// than the stack trace does.
Console.Error.WriteLine(ex.Message);
return 1;
}
Console.WriteLine($"old: {oldSide.Description}");
Console.WriteLine($"new: {newSide.Description}");
var assemblies = corpus
.SelectMany(a => Directory.Exists(a)
? Directory.EnumerateFiles(a, "*.dll", SearchOption.AllDirectories)
: [a])
.Where(f => !f.EndsWith(".resources.dll", StringComparison.OrdinalIgnoreCase))
.Distinct()
.OrderBy(f => f)
.ToList();
Console.WriteLine($"corpus: {assemblies.Count} assemblies");
var refIndex = new RefIndex(refDirs, corpus);
Console.WriteLine($"references: {refIndex.Description}");
var stageRoot = Path.Combine(reportDir, ".staging");
var unresolvedRefs = new SortedDictionary<string, List<string>>(); // assembly -> missing refs
int asmCount = 0, unchanged = 0;
var skipped = new List<string>();
var changed = new List<ChangedType>();
var transitions = new List<string>(); // fixed-error / NEW-ERROR / only-old / only-new lines
int newErrors = 0, fixedErrors = 0, bothErrors = 0;
var oldTotals = default(Metrics);
var newTotals = default(Metrics);
foreach (var dll in assemblies)
{
var asmName = Path.GetFileNameWithoutExtension(dll);
var (staged, missing) = RefIndex.Stage(dll, stageRoot, refIndex);
if (missing.Count > 0)
unresolvedRefs[asmName] = missing;
var oldTypes = oldSide.DecompileAssembly(staged);
if (oldTypes == null)
{
var why = missing.Count > 0 ? $"; unresolved refs: {string.Join(", ", missing.Take(5))}" : "";
Console.WriteLine($" skip {asmName}: not decompilable ({oldSide.LastAssemblyError}{why})");
skipped.Add($"{asmName}: {oldSide.LastAssemblyError}{why}");
continue;
}
var newTypes = newSide.DecompileAssembly(staged);
if (newTypes == null)
{
Console.WriteLine($" NEW-ERROR {asmName}: whole assembly failed only with new version ({newSide.LastAssemblyError})");
transitions.Add($"NEW-ERROR (assembly) {asmName}: {newSide.LastAssemblyError}");
newErrors++;
continue;
}
asmCount++;
int asmChanged = 0;
foreach (var name in oldTypes.Keys.Union(newTypes.Keys).OrderBy(n => n))
{
var o = oldTypes.GetValueOrDefault(name);
var n = newTypes.GetValueOrDefault(name);
var location = $"{asmName} / {name}";
if (o == null || n == null)
{
transitions.Add($"only-{(o != null ? "old" : "new")} {location}");
continue;
}
if (o.Error != null || n.Error != null)
{
if (o.Error != null && n.Error != null)
{
bothErrors++;
if (o.Error != n.Error)
transitions.Add($"error-changed {location}: {o.Error} -> {n.Error}");
}
else if (o.Error != null)
{
fixedErrors++;
transitions.Add($"fixed-error {location}: {o.Error}");
}
else
{
newErrors++;
transitions.Add($"NEW-ERROR {location}: {n.Error}");
DumpPair(location, o.Code!, $"// ERROR: {n.Error}");
}
continue;
}
oldTotals += o.Metrics;
newTotals += n.Metrics;
if (o.Code == n.Code)
{
unchanged++;
continue;
}
asmChanged++;
changed.Add(new ChangedType(location, o.Metrics, n.Metrics));
DumpPair(location, o.Code!, n.Code!);
}
Console.WriteLine($" {asmName}: {oldTypes.Count} types, {asmChanged} changed");
}
var summary = new StringBuilder();
summary.AppendLine("# decompdiff report");
summary.AppendLine();
summary.AppendLine($"- old: {oldSide.Description}");
summary.AppendLine($"- new: {newSide.Description}");
summary.AppendLine($"- corpus: {asmCount} assemblies, {unchanged + changed.Count} types compared");
summary.AppendLine();
summary.AppendLine($"| | old | new | delta |");
summary.AppendLine($"|---|---|---|---|");
summary.AppendLine(MetricRow("lines", oldTotals.Lines, newTotals.Lines));
summary.AppendLine(MetricRow("goto statements", oldTotals.Gotos, newTotals.Gotos));
summary.AppendLine(MetricRow("//IL_ warning comments", oldTotals.IlWarnings, newTotals.IlWarnings));
summary.AppendLine(MetricRow("compiler-generated name leaks (<>)", oldTotals.GeneratedNames, newTotals.GeneratedNames));
summary.AppendLine();
summary.AppendLine($"types: {unchanged} unchanged, {changed.Count} changed, {newErrors} NEW errors, {fixedErrors} fixed errors, {bothErrors} errored in both");
summary.AppendLine();
if (changed.Count > 0)
{
summary.AppendLine("## Changed types (largest line delta first)");
summary.AppendLine();
foreach (var c in changed.OrderByDescending(c => Math.Abs(c.New.Lines - c.Old.Lines)).Take(50))
summary.AppendLine($"- {c.Location}: {c.Old.Lines} -> {c.New.Lines} lines" + MetricNotes(c));
if (changed.Count > 50)
summary.AppendLine($"- ... {changed.Count - 50} more, see {reportDir}/{{old,new}}/");
summary.AppendLine();
}
if (transitions.Count > 0)
{
summary.AppendLine("## Error / presence transitions");
summary.AppendLine();
foreach (var t in transitions)
summary.AppendLine($"- {t}");
summary.AppendLine();
}
if (skipped.Count > 0)
{
summary.AppendLine("## Skipped assemblies (undecompilable on BOTH sides)");
summary.AppendLine();
foreach (var s in skipped)
summary.AppendLine($"- {s}");
summary.AppendLine();
}
if (unresolvedRefs.Count > 0)
{
// Point at what would widen the corpus: each name here is a reference no --refs
// directory, the NuGet cache, or a reference-assembly pack could supply.
summary.AppendLine("## Unresolved references (add --refs dirs to cover these)");
summary.AppendLine();
foreach (var (asm, refs) in unresolvedRefs)
summary.AppendLine($"- {asm}: {string.Join(", ", refs)}");
summary.AppendLine();
}
summary.AppendLine($"inspect changed output with: git diff --no-index {reportDir}/old {reportDir}/new");
summary.AppendLine($"or open {Path.Combine(reportDir, "index.html")}");
File.WriteAllText(Path.Combine(reportDir, "summary.md"), summary.ToString());
Report.WriteHtml(Path.Combine(reportDir, "index.html"), new ReportModel(
oldSide.Description, newSide.Description, asmCount, unchanged, changed, transitions,
skipped, unresolvedRefs, oldTotals, newTotals, newErrors, fixedErrors, bothErrors, reportDir));
Console.WriteLine();
Console.Write(summary);
return newErrors > 0 ? 1 : 0;
void DumpPair(string location, string oldCode, string newCode)
{
foreach (var (side, code) in new[] { ("old", oldCode), ("new", newCode) })
{
var path = Path.Combine(reportDir, side, Report.SanitizeFileName(location.Replace(" / ", "/")) + ".cs");
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, code);
}
}
static string MetricRow(string name, long oldValue, long newValue)
=> $"| {name} | {oldValue} | {newValue} | {newValue - oldValue:+#;-#;0} |";
static string MetricNotes(ChangedType c)
{
var notes = new List<string>();
if (c.New.Gotos != c.Old.Gotos)
notes.Add($"gotos {c.Old.Gotos}->{c.New.Gotos}");
if (c.New.IlWarnings != c.Old.IlWarnings)
notes.Add($"IL warnings {c.Old.IlWarnings}->{c.New.IlWarnings}");
if (c.New.GeneratedNames != c.Old.GeneratedNames)
notes.Add($"name leaks {c.Old.GeneratedNames}->{c.New.GeneratedNames}");
return notes.Count > 0 ? $" ({string.Join(", ", notes)})" : "";
}
// Locates reference assemblies by simple name and stages them next to the assembly
// being decompiled. Sources, in order: the reference-assembly pack for the assembly's
// own target framework, the --refs directories (indexed once), and the machine-wide
// NuGet cache (probed per name, so nothing scans ~40k packages).
//
// The pack has to be chosen per assembly and has to win: a net9.0 assembly resolved
// against the .NET Framework packs finds mscorlib but not ValueTask or the async
// method builders, which collapses whole type hierarchies to Unknown and leaves
// AsyncAwaitDecompiler unable to recognise a state machine at all. The packs are also
// what makes classic net4x assemblies decompilable on Linux, whose mscorlib otherwise
// only exists behind a Windows path lookup.
sealed class RefIndex
{
readonly Dictionary<string, string> byName = new(StringComparer.OrdinalIgnoreCase);
readonly List<string> probeRoots = new();
readonly string nugetRoot;
// Reference assemblies are picked per corpus assembly, keyed by its TargetFrameworkAttribute:
// a net9.0 assembly resolved against the .NET Framework packs finds mscorlib but not ValueTask
// or the async method builders, which collapses whole type hierarchies to Unknown and leaves
// AsyncAwaitDecompiler unable to recognise a state machine at all.
readonly Dictionary<string, Dictionary<string, string>> byFramework = new(StringComparer.OrdinalIgnoreCase);
public string Description { get; }
public RefIndex(List<string> refDirs, List<string> corpus)
{
foreach (var dir in refDirs.Concat(corpus).Where(Directory.Exists))
IndexDirectory(dir, byName);
nugetRoot = Environment.GetEnvironmentVariable("NUGET_PACKAGES")
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages");
if (Directory.Exists(nugetRoot))
probeRoots.Add(nugetRoot);
// nugetfuzz's package cache uses the same <id>/<version>/lib/<tfm> layout and holds the
// dependency closure of everything it downloaded. It is the usual source of the corpus,
// so its packages are exactly the ones a corpus assembly's own references point at.
var fuzzCache = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "nugetfuzz");
if (Directory.Exists(fuzzCache))
probeRoots.Add(fuzzCache);
Description = $"{byName.Count} assemblies indexed"
+ (probeRoots.Count > 0 ? $", NuGet cache probe at {string.Join(", ", probeRoots)}" : "");
}
// The reference assemblies for one target framework moniker, indexed by simple name.
// Empty when no matching pack is installed, in which case resolution falls back to the
// --refs directories, the corpus and the NuGet cache probe.
public Dictionary<string, string> IndexFor(string? targetFramework)
{
var key = targetFramework ?? "";
if (byFramework.TryGetValue(key, out var index))
return index;
index = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var dir in RefPackDirs(key))
IndexDirectory(dir, index);
byFramework[key] = index;
return index;
}
// Ref-pack directories for a TargetFrameworkAttribute value, most specific first.
IEnumerable<string> RefPackDirs(string targetFramework)
{
var version = TfmVersion(targetFramework);
if (targetFramework.StartsWith(".NETCoreApp", StringComparison.OrdinalIgnoreCase) && version != null)
{
// The WindowsDesktop and AspNetCore packs come first: Microsoft.NETCore.App.Ref ships
// stub facades for their assemblies (its WindowsBase.dll has no DependencyObject), and
// whichever is indexed first wins.
foreach (var pack in new[] { "microsoft.windowsdesktop.app.ref", "microsoft.aspnetcore.app.ref", "microsoft.netcore.app.ref" })
{
var dir = CorePackDir(pack, version);
if (dir != null)
yield return dir;
}
yield break;
}
// .NETFramework and .NETStandard both resolve against the classic reference assemblies,
// but only a .NETFramework version selects a pack: a .NETStandard version is not a net4x
// version, and feeding 2.0 to the picker would pick net45, whose Facades carry no
// netstandard.dll. netstandard targets take the newest pack, with the widest facade set.
var netFxVersion = targetFramework.StartsWith(".NETFramework", StringComparison.OrdinalIgnoreCase)
? version : null;
foreach (var dir in EnumerateFrameworkRefPacks(nugetRoot, netFxVersion))
yield return dir;
// On Windows the same reference assemblies also ship with the targeting packs, so a
// net4x corpus resolves there without restoring the NuGet package first.
var installedFxRefs = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86),
"Reference Assemblies", "Microsoft", "Framework", ".NETFramework");
if (Directory.Exists(installedFxRefs))
yield return installedFxRefs;
}
static Version? TfmVersion(string targetFramework)
{
var i = targetFramework.IndexOf("Version=v", StringComparison.OrdinalIgnoreCase);
return i >= 0 && Version.TryParse(targetFramework[(i + 9)..], out var v) ? v : null;
}
// ref/<tfm> of a .NET (Core) shared-framework pack. Never falls back to "newest available":
// the packs only go back to 3.0, so a netcoreapp1.x/2.x assembly would silently bind against
// a current BCL and decompile as a wall of Unknown. Better to resolve nothing and say so.
string? CorePackDir(string packId, Version version)
{
var root = Path.Combine(nugetRoot, packId);
if (!Directory.Exists(root))
return null;
var versions = Directory.EnumerateDirectories(root)
.Select(d => (Dir: d, V: Version.TryParse(Path.GetFileName(d).Split('-')[0], out var v) ? v : null))
.Where(x => x.V != null)
.OrderBy(x => x.V)
.ToList();
var pick = versions.LastOrDefault(x => x.V!.Major == version.Major && x.V.Minor == version.Minor).Dir
?? versions.LastOrDefault(x => x.V!.Major <= version.Major).Dir;
if (pick == null)
return null;
var refRoot = Path.Combine(pick, "ref");
return Directory.Exists(refRoot) ? Directory.EnumerateDirectories(refRoot).FirstOrDefault() : null;
}
// Reference assemblies for .NET Framework targets; the newest pack wins, and its
// Facades subdirectory carries the type-forwarding shims netstandard code needs.
// The single .NET Framework reference-assembly pack to bind against: the smallest one that
// is still a superset of the requested version, or the newest installed when no version is
// given (.NETStandard, which wants the widest set of facades). Indexing every installed pack
// instead would let the oldest one win by name and hide the newer BCL from every assembly.
static IEnumerable<string> EnumerateFrameworkRefPacks(string nugetRoot, Version? requested)
{
if (!Directory.Exists(nugetRoot))
yield break;
var packs = Directory.EnumerateDirectories(nugetRoot, "microsoft.netframework.referenceassemblies.net4*")
.Select(d => (Dir: d, V: PackVersion(Path.GetFileName(d))))
.Where(x => x.V != null)
.OrderBy(x => x.V)
.ToList();
var pick = (requested != null ? packs.FirstOrDefault(x => x.V >= requested).Dir : null)
?? packs.LastOrDefault().Dir;
if (pick == null)
yield break;
foreach (var dir in Directory.EnumerateDirectories(pick, "v*", SearchOption.AllDirectories))
{
yield return dir;
var facades = Path.Combine(dir, "Facades");
if (Directory.Exists(facades))
yield return facades;
}
}
// "microsoft.netframework.referenceassemblies.net472" -> 4.7.2
static Version? PackVersion(string packageId)
{
var tfm = packageId[(packageId.LastIndexOf('.') + 1)..];
return tfm.Length > 3 && tfm.StartsWith("net", StringComparison.Ordinal)
&& Version.TryParse(string.Join('.', tfm[3..].ToCharArray()), out var v) ? v : null;
}
static void IndexDirectory(string dir, Dictionary<string, string> index)
{
foreach (var dll in Directory.EnumerateFiles(dir, "*.dll", SearchOption.AllDirectories))
{
var name = Path.GetFileNameWithoutExtension(dll);
// First indexed wins: --refs directories are added before the corpus, so an
// explicitly supplied reference is never shadowed by a corpus copy.
if (!index.ContainsKey(name))
index[name] = dll;
}
}
public string? Find(string simpleName, Dictionary<string, string> frameworkIndex)
{
// The target framework's own reference assemblies outrank everything else: a corpus
// neighbour or a stray NuGet copy of System.Runtime would otherwise decide the BCL.
if (frameworkIndex.TryGetValue(simpleName, out var fxHit))
return fxHit;
if (byName.TryGetValue(simpleName, out var hit))
return hit;
foreach (var root in probeRoots)
{
// NuGet lays packages out as <root>/<id lowercased>/<version>/lib/<tfm>/<id>.dll,
// and the assembly name matches the package id often enough to be worth a look.
var pkgDir = Path.Combine(root, simpleName.ToLowerInvariant());
if (!Directory.Exists(pkgDir))
continue;
var candidate = Directory.EnumerateDirectories(pkgDir)
.OrderByDescending(d => Path.GetFileName(d), StringComparer.OrdinalIgnoreCase)
.SelectMany(v => Directory.EnumerateFiles(v, simpleName + ".dll", SearchOption.AllDirectories))
.FirstOrDefault(f => f.Contains($"{Path.DirectorySeparatorChar}lib{Path.DirectorySeparatorChar}")
|| f.Contains($"{Path.DirectorySeparatorChar}ref{Path.DirectorySeparatorChar}"));
if (candidate != null)
{
byName[simpleName] = candidate;
return candidate;
}
}
byName[simpleName] = null!; // negative cache: probing the filesystem twice buys nothing
return null;
}
// Builds the staging directory for one assembly and returns the path to decompile
// plus the reference names nothing could supply.
public static (string Staged, List<string> Missing) Stage(string dll, string stageRoot, RefIndex refs)
{
var frameworkIndex = refs.IndexFor(TargetFrameworkOf(dll));
var dir = Path.Combine(stageRoot, StageName(dll));
Directory.CreateDirectory(dir);
// Whatever sat next to the assembly keeps sitting next to it, so staging never
// resolves LESS than decompiling in place would.
foreach (var sibling in Directory.EnumerateFiles(Path.GetDirectoryName(Path.GetFullPath(dll))!, "*.dll"))
Link(sibling, dir);
Link(dll, dir);
var missing = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// The flag records whether a name was reached through a reference assembly, which
// decides whether its absence is worth reporting.
var queue = new Queue<(string Name, bool ViaRefPack)>();
foreach (var name in ReferencesOf(dll))
queue.Enqueue((name, false));
while (queue.Count > 0)
{
var (name, viaRefPack) = queue.Dequeue();
if (!seen.Add(name))
continue;
var staged = Path.Combine(dir, name + ".dll");
if (!File.Exists(staged))
{
var found = refs.Find(name, frameworkIndex);
if (found == null)
{
// Reference assemblies name GAC-only internals - SMDiagnostics and
// System.ServiceModel.Internals are referenced from twenty .NET Framework
// ref assemblies and ship in no pack and no package. Nothing can supply
// them, so listing them as coverable by --refs is a false lead.
if (!viaRefPack)
missing.Add(name);
continue;
}
Link(found, dir);
}
// A staged reference brings its own references along: the type system follows
// base types and type-forwards across the whole closure, not just one hop.
var transitiveViaRefPack = viaRefPack || frameworkIndex.ContainsKey(name);
foreach (var transitive in ReferencesOf(staged))
queue.Enqueue((transitive, transitiveViaRefPack));
}
missing.Sort(StringComparer.OrdinalIgnoreCase);
return (Path.Combine(dir, Path.GetFileName(dll)), missing);
}
// Distinct per source path: two packages can ship the same assembly name with
// different contents, and they must not share a staging directory.
static string StageName(string dll)
{
var full = Path.GetFullPath(dll);
var hash = Convert.ToHexString(System.Security.Cryptography.MD5.HashData(Encoding.UTF8.GetBytes(full)))[..8];
return $"{Path.GetFileNameWithoutExtension(full)}-{hash}";
}
static void Link(string source, string targetDir)
{
var link = Path.Combine(targetDir, Path.GetFileName(source));
if (File.Exists(link))
return;
try
{
File.CreateSymbolicLink(link, Path.GetFullPath(source));
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
// Windows only hands out symlink privileges to elevated processes or with
// Developer Mode enabled; copying costs disk but keeps staging working.
File.Copy(source, link, overwrite: true);
}
}
// The assembly's TargetFrameworkAttribute value (e.g. ".NETCoreApp,Version=v9.0"), or null
// when it carries none - which is normal for the .NET Framework era and for ref assemblies.
public static string? TargetFrameworkOf(string dll)
{
try
{
using var stream = File.OpenRead(dll);
using var pe = new PEReader(stream);
if (!pe.HasMetadata)
return null;
var md = pe.GetMetadataReader();
foreach (var handle in md.GetAssemblyDefinition().GetCustomAttributes())
{
var attr = md.GetCustomAttribute(handle);
if (attr.Constructor.Kind != HandleKind.MemberReference)
continue;
var ctor = md.GetMemberReference((MemberReferenceHandle)attr.Constructor);
if (ctor.Parent.Kind != HandleKind.TypeReference)
continue;
var type = md.GetTypeReference((TypeReferenceHandle)ctor.Parent);
if (md.GetString(type.Name) != "TargetFrameworkAttribute")
continue;
// blob: prolog (0x0001), then a SerString holding the moniker.
var reader = md.GetBlobReader(attr.Value);
if (reader.ReadUInt16() != 1)
return null;
return reader.ReadSerializedString();
}
}
catch (Exception ex) when (ex is BadImageFormatException or IOException)
{
// Native or corrupt file: nothing to read.
}
return null;
}
public static List<string> ReferencesOf(string dll)
{
var names = new List<string>();
try
{
using var stream = File.OpenRead(dll);
using var pe = new PEReader(stream);
if (!pe.HasMetadata)
return names;
var md = pe.GetMetadataReader();
foreach (var handle in md.AssemblyReferences)
names.Add(md.GetString(md.GetAssemblyReference(handle).Name));
}
catch (Exception ex) when (ex is BadImageFormatException or IOException)
{
// Native or corrupt file: it carries no managed references to follow.
}
return names;
}
}
// Self-contained HTML view of a run: the same numbers summary.md carries, plus the
// actual diff of every changed type inline, so a corpus sweep can be surveyed in a
// browser instead of by shelling out to `git diff --no-index` per type. No assets,
// no scripts from anywhere - the file opens straight off disk.
static class Report
{
public static void WriteHtml(string path, ReportModel m)
{
var html = new StringBuilder();
html.AppendLine("""
<!doctype html><html><head><meta charset="utf-8">
<title>decompdiff report</title>
<style>
:root { color-scheme: light dark; --bg:#fff; --fg:#1a1a1a; --muted:#666; --line:#d8d8d8;
--add:#e6ffec; --addfg:#0a5c1e; --del:#ffebe9; --delfg:#8a1c11; --chip:#f0f0f0; }
@media (prefers-color-scheme: dark) {
:root { --bg:#16181c; --fg:#e6e6e6; --muted:#9aa0a6; --line:#333;
--add:#12261a; --addfg:#7ee2a8; --del:#2b1416; --delfg:#ff9c92; --chip:#24262b; }
}
body { background:var(--bg); color:var(--fg); font:14px/1.5 system-ui,sans-serif; margin:0 auto; padding:24px; max-width:1100px; }
h1 { font-size:20px; margin:0 0 4px; } h2 { font-size:16px; margin:28px 0 8px; }
.meta { color:var(--muted); font-size:13px; }
table { border-collapse:collapse; margin:12px 0; } th,td { border:1px solid var(--line); padding:4px 10px; text-align:right; }
th:first-child,td:first-child { text-align:left; }
.pos { color:var(--delfg); } .neg { color:var(--addfg); }
details { border:1px solid var(--line); border-radius:6px; margin:6px 0; background:var(--chip); }
summary { cursor:pointer; padding:8px 10px; font-family:ui-monospace,monospace; font-size:13px; }
pre { margin:0; padding:10px; overflow-x:auto; background:var(--bg); font:12px/1.45 ui-monospace,monospace; }
ins { background:var(--add); color:var(--addfg); text-decoration:none; display:block; }
del { background:var(--del); color:var(--delfg); text-decoration:none; display:block; }
span.ctx { display:block; color:var(--muted); }
span.hunk { display:block; color:var(--muted); opacity:0.7; }
#filter { width:100%; padding:8px; margin:8px 0; border:1px solid var(--line); border-radius:6px;
background:var(--bg); color:var(--fg); font:13px ui-monospace,monospace; }
ul { padding-left:20px; } li { font-family:ui-monospace,monospace; font-size:12.5px; }
</style></head><body>
""");
html.AppendLine("<h1>decompdiff report</h1>");
html.AppendLine($"<div class=meta>old: {Esc(m.Old)}<br>new: {Esc(m.New)}<br>"
+ $"corpus: {m.Assemblies} assemblies, {m.Unchanged + m.Changed.Count} types compared</div>");
html.AppendLine("<table><tr><th>metric</th><th>old</th><th>new</th><th>delta</th></tr>");
html.AppendLine(Row("lines", m.OldTotals.Lines, m.NewTotals.Lines));
html.AppendLine(Row("goto statements", m.OldTotals.Gotos, m.NewTotals.Gotos));
html.AppendLine(Row("//IL_ warning comments", m.OldTotals.IlWarnings, m.NewTotals.IlWarnings));
html.AppendLine(Row("compiler-generated name leaks", m.OldTotals.GeneratedNames, m.NewTotals.GeneratedNames));
html.AppendLine("</table>");
html.AppendLine($"<div class=meta>{m.Unchanged} unchanged, {m.Changed.Count} changed, "
+ $"<b>{m.NewErrors} NEW errors</b>, {m.FixedErrors} fixed errors, {m.BothErrors} errored in both</div>");
if (m.Changed.Count > 0)
{
html.AppendLine($"<h2>Changed types ({m.Changed.Count})</h2>");
html.AppendLine("<input id=filter placeholder='filter by type or assembly name'>");
foreach (var c in m.Changed.OrderByDescending(c => Math.Abs(c.New.Lines - c.Old.Lines)))
{
var file = SanitizeFileName(c.Location.Replace(" / ", "/")) + ".cs";
var delta = c.New.Lines - c.Old.Lines;
html.AppendLine($"<details><summary>{Esc(c.Location)} "
+ $"<span class=meta>({c.Old.Lines} &rarr; {c.New.Lines} lines, {delta:+#;-#;0})</span></summary>");
html.AppendLine($"<pre>{Diff(Path.Combine(m.ReportDir, "old", file), Path.Combine(m.ReportDir, "new", file))}</pre></details>");
}
html.AppendLine("""
<script>
const box = document.getElementById('filter');
box.addEventListener('input', () => {
const needle = box.value.toLowerCase();
for (const d of document.querySelectorAll('details'))
d.style.display = d.querySelector('summary').textContent.toLowerCase().includes(needle) ? '' : 'none';
});
</script>
""");
}
AppendList(html, "Error / presence transitions", m.Transitions);
AppendList(html, "Skipped assemblies (undecompilable on both sides)", m.Skipped);
AppendList(html, "Unresolved references (pass --refs to cover these)",
m.UnresolvedRefs.Select(kv => $"{kv.Key}: {string.Join(", ", kv.Value)}").ToList());
html.AppendLine("</body></html>");
File.WriteAllText(path, html.ToString());
}
static void AppendList(StringBuilder html, string title, List<string> items)
{
if (items.Count == 0)
return;
html.AppendLine($"<h2>{Esc(title)} ({items.Count})</h2><ul>");
foreach (var item in items)
html.AppendLine($"<li>{Esc(item)}</li>");
html.AppendLine("</ul>");
}
static string Row(string name, long o, long n)
{
var delta = n - o;
var cls = delta == 0 ? "" : delta > 0 ? " class=pos" : " class=neg";
return $"<tr><td>{Esc(name)}</td><td>{o}</td><td>{n}</td><td{cls}>{delta:+#;-#;0}</td></tr>";
}
// Delegates the actual line diffing to `git diff --no-index`: it has a real
// Myers/patience implementation with move detection and heuristics tuned over two
// decades, which a hand-rolled LCS here could never match - and its -U3 context
// windowing is exactly what the previous prefix/suffix trimming was trying to fake.
// Both files were already written to disk by DumpPair, so they are diffed in place.
static string Diff(string oldFile, string newFile)
{
var psi = new ProcessStartInfo("git") {
RedirectStandardOutput = true,
RedirectStandardError = true,
};
foreach (var arg in new[] { "diff", "--no-index", "--no-color", "-U3", "--", ExistingOrNullDevice(oldFile), ExistingOrNullDevice(newFile) })
psi.ArgumentList.Add(arg);
using var p = Process.Start(psi)!;
var output = p.StandardOutput.ReadToEnd();
p.WaitForExit();
// exit code 1 just means differences were found; anything else is a real failure.
return p.ExitCode is 0 or 1 ? RenderUnifiedDiff(output) : Esc(p.StandardError.ReadToEnd());
}
// A changed type is only ever missing one side's file when it appeared/disappeared
// entirely, which DumpPair never does for a "changed" entry - kept defensive anyway.
static string ExistingOrNullDevice(string path)
=> File.Exists(path) ? path : (OperatingSystem.IsWindows() ? "NUL" : "/dev/null");
// Renders a unified diff body (as produced by `git diff`) as HTML, skipping the
// file-header lines (diff/index/---/+++) which name the throwaway temp files.
static string RenderUnifiedDiff(string unifiedDiff)
{
var sb = new StringBuilder();
foreach (var line in unifiedDiff.ReplaceLineEndings("\n").Split('\n'))
{
if (line.Length == 0 || line.StartsWith("diff --git") || line.StartsWith("index ")
|| line.StartsWith("--- ") || line.StartsWith("+++ ") || line.StartsWith("\\ No newline"))
continue;
if (line.StartsWith("@@"))
{
sb.Append("<span class=hunk>").Append(Esc(line)).Append("</span>");
continue;
}
var tag = line[0];
sb.Append(tag switch { '+' => "<ins>", '-' => "<del>", _ => "<span class=ctx>" })
.Append(Esc(line[1..]))
.Append(tag switch { '+' => "</ins>", '-' => "</del>", _ => "</span>" });
}
return sb.ToString();
}
static string Esc(string s) => s.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
// '/' survives as a directory separator; everything the platform rejects becomes '_'.
// Long segments are truncated with a hash of the original appended, because a
// namespace-qualified generic type name can push a report path past the 260-character
// limit Windows applies unless long paths are enabled machine-wide.
public static string SanitizeFileName(string s)
=> string.Join('/', s.Split('/').Select(segment => {
var clean = string.Concat(segment.Select(
ch => Path.GetInvalidFileNameChars().Contains(ch) ? '_' : ch));
return clean.Length <= 80
? clean
: clean[..72] + Convert.ToHexString(
System.Security.Cryptography.MD5.HashData(Encoding.UTF8.GetBytes(clean)))[..8];
}));
}
record ReportModel(
string Old, string New, int Assemblies, int Unchanged, List<ChangedType> Changed,
List<string> Transitions, List<string> Skipped, SortedDictionary<string, List<string>> UnresolvedRefs,
Metrics OldTotals, Metrics NewTotals, int NewErrors, int FixedErrors, int BothErrors, string ReportDir);
record TypeResult(string? Code, string? Error, Metrics Metrics);
record ChangedType(string Location, Metrics Old, Metrics New);
record struct Metrics(int Lines, int Gotos, int IlWarnings, int GeneratedNames)
{
public static Metrics Measure(string code) => new(
code.Count(c => c == '\n') + 1,
Regex.Matches(code, @"\bgoto ").Count,
Regex.Matches(code, @"//IL_[0-9a-fA-F]+:").Count,
Regex.Matches(code, @"<>").Count);
public static Metrics operator +(Metrics a, Metrics b)
=> new(a.Lines + b.Lines, a.Gotos + b.Gotos, a.IlWarnings + b.IlWarnings, a.GeneratedNames + b.GeneratedNames);
}
// One decompiler version: locates/builds ICSharpCode.Decompiler.dll, loads it in
// its own AssemblyLoadContext, and drives it via `dynamic` through the stable
// CSharpDecompiler(string, DecompilerSettings) API so any two versions work.
class Side
{
readonly Assembly assembly;
public string Description { get; }
public string? LastAssemblyError { get; private set; }
Side(Assembly assembly, string description)
{
this.assembly = assembly;
Description = description;
}
public static Side Create(string name, string spec, bool skipBuild)
{
string dllPath;
string description;
if (File.Exists(spec))
{
dllPath = Path.GetFullPath(spec);
description = dllPath;
}
else if (Directory.Exists(spec))
{
var checkout = Path.GetFullPath(spec);
dllPath = BuildCheckout(checkout, skipBuild);
// The dll timestamp is in the header so a --no-build run says what it measured.
description = $"{checkout} ({GitDescribe(checkout)}, dll of {File.GetLastWriteTime(dllPath):yyyy-MM-dd HH:mm})";
}
else if (TryResolveCommit(spec, out var commit, out var repoRoot))
{
// A git ref, so a PR or a tag can be diffed without preparing checkouts by hand.
// The worktree is keyed by commit and kept: reusing it reuses the Release build
// already sitting in its bin/, which is what dominates the runtime of a rerun.
var checkout = EnsureWorktree(repoRoot, commit);
dllPath = BuildCheckout(checkout, skipBuild);
description = $"{spec} ({commit[..9]}, dll of {File.GetLastWriteTime(dllPath):yyyy-MM-dd HH:mm})";
}
else
{
throw new ArgumentException(
$"--{name} {spec}: not a file, a directory, or a commit-ish in the repository at {Environment.CurrentDirectory}");
}
var alc = new DecompilerLoadContext(name, dllPath);
return new Side(alc.LoadFromAssemblyPath(dllPath), description);
}
static string BuildCheckout(string checkout, bool skipBuild)
{
var csproj = Path.Combine(checkout, "ICSharpCode.Decompiler", "ICSharpCode.Decompiler.csproj");
if (!File.Exists(csproj))
throw new ArgumentException($"{checkout}: no ICSharpCode.Decompiler/ICSharpCode.Decompiler.csproj");
var binDir = Path.Combine(checkout, "ICSharpCode.Decompiler", "bin", "Release");
var existing = Directory.Exists(binDir)
? Directory.EnumerateFiles(binDir, "ICSharpCode.Decompiler.dll", SearchOption.AllDirectories)
.OrderByDescending(File.GetLastWriteTimeUtc).FirstOrDefault()
: null;
if (existing != null && skipBuild)
{
Console.Error.WriteLine($" warning: --no-build, reusing {existing}");
Console.Error.WriteLine($" warning: built {File.GetLastWriteTime(existing):yyyy-MM-dd HH:mm} - if that predates the checkout's"
+ " last change, the diff describes code neither side is on");
return existing;
}
// A bare restore would prune the repo's packages.lock.json files; keep them whole.
Run("dotnet", $"restore \"{csproj}\" -p:RestoreEnablePackagePruning=false");
Run("dotnet", $"build \"{csproj}\" -c Release --no-restore");
return Directory.EnumerateFiles(binDir, "ICSharpCode.Decompiler.dll", SearchOption.AllDirectories)
.OrderByDescending(File.GetLastWriteTimeUtc).First();
}
static void Run(string exe, string arguments)
{
Console.WriteLine($" $ {exe} {arguments}");
var psi = new ProcessStartInfo(exe, arguments) {
RedirectStandardOutput = true,
RedirectStandardError = true,
};
using var p = Process.Start(psi)!;
var output = p.StandardOutput.ReadToEnd() + p.StandardError.ReadToEnd();
p.WaitForExit();
if (p.ExitCode != 0)
throw new InvalidOperationException($"{exe} {arguments} failed:\n{output}");
}
// Resolves a commit-ish against the repository the tool is run from. Files and directories
// win over refs, so a branch sharing a name with a directory still needs the path spelled out.
static bool TryResolveCommit(string spec, out string commit, out string repoRoot)
{
commit = "";
repoRoot = "";
var root = Git(Environment.CurrentDirectory, "rev-parse", "--show-toplevel");
if (root == null)
return false;
var resolved = Git(root, "rev-parse", "--verify", "--quiet", spec + "^{commit}");
if (string.IsNullOrEmpty(resolved))
return false;
commit = resolved;
repoRoot = root;
return true;
}
// Worktrees live outside the repository, so they never show up in its status or get
// swept up by a clean; `git worktree list` still shows them, and they are safe to delete.
static string EnsureWorktree(string repoRoot, string commit)
{
var dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".cache", "decompdiff", Path.GetFileName(repoRoot), commit);
if (Directory.Exists(dir))
return dir;
Directory.CreateDirectory(Path.GetDirectoryName(dir)!);
Console.WriteLine($" $ git worktree add --detach {dir} {commit[..9]}");
if (Git(repoRoot, "worktree", "add", "--detach", dir, commit) == null)
throw new InvalidOperationException($"git worktree add failed for {commit}");
return dir;
}
// Runs git and returns its trimmed stdout, or null if it could not be run or failed.
static string? Git(string workingDirectory, params string[] arguments)
{
try
{
var psi = new ProcessStartInfo("git") {
WorkingDirectory = workingDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
foreach (var argument in arguments)
psi.ArgumentList.Add(argument);
using var p = Process.Start(psi)!;
var output = p.StandardOutput.ReadToEnd().Trim();
p.WaitForExit();
return p.ExitCode == 0 ? output : null;
}
catch
{
return null;
}
}
static string GitDescribe(string checkout)
{
var output = Git(checkout, "describe", "--always", "--dirty", "--exclude", "*");
return string.IsNullOrEmpty(output) ? "unknown" : output;
}
// Decompiles every top-level type; null when the assembly itself cannot be
// opened (not managed, type system init failed) - see LastAssemblyError.
public Dictionary<string, TypeResult>? DecompileAssembly(string dllPath)
{
LastAssemblyError = null;
dynamic decompiler;
try
{
var settings = Activator.CreateInstance(assembly.GetType("ICSharpCode.Decompiler.DecompilerSettings", throwOnError: true)!)!;
decompiler = Activator.CreateInstance(
assembly.GetType("ICSharpCode.Decompiler.CSharp.CSharpDecompiler", throwOnError: true)!,
dllPath, settings)!;
}
catch (Exception ex)
{
LastAssemblyError = Unwrap(ex).Message;
return null;
}
var results = new Dictionary<string, TypeResult>();
foreach (object type in (IEnumerable)decompiler.TypeSystem.MainModule.TopLevelTypeDefinitions)
{
// The type definition's runtime type is internal, so dynamic cannot bind
// its members; go through the public interface property via reflection.
object fullTypeName = GetProperty(type, "FullTypeName");
string name = fullTypeName.ToString()!;
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
try
{
decompiler.CancellationToken = cts.Token;
string code = decompiler.DecompileTypeAsString((dynamic)fullTypeName);
results[name] = new TypeResult(code, null, Metrics.Measure(code));
}
catch (Exception ex)
{
var inner = Unwrap(ex);
var error = inner is OperationCanceledException
? "timeout (60s)"
: $"{inner.GetType().Name}: {FirstLine(inner.Message)}";
results[name] = new TypeResult(null, error, default);
}
}
return results;
}
static object GetProperty(object obj, string name)
{
var type = obj.GetType();
var property = type.GetProperty(name)
?? type.GetInterfaces().Select(i => i.GetProperty(name)).FirstOrDefault(p => p != null)
?? throw new MissingMemberException(type.FullName, name);
return property.GetValue(obj)!;
}
static Exception Unwrap(Exception ex)
{
while (ex is TargetInvocationException { InnerException: not null } tie)
ex = tie.InnerException!;
return ex;
}
static string FirstLine(string s)
{
var i = s.IndexOfAny(['\r', '\n']);
return i < 0 ? s : s[..i];
}
}
// Resolves the decompiler's own dependencies from its build-output directory,
// falling back to the default context for framework assemblies. Each Side gets
// its own context so two ICSharpCode.Decompiler versions can coexist.
class DecompilerLoadContext(string name, string mainDllPath) : AssemblyLoadContext(name)
{
readonly string dir = Path.GetDirectoryName(Path.GetFullPath(mainDllPath))!;
protected override Assembly? Load(AssemblyName assemblyName)
{
var candidate = Path.Combine(dir, assemblyName.Name + ".dll");
return File.Exists(candidate) ? LoadFromAssemblyPath(candidate) : null;
}
}
class AssertionFailedException(string message) : Exception(message);
class ThrowOnAssert : TraceListener
{
public override void Fail(string? message, string? detailMessage)
=> throw new AssertionFailedException($"{message} {detailMessage}".Trim());
public override void Write(string? message)
{
}
public override void WriteLine(string? message)
{
}
}