Files
ILSpy/TestTools/decompdiff.cs
T
Siegfried Pammer 1da62d9e08 Add TestTools with the nugetfuzz and decompdiff corpus tools
Both tools have found real decompiler defects (several merged fixes came out of
nugetfuzz sweeps), but they only existed in a private checkout, so nobody else
could run them and their setup knowledge lived in one head. They complement the
fixture suite from the other side: it decompiles code we wrote, these decompile
what the world ships.

They stay outside the solution - file-based apps, run by hand, never by CI - and
the near-empty Directory.Build.props/Directory.Packages.props keep the repo-wide
warnings-as-errors, lock-file and central-package-management settings from
reaching them.

The catalog sweep driver is PowerShell rather than bash so it runs on Windows as
well, which also drops its curl/jq dependency; staging falls back to copying when
Windows withholds symlink privileges, and report file names are hash-truncated to
stay inside the 260-character path limit.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
2026-08-16 12:15:09 +02:00

877 lines
34 KiB
C#

// 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 <ILSpy-checkout|Decompiler.dll> --new <ILSpy-checkout|Decompiler.dll>
// [-o <report-dir>] [--build] [--refs <dir>]... <dll|dir>...
//
// - checkout args are built on demand (Release; restore keeps packages.lock.json
// whole via -p:RestoreEnablePackagePruning=false); pass --build to force rebuild.
// - corpus dirs are scanned recursively for *.dll (e.g. ~/.cache/nugetfuzz).
// - 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, and
// the .NET Framework reference-assembly packs. 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 forceBuild = 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":
forceBuild = true;
break;
case "--refs":
refDirs.Add(args[++i]);
break;
default:
corpus.Add(args[i]);
break;
}
}
if (oldSpec == null || newSpec == null || corpus.Count == 0)
{
Console.Error.WriteLine("usage: decompdiff --old <ILSpy-checkout|Decompiler.dll> --new <...> [-o report-dir] [--build] [--refs <dir>]... <dll|dir>...");
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);
var oldSide = Side.Create("old", oldSpec, forceBuild);
var newSide = Side.Create("new", newSpec, forceBuild);
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 --refs directories (indexed once), the
// machine-wide NuGet cache (probed per name, so nothing scans ~40k packages), and
// the .NET Framework reference-assembly packs restored under it - the last one is
// what makes classic net4x assemblies decompilable on Linux at all, since their
// 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();
public string Description { get; }
public RefIndex(List<string> refDirs, List<string> corpus)
{
foreach (var dir in refDirs.Concat(corpus).Where(Directory.Exists))
IndexDirectory(dir);
var nugetRoot = Environment.GetEnvironmentVariable("NUGET_PACKAGES")
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages");
if (Directory.Exists(nugetRoot))
probeRoots.Add(nugetRoot);
foreach (var pack in EnumerateFrameworkRefPacks(nugetRoot))
IndexDirectory(pack);
// 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))
IndexDirectory(installedFxRefs);
Description = $"{byName.Count} assemblies indexed"
+ (probeRoots.Count > 0 ? $", NuGet cache probe at {string.Join(", ", probeRoots)}" : "");
}
// Reference assemblies for .NET Framework targets; the newest pack wins, and its
// Facades subdirectory carries the type-forwarding shims netstandard code needs.
static IEnumerable<string> EnumerateFrameworkRefPacks(string nugetRoot)
{
if (!Directory.Exists(nugetRoot))
yield break;
foreach (var pkg in Directory.EnumerateDirectories(nugetRoot, "microsoft.netframework.referenceassemblies.*")
.OrderBy(d => d))
{
foreach (var dir in Directory.EnumerateDirectories(pkg, "v*", SearchOption.AllDirectories))
{
yield return dir;
var facades = Path.Combine(dir, "Facades");
if (Directory.Exists(facades))
yield return facades;
}
}
}
void IndexDirectory(string dir)
{
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 (!byName.ContainsKey(name))
byName[name] = dll;
}
}
public string? Find(string simpleName)
{
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 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);
var queue = new Queue<string>();
foreach (var name in ReferencesOf(dll))
queue.Enqueue(name);
while (queue.Count > 0)
{
var name = queue.Dequeue();
if (!seen.Add(name))
continue;
var staged = Path.Combine(dir, name + ".dll");
if (!File.Exists(staged))
{
var found = refs.Find(name);
if (found == null)
{
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.
foreach (var transitive in ReferencesOf(staged))
queue.Enqueue(transitive);
}
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);
}
}
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); }
#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 oldCode = ReadIfExists(Path.Combine(m.ReportDir, "old", file));
var newCode = ReadIfExists(Path.Combine(m.ReportDir, "new", file));
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(oldCode, newCode)}</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>";
}
static string ReadIfExists(string path) => File.Exists(path) ? File.ReadAllText(path) : "";
// Line diff: common prefix/suffix are cheap to strip and usually account for nearly
// everything, leaving a middle small enough for an O(n*m) LCS. Beyond the cap the
// middle is shown as a plain replacement rather than spending minutes on alignment.
const int LcsCap = 1500;
static string Diff(string oldCode, string newCode)
{
var a = oldCode.ReplaceLineEndings("\n").Split('\n');
var b = newCode.ReplaceLineEndings("\n").Split('\n');
int start = 0;
while (start < a.Length && start < b.Length && a[start] == b[start])
start++;
int endA = a.Length, endB = b.Length;
while (endA > start && endB > start && a[endA - 1] == b[endB - 1])
{
endA--;
endB--;
}
var sb = new StringBuilder();
// A few lines of context on each side make the hunk readable on its own.
for (int i = Math.Max(0, start - 3); i < start; i++)
sb.Append("<span class=ctx>").Append(Esc(a[i])).Append("</span>");
int lenA = endA - start, lenB = endB - start;
if (lenA <= LcsCap && lenB <= LcsCap)
{
foreach (var (tag, line) in LcsDiff(a[start..endA], b[start..endB]))
sb.Append(tag switch { '+' => "<ins>", '-' => "<del>", _ => "<span class=ctx>" })
.Append(Esc(line))
.Append(tag switch { '+' => "</ins>", '-' => "</del>", _ => "</span>" });
}
else
{
for (int i = start; i < endA; i++)
sb.Append("<del>").Append(Esc(a[i])).Append("</del>");
for (int i = start; i < endB; i++)
sb.Append("<ins>").Append(Esc(b[i])).Append("</ins>");
}
for (int i = endA; i < Math.Min(a.Length, endA + 3); i++)
sb.Append("<span class=ctx>").Append(Esc(a[i])).Append("</span>");
return sb.ToString();
}
static List<(char Tag, string Line)> LcsDiff(string[] a, string[] b)
{
var lcs = new int[a.Length + 1, b.Length + 1];
for (int i = a.Length - 1; i >= 0; i--)
for (int j = b.Length - 1; j >= 0; j--)
lcs[i, j] = a[i] == b[j] ? lcs[i + 1, j + 1] + 1 : Math.Max(lcs[i + 1, j], lcs[i, j + 1]);
var result = new List<(char, string)>();
int x = 0, y = 0;
while (x < a.Length && y < b.Length)
{
if (a[x] == b[y])
{
result.Add((' ', a[x]));
x++;
y++;
}
else if (lcs[x + 1, y] >= lcs[x, y + 1])
{
result.Add(('-', a[x++]));
}
else
{
result.Add(('+', b[y++]));
}
}
while (x < a.Length)
result.Add(('-', a[x++]));
while (y < b.Length)
result.Add(('+', b[y++]));
return result;
}
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 forceBuild)
{
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, forceBuild);
// The dll timestamp exposes stale pre-existing builds; --build forces a fresh one.
description = $"{checkout} ({GitDescribe(checkout)}, dll of {File.GetLastWriteTime(dllPath):yyyy-MM-dd HH:mm})";
}
else
{
throw new ArgumentException($"--{name} {spec}: no such file or directory");
}
var alc = new DecompilerLoadContext(name, dllPath);
return new Side(alc.LoadFromAssemblyPath(dllPath), description);
}
static string BuildCheckout(string checkout, bool forceBuild)
{
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 && !forceBuild)
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}");
}
static string GitDescribe(string checkout)
{
try
{
var psi = new ProcessStartInfo("git", "describe --always --dirty --exclude *") {
WorkingDirectory = checkout,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
using var p = Process.Start(psi)!;
var output = p.StandardOutput.ReadToEnd().Trim();
p.WaitForExit();
return p.ExitCode == 0 && output.Length > 0 ? output : "unknown";
}
catch
{
return "unknown";
}
}
// 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)
{
}
}