The PDB writer had no coverage beyond hand-written fixtures whose sequence
points are compared to the compiler's, and nothing ever asked whether a
consumer can read what it emits. Issue #2823 is the consequence: a PDB that
loads fine in ILSpy kills ILLink, and it took a reporter's own tool to find
out.
--pdb reuses the corpus, download and reference-pack machinery already in
nugetfuzz and replaces the type sweep with two checks: Mono.Cecil - the
consumer ILLink uses - has to read every method body through the generated
PDB, and a lint has to find everything the PDB claims true of the assembly.
--pdb-lint exists because a lint is only worth its findings if it is silent
on correct input. It runs the same checks against a PDB the compiler wrote,
and three of the checks written here were wrong until it said so - including
the async one, which flagged the compiler's own PDB for any executable with
an async Main, a case 190 nuget packages could not contain because a library
has no entry point.
Assisted-by: Claude:claude-opus-5:Claude Code
Reusing whatever Release build a checkout happened to carry made a run
measure code that neither side is on, and the only signal was a
timestamp in the header line that is easy to read past. Building is now
what happens unless --no-build asks for the fast path, which repeated
runs against unchanged sides still want; it says which dll it reused and
when that was built.
Assisted-by: Claude:claude-opus-5:Claude Code
Resolving one assembly resolves its whole reference closure, and every
reference in it asked the same framework directories the same questions.
The worst of it was the scan for the closest version folder of a shared
framework: a directory listing plus a recursive file search, repeated per
reference and per runtime pack - 42 scans for two distinct answers when
decompiling ICSharpCode.ILSpyX.dll.
The scan result is only safe to keep for a bounded time: a runtime can be
installed or removed while ILSpy runs, and reloading an assembly list has
to see that. So it is kept for the length of an explicitly opened scope,
which the type system opens around the closure it resolves and closes
again afterwards; outside a scope the file system is read as before. The
scope owns what was read, so two of them on one resolver do not stack -
the first to end takes it, and the other reads the file system again.
BeginSnapshot is on IAssemblyResolver rather than an interface of its
own: it is core functionality of a resolver, and one implementation is
not an abstraction. This breaks the interface for implementors outside
this repository, who opt out by returning null - which is what the three
resolvers here that hold nothing do.
The remaining probes cost nothing to fix: the preferred runtime pack was
listed among the defaults it already belongs to, so its directory was
scanned twice for every reference that is not in it, and one package
folder was probed once per assembly the package contains.
Measured over 27 references with a fresh resolver each time: 3.3 ms per
assembly before, 3.1 ms without a scope, 1.1 ms with one.
Assisted-by: Claude:claude-opus-5:Claude Code
The nugetfuzz package cache is usually where the corpus itself came from,
so it holds exactly the dependency closure a corpus assembly references;
probing it resolves references the machine-wide NuGet cache does not have.
Reference assemblies name GAC-only internals such as SMDiagnostics and
System.ServiceModel.Internals, which ship in no pack and no package.
Reporting them as missing suggested --refs could supply them, so the
staging walk now tracks whether a name was reached through a reference
pack and drops those from the report.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Handing the corpus over as `$(cat list)` is a bourne shell construct, so the
one invocation the tools print and the README documents did not work on
Windows. nugetfuzz already reads its package list from an @file for the same
reason; decompdiff now takes its corpus entries the same way, leaving the
shell out of it entirely.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Both tools need a corpus of real assemblies and neither had a way to get
one: nugetfuzz-all.ps1 walks the catalog in publish order, which is fine for
a crash sweep but makes a poor readability corpus, and the alternative was
picking package ids by hand.
The ids come from an empty search query, which orders by download count.
Downloading them reuses nugetfuzz, which already resolves versions, matches
target frameworks and walks the dependency closure into the same cache
decompdiff reads; --download-only stops it before it decompiles, since the
sweep is the expensive part and a corpus only needs the files.
The result is a list of lib directories rather than a single root, because a
package already restored on this machine is used from the machine-wide NuGet
cache instead of being copied into ours, and a corpus that silently omitted
those would misrepresent what was tested.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Comparing two commits meant preparing a worktree for each by hand before the
tool could be called, which is most of the work of running it and easy to get
wrong: a checkout carrying a stale Release build is reused silently, and the
timestamp in the header line was the only thing that said so.
--old and --new now also take anything git can resolve to a commit, checked
out into a worktree under ~/.cache/decompdiff keyed by that commit. The
worktrees are kept because the Release build inside one is what a rerun would
otherwise repeat: a second run of the same pair drops from minutes to seconds.
Paths keep priority over refs, so an existing directory never changes meaning.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
decompdiff indexed the Microsoft.NETFramework.ReferenceAssemblies packs into
one flat name map shared by the whole corpus, and never indexed
Microsoft.NETCore.App.Ref at all. Ordering the packs by name let net45 claim
mscorlib and System.Runtime, so a net9.0 assembly resolved its BCL against
.NET Framework 4.5: Task, ValueTask and the async method builders came back
as UnknownType, AsyncAwaitDecompiler could not match a state machine, and
every async method decompiled as a raw MoveNext.
Both sides of a diff degraded identically, so comparisons stayed valid, but
the corpus stopped representing modern code. Over 28 nuget assemblies the
same run reports 338 //IL_ warnings instead of 18202, 237 leaked <> names
instead of 15654, and 116k fewer lines.
The pack is now chosen per assembly from its TargetFrameworkAttribute and
searched before anything else, matching how nugetfuzz already resolves them.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
A stalled request to nuget.org travelled out of the package run as an unhandled
exception, so the report filed it as a decompiler [EXCEPTION] - the one bucket
that has to hold nothing but real crashes - and the package was skipped without
a single type being decompiled. Seen in the 2026-08-16 sweep, where
common.logging.log4net timed out resolving its version list and decompiles
clean on a second attempt.
A 404 stays immediate: it is an answer, not a flake, and the sweep asks about
plenty of ids that are not packages.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
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