When an IfInstruction was created with resultType=bool, because only
values 0 or 1 are possible, then we need to preserve this property in
transforms -- otherwise the added correctness test would fail.
A finding is recorded before the reference context exists, because the resolver
keeps discovering references until the assembly is done. An assembly that fails
in the type system returns without ever reporting its resolutions, so its keys
stayed pending and the next assembly stamped its own reference set onto them.
Naming the wrong references is worse than naming none, in a report that is read
precisely to tell a reference problem from a decompiler defect.
Assisted-by: Claude:claude-opus-5:Claude Code
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
A sweep covers thousands of packages, and the same simple assembly name
ships in many of them and in several TFM folders of a single package, so
a finding keyed on the file name alone cannot be traced back to the
assembly it came from.
Assisted-by: Claude:claude-opus-5:Claude Code
Declining every type-parameter operand was too broad. The reason a type
parameter has no lambda spelling is that `v == other` is CS0019 and boxing
both operands compares box identity where the tree compares values - and both
only apply while the parameter may be a value type. A parameter constrained to
a reference type compares as a reference, which is what the tree asks for and
what `t == null` spells, so it converts like any other reference comparison.
IsReferenceType is the distinction the type system already makes here:
TypeUtils.GetStackType maps a type parameter to Obj or VT by the same
question. An unconstrained parameter answers null and keeps declining.
Assisted-by: Claude:claude-opus-5:Claude Code
The decompiler emits comments - //IL_ warnings, "Could not convert
BlockContainer", "try-fault", a Nop's comment - as an EmptyStatement in the
middle of a statement sequence. Every transform that walks such a sequence
then stops recognizing its pattern the moment one of those lands in it:
constructor initializers stay in the body, `using var` and `for` are not
introduced, and a finalizer keeps its `override Finalize` shape, which does
not compile at all.
The destructor matcher moves the placeholders it skipped into the body that
replaces the old one, so the warning that caused the problem is not dropped
along with the statement carrying it.
Assisted-by: Claude:claude-opus-5:Claude Code
A constructor store to an auto-property's backing field is expressible
after the field declaration is gone in one of two ways: ReplaceBackingFieldUsage
rewrites it to an assignment of a setter-less property, or
TransformFieldAndConstructorInitializers lifts it into a property initializer.
A deconstruction target assigns several members at once, so it can never take
the second route, and a property that kept a setter would invoke that setter
instead of storing the field. Without the restriction the declaration is
removed while the store keeps referencing it.
Assisted-by: Claude:claude-opus-5:Claude Code
The WPF host kept the name prompt open and said the name was taken; the port
kept the resource string but dropped the message, and the New / Clone / Rename
/ Add-preconfigured handlers only return early on a collision. Nothing tells
the user why the list they just named did not appear, which reads as the dialog
having accepted the name.
The check belongs in the prompt, where the name is entered: OK stays disabled
while the name collides, so no flow can be handed one. Rename passes the name
of the list being renamed as allowed, because that is a no-op rather than a
collision with itself.
Assisted-by: Claude:claude-opus-5:Claude Code
decompdiff counted the substring "<>" in the output text, which both missed
mangled names that do not contain it ("VB$AnonymousType_0", "<Main>$") and
counted every generic argument list ending in an identifier character. The
shape is matched lexically there because only text is available; nugetfuzz has
the syntax tree, so it applies the decompiler's own identifier rule
(EscapeInvalidIdentifiers.IsValid) to the tree's identifiers instead, and any
hit is output that does not compile. Findings collapse to the shape of the
name because the bracketed part and the digits vary per occurrence, so one
unfolded construct stays one finding rather than one per member it hit.
The report's kind list is also the render loop's only source of sections, so
the PDB bucket added with the PDB verification mode never reached the HTML.
Assisted-by: Claude:claude-opus-5:Claude Code
The async stepping blob's first field is the compiler-generated catch
handler's IL offset plus one, and 0 when there is nothing to record. ILSpy
wrote the raw offset, so a consumer decoding it as (value - 1) resolved an
address in the middle of an instruction: Mono.Cecil throws
ArgumentNullException while reading the body, which is why the reported
assembly opened in ILSpy but killed ILLink on every MoveNext it had.
CatchHandlerOffset now holds the offset it is named after, or -1 for "none",
and BuildBlob applies the bias - the same model the compiler uses.
It is recorded only where an escaping exception is unlikely to be observed:
an async void method, and an async entry point. Recording it more widely
would be worse than recording it nowhere, because an async Task method
returns its exception through the Task and the debugger would then break on
exceptions user code catches. Measured over csc 1.3.2 to 5.10: async void is
handler+1 and a normal async Task is 0 in every version; only async Main
changed, from 0 to handler+1 between 2.10 and 3.11.
The entry point token names the synchronous '<Main>' shim that exists because
the runtime will not take .entrypoint on an async method, so the method to
record is the one the shim calls. Reading that call is exact; matching the
shim's siblings by name is not, and gets a 'Main' overload beside the real
entry point wrong.
Both tests compare against the compiler's own PDB for the same assembly, so
the blobs describe the same IL and the field compares directly.
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
Switching to another assembly list replaces the whole tree, but nothing
is removed from the outgoing list - the list itself goes away - so no
collection event announces it and the selection, the open tabs and the
navigation history all kept describing a list that was no longer on
screen. The switch now says so with the same Reset that clearing a list
raises, which is the one path that already discards all of it.
Emptying a tab kept its title, because the CurrentNodes setter only
recomputes the cached base title and StartDecompile returns early with
nothing to decompile. A tab showing an empty document went on naming the
member it used to show.
A removal that took the selected node with it left nothing selected,
even with assemblies still loaded: the tree view picks the nearest
survivor for its own Delete gesture, but a removal from anywhere else
did not. The selection is handed over once the tree has caught up.
Assisted-by: Claude:claude-opus-5:Claude Code
A member ID was resolved against the loaded assemblies with the
reference assemblies filtered out entirely, so the assembly set a
project's references make up - which is what the VS add-in passes -
left every target unresolved. The lookup now prefers assemblies that
carry a body and falls back to the reference assemblies, which do
declare the member; the banner already says what the reader is looking
at.
An ID that names nothing anywhere used to leave the tree untouched and
say nothing, which is indistinguishable from a jump to the wrong place.
It now names the target and the assemblies that were searched, rather
than selecting an arbitrary one of them. The report prefers the pane the
jump would have filled and falls back to a tab of its own, because
ShowText writes to the active decompiler tab and does nothing at all
when the active content is something else - a metadata table, or nothing
yet at startup, which is exactly when this report is written.
The test fixture is this project's own reference assembly: the compiler
writes one carrying the ReferenceAssembly attribute and the same members
as the output, which is the pair a targeting pack and its runtime form,
and keeps the test off machine-specific NuGet paths.
Assisted-by: Claude:claude-opus-5:Claude Code
Removing assemblies one at a time cleaned up the tabs that showed them,
but clearing the whole list did not: Clear() raises a Reset whose
OldItems is null, and the handler pruned the history and returned before
the loop that empties the main tab and closes the orphaned ones. The
last decompiled member stayed on screen over an empty list.
Sorting had to be fixed first. It rebuilt the collection through Clear()
plus AddRange, so it raised the same Reset and dropped the whole
navigation history as a side effect - and once Reset closes tabs, it
would have closed all of those too. It now reorders in place, and a Move
is ignored where a removal would be handled, because a sort removes
nothing.
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
A constant narrower than its stack type builds as a plain ldc.i4, which
infers as int, so a conditional whose other branch really is a char saw
two different types and the whole tree was left as the Expression calls
that built it - EF Core's StringCharConverter.ToChar is one. Bool and
enum constants were already wrapped for this reason; the wrap now
applies wherever the built value does not infer as the declared type.
Assisted-by: Claude:claude-opus-5:Claude Code
The builder returned by ConvertLambda hands back null when a nested
conversion declines, and the result was cast and dereferenced before
anything checked it, so a tree the transform cannot handle took down the
whole method with a NullReferenceException instead of being left alone.
EF Core's StringCharConverter.ToChar is such a tree: the conditional
spills the Expression.Call arguments into stack slots, which
MatchGetMethodFromHandle does not see through.
Assisted-by: Claude:claude-opus-5:Claude Code
A hand-built Expression.Equal whose operands are a type parameter has no
lambda to decompile to: `v == other` is CS0019 for a type parameter, and
boxing both operands compiles but compares box identity where the tree
compares values once the parameter is a value type. The conversion now
declines, leaving the Expression calls that built the tree.
Found in EF Core's BoolToTwoValuesConverter<TProvider>.ToBool, which
decompiled to code that does not compile.
Assisted-by: Claude:claude-opus-5:Claude Code
The parameters standing in for the expanded arguments were built from
the element type of the array the compiler had built, not from the
element type the params collection declares. The two can only differ
where the collection is covariant in its element type, and no compiler
emits that shape today - it materializes the array into a local of the
target type first, which the pattern no longer matches - so this only
removes the dependency on that.
Where the params collection is one overload resolution cannot unpack,
the expanded form is now abandoned instead of being built from a type
that resolution would never have used.
Assisted-by: Claude:claude-opus-5:Claude Code
Resolving a method reference and resolving one by name and signature
differed in three ways that had no reason to differ: only the metadata
path found a static constructor, only it restricted the candidates to
the declared members, and only it matched a vararg signature against
its required parameters plus __arglist.
Assisted-by: Claude:claude-opus-5:Claude Code
MatchArrayInitializerFinal selects the operator by its return type, so
the block's result type is that return type. The declaring type only
happens to be the same one for the two array-to-span operators; Span<T>
also declares the conversion to ReadOnlySpan<T>.
Assisted-by: Claude:claude-opus-5:Claude Code
The order of a record's fields and properties has to be known, because
Equals, GetHashCode, PrintMembers and the copy constructor are recognised
by walking their bodies in lockstep with it. It was assumed to be every
property followed by every field, so a record that declares a field
before a property desynchronised all four at once: none was recognised as
generated, all of them were emitted, and the auto-properties lost their
backing fields to raw <Property>k__BackingField accesses - output that
does not compile.
The order is in the generated members themselves, but no single one has
all of it: Equals compares everything that carries state and never a
computed property, PrintMembers prints everything public and never a
private field. Both follow declaration order, so the two sequences are
merged along the members they share, which puts a private field and a
computed property back in the right places relative to each other.
Members neither of them mentions - EqualityContract, static members -
keep the position they had.
Where the two orders conflict, which can only happen for a member that
one of them never sees, the equality order wins; nothing in the metadata
says more, and the choice cannot change more than the order the members
are printed in.
Assisted-by: Claude:claude-opus-5:Claude Code
An array initializer standing in for a Span<T>/ReadOnlySpan<T> reaches the
call builder as an implicit span conversion over the array creation, a shape
the params expansion did not know, so a params span argument came out as the
array the compiler had built rather than as the argument list that was
written.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
The block addresses its allocation through the pointer localloc returns, and
only its result is a Span<T>. Retyping the initializer variable to the span
made every element store ask for a pointer it no longer had, which was papered
over with a conv from the span; once Obj and VT became distinct stack types
that conv had no conversion kind left and the whole method failed to decompile.
The span constructor becomes the block's final instruction instead, so the
element stores keep the pointer they were written against and the block still
evaluates to the span.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code