A finding named the package, the assembly's file name and the type, which is
not enough to reproduce it: the assembly lives in a version-specific cache
directory, and the references it was decompiled against are the whole question
whenever a warning turns out to be a reference problem rather than a defect.
That judgement had to be made by rerunning the package under NUGETFUZZ_VERBOSE
and reading the console, which the ledger of a catalog sweep cannot offer at
all.
The reference set is captured once the assembly is finished rather than when a
finding is first hit. The resolver keeps discovering references for as long as
it decompiles, so a finding from the first type would otherwise record a set
that is mostly still empty.
Ledger lines written before this field existed deserialize with it empty and
render as they did.
Assisted-by: Claude:claude-opus-5:Claude Code
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
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
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