diff --git a/CLAUDE.md b/CLAUDE.md
index f8c53b8c09..95c397e7b5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -37,6 +37,7 @@ Cross-platform core (decompiler engine + shared support):
Test support:
- `TestPlugin/` — sample plugin exercising the plugin-loading system (`net10.0`)
- `TestFixtures.Resources/` — generates resource fixtures consumed by the decompiler tests
+- `TestTools/` — file-based apps run by hand against real-world assemblies: `nugetfuzz` (crash/assert sweep over nuget.org packages) and `decompdiff` (output diff between two decompiler builds). In no solution and not run by CI; see `TestTools/README.md`
Windows-only frontends, packaging, and tests:
- `ILSpy.AddIn/`, `ILSpy.AddIn.VS2022/` — Visual Studio add-ins (`net472`)
diff --git a/TestTools/.gitignore b/TestTools/.gitignore
new file mode 100644
index 0000000000..e716715471
--- /dev/null
+++ b/TestTools/.gitignore
@@ -0,0 +1,12 @@
+# Run artifacts: crawl state and per-package logs are regenerated by every sweep
+# and grow into the tens of megabytes.
+crawl/
+logs/
+
+# Generated reports (nugetfuzz-report.html, decompdiff report dirs)
+*-report.html
+decompdiff-report/
+
+# dotnet file-based app build output
+bin/
+obj/
diff --git a/TestTools/Directory.Build.props b/TestTools/Directory.Build.props
new file mode 100644
index 0000000000..5426d0f249
--- /dev/null
+++ b/TestTools/Directory.Build.props
@@ -0,0 +1,5 @@
+
+
+
diff --git a/TestTools/Directory.Packages.props b/TestTools/Directory.Packages.props
new file mode 100644
index 0000000000..1e333bf884
--- /dev/null
+++ b/TestTools/Directory.Packages.props
@@ -0,0 +1,7 @@
+
+
+
+ false
+
+
diff --git a/TestTools/README.md b/TestTools/README.md
new file mode 100644
index 0000000000..cf93ce3a85
--- /dev/null
+++ b/TestTools/README.md
@@ -0,0 +1,95 @@
+# TestTools
+
+Two standalone tools that run the decompiler over real-world assemblies, to find defects the
+in-repo test suite cannot: it decompiles fixtures we wrote, these decompile what the world ships.
+
+| tool | question it answers |
+|---|---|
+| `nugetfuzz.cs` | Does the decompiler *crash* on real code? (asserts, exceptions, IL warnings) |
+| `decompdiff.cs` | Did a change make the *output* better or worse? (readability across two builds) |
+
+Both are [file-based apps](https://learn.microsoft.com/dotnet/core/whats-new/dotnet-10/sdk#file-based-apps):
+single `.cs` files run directly by the SDK, no project, no solution entry. They are not built by
+`ILSpy.sln` and not run by CI. Requirements: the .NET SDK from `global.json` (or newer) and, for
+the sweep script, PowerShell 7 (`pwsh`) - both cross-platform.
+
+`Directory.Build.props` / `Directory.Packages.props` in this folder are intentionally near-empty:
+they stop MSBuild's upward search, so the repo-wide warnings-as-errors, lock-file and central
+package management settings do not reach these tools.
+
+## nugetfuzz
+
+Downloads NuGet packages, resolves their dependency closure, picks a matching lib TFM, and
+decompiles every type of every assembly, reporting `Debug.Assert` failures, exceptions and
+`//IL_xxxx:` warning comments. Exit code 0 means no finding.
+
+```pwsh
+dotnet run nugetfuzz.cs -- Newtonsoft.Json Serilog@3.1.1
+dotnet run nugetfuzz.cs -- @packagelist.txt # one id per line, # comments allowed
+dotnet run nugetfuzz.cs -- --report crawl/findings.jsonl [out.html]
+```
+
+Reference assemblies are fetched as needed: `Microsoft.NETCore.App.Ref` and the Windows-desktop /
+ASP.NET packs for .NET Core targets, `Microsoft.NETFramework.ReferenceAssemblies` for classic
+net4x. Getting these right matters - binding a WPF assembly against the stub facades in
+`NETCore.App.Ref` collapses whole type hierarchies to `Unknown` and invents hundreds of bogus
+warnings, so treat a sudden warning spike as a reference problem until proven otherwise.
+
+Environment variables: `NUGETFUZZ_VERBOSE` (per-type progress), `NUGETFUZZ_DUMP=
` (write the
+decompiled C#), `NUGETFUZZ_LEDGER=` (append findings as JSONL instead of writing a
+per-run HTML report), `NUGETFUZZ_HTML=` (report path), `NUGET_PACKAGES` (package cache).
+
+### Sweeping the whole catalog
+
+`nugetfuzz-all.ps1` walks the nuget.org catalog and runs `nugetfuzz.cs` on every package id it
+has not seen. It is resumable - the page cursor and the seen-id list live in `crawl/`, so an
+interrupted sweep continues where it stopped:
+
+```pwsh
+./nugetfuzz-all.ps1 # everything, from the cursor
+./nugetfuzz-all.ps1 -MaxPages 5 -MaxPackages 50
+```
+
+Findings from every package land in `crawl/findings.jsonl`; render the aggregate at any time,
+including while the sweep is still running, with `--report`. Logs of failed runs are kept in
+`logs/`, successful ones are deleted. The package cache (`~/.cache/nugetfuzz`) is capped at 20 GB
+by default (`-CacheCapMB`) and pruned least-recently-used, because `decompdiff` uses it as a corpus.
+
+## decompdiff
+
+Decompiles a corpus with **two** builds of `ICSharpCode.Decompiler` side by side (separate
+`AssemblyLoadContext`s, driven through the stable `CSharpDecompiler(string, DecompilerSettings)`
+API, so arbitrary version pairs work) and reports how the output differs. Correctness is what the
+round-trip tests check; this checks readability. Exit code 1 means the new side has errors the old
+side did not.
+
+```pwsh
+dotnet run decompdiff.cs -- --old ../../ILSpy-master --new . -o report ~/.cache/nugetfuzz
+dotnet run decompdiff.cs -- --old v9.1.dll --new v11.dll --refs corpus.dll
+```
+
+An `--old`/`--new` argument is either a path to `ICSharpCode.Decompiler.dll` or an ILSpy checkout,
+which is restored and built in Release on demand. **Watch the timestamp in the header line**: an
+existing Release build is reused as-is; pass `--build` to force a rebuild.
+
+The report directory gets `summary.md`, a self-contained `index.html` with inline diffs, and the
+changed types dumped under `old/` and `new/` for `git diff --no-index report/old report/new`.
+
+Each assembly is decompiled from a staging directory holding its neighbours plus the transitive
+closure of its references, found in `--refs` directories, the NuGet cache and the .NET Framework
+reference packs. Both sides read the same staging directory, so anything still unresolved degrades
+them identically and the diff stays meaningful. Unresolved references are listed in the summary.
+
+## Windows notes
+
+Both tools run on Windows, with two differences worth knowing:
+
+- Staging uses symbolic links, which Windows only grants to elevated processes or with Developer
+ Mode enabled. Without it, the files are copied instead - correct, just slower and more disk.
+- Report file names are truncated with a hash appended when a namespace-qualified type name would
+ push the path past the 260-character limit that applies unless long paths are enabled.
+
+## Ignored output
+
+`crawl/`, `logs/`, generated reports and `bin/`/`obj/` are gitignored: they are run artifacts that
+grow into the gigabytes.
diff --git a/TestTools/decompdiff.cs b/TestTools/decompdiff.cs
new file mode 100644
index 0000000000..b8d5d62b29
--- /dev/null
+++ b/TestTools/decompdiff.cs
@@ -0,0 +1,876 @@
+// 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 --new
+// [-o ] [--build] [--refs ]... ...
+//
+// - 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 /{old,new}/...; inspect with
+// `git diff --no-index /old /new`, or open the generated
+// /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();
+var refDirs = new List();
+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 --new <...> [-o report-dir] [--build] [--refs ]... ...");
+ 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>(); // assembly -> missing refs
+
+int asmCount = 0, unchanged = 0;
+var skipped = new List();
+var changed = new List();
+var transitions = new List(); // 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();
+ 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 byName = new(StringComparer.OrdinalIgnoreCase);
+ readonly List probeRoots = new();
+ public string Description { get; }
+
+ public RefIndex(List refDirs, List 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 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 ///lib//.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 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();
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var queue = new Queue();
+ 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 ReferencesOf(string dll)
+ {
+ var names = new List();
+ 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("""
+
+ decompdiff report
+
+ """);
+ html.AppendLine("
");
+ foreach (var f in group)
+ {
+ // Only ever seen while references were missing: flagged, not hidden - the
+ // warning text itself blames missing references, so it is weak evidence.
+ var suspect = IsDegraded(f)
+ ? " refs incomplete"
+ : "";
+ html.AppendLine($"{f.Count}x "
+ + $"{Esc(f.ExceptionType)}: {Esc(f.Message)}{suspect}");
+ html.AppendLine($"