An agent review of PR #4065 posted twelve comments and eight had to be
withdrawn. Most were output of a decompdiff corpus sweep run against the
PR branch, which measures the whole decompiler rather than the diff, and
one called a cast a regression when it was the correct emission -- the
old form compiled only because C# target-types the switch expression.
The guide lives in .github/ rather than inline because most sessions
never review anything and should not carry it, and because it is as
useful to a human reviewer as to an agent. CLAUDE.md keeps only the two
rules that have to hold before you have decided you are reviewing at
all, plus the pointer.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
A Roslyn local function name is "<caller>g__name|x_y", where the trailing digits
are a synthetic disambiguator that SplitName has to strip before the scope-local
renumbering can run. An obfuscated name has no such suffix, so running it through
the same path renames "smethod_1" to "smethod_" -- a needless second mangling on
top of what the obfuscator already did.
#3202
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Obfuscators strip the CompilerGeneratedAttribute and rewrite the
"<caller>g__name|x_y" name, which is all IsLocalFunctionMethod had to go on.
The method then stays an ordinary static method, its display struct escapes by
ref into a plain call, and TransformDisplayClassUsage correctly refuses to
scalar-replace it -- so the closure fields leak into the output as
"<>c__DisplayClass29_0_.iid" (issue #3202).
The one marker an obfuscator cannot remove is the signature: Roslyn emits
struct closures exclusively for local functions, and no hand-written C# can
name a "<>c__DisplayClass" type, so a by-ref parameter of one identifies the
method regardless of what it is called.
#3202
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
The ilspycmd dump and the contributor notes described themselves by reference to
the UI's ILAst language; the pane is what walks that pipeline now.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
The file this branch adds takes the contributor's name rather than
AlphaSierraPapa, and three comments it added drop their en-GB spelling.
EndOpenGroups now requires its target depth: zero is the one value that closes
groups the caller does not own, which is the misattribution the depth argument
was added to prevent.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Three copies of the same pre-order walk across two test files become
TreeTraversal.PreOrder, and the stepper fixture builds its decompiler through
the file-name constructor instead of assembling the type system by hand.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
The ref parameter was only there because a partial method could not return one.
An extended partial can, so the caller reads the answer where it uses it. The
implementation stays behind DEBUG, which now needs a Release counterpart: an
extended partial must have one in every configuration, and there the step limit
is never set, so it never writes.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Two things about the step tree's presentation. Nothing said which half of the
pipeline a step belonged to, so each one now names its phase from what it points
at - instructions for the IL half, syntax nodes for the C# half - and a group
opener with no anchor takes it from the first step underneath.
And the wrappers are built on demand because a recorded type runs to tens of
thousands of steps, but the filter walked them and so built every one on the
first keystroke. It now asks the recorded steps whether a subtree contains a
match, and only descends into wrappers that already exist or are on a revealed
path.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
The state the ExpressionBuilder and StatementBuilder leave behind was only
reachable as "state before the first AST transform" - an entry that names a
transform rather than the state, and that sits below every member's group in a
tree with one group per member. It is now a top-level step at the seam, where
the whole type is converted and nothing has transformed it yet.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Two extra pieces of state existed to reconstruct what the stepper already
records: which member a halt belongs to. The step the limit stopped on answers
it, except on a member's opening step, which is where the previous member's
state ends - and there the last step actually recorded answers it instead.
IL group openers gain an anchor so their position resolves. The member opener
deliberately keeps none: anchoring it to the member the pipeline is about to
start is exactly the misattribution the fallback exists to avoid.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Whether a run records was read from the pane's own state at the moment the
decompiler was built, on a background task. A step index only means anything
against a run recorded the same way, so the full run and the step-limited replay
of one of its indices have to agree on it - and a view that can be attached or
detached between the two is the wrong place to ask.
The flag now travels in DecompilationOptions beside StepLimit and IsDebug, read
from the workspace on the UI thread when a run starts. Putting it on the tab
instead would miss the tabs opened after the pane, and the pane can be opened
before any tab exists at all.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
The recorded steps are pinned on the MEF-shared C# language, but the release
went through the pane's attached language, which is null whenever another
language is selected - open the pane on C#, switch to IL, close it, and the tree
stayed alive until the next full C# run. The language still raises StepperUpdated
at the end of every run, so a run that outlived the pane pinned its tree straight
back in. And opening the pane re-ran the decompile whatever the language,
discarding the view for a tree that only C# can produce.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Recording only gated the IL half, so a run with it off still numbered the C# AST
transforms into the shared stepper. That gave one pipeline two numbering scales,
and a step index is only meaningful against the scale it was recorded on: a tree
captured under one and replayed under the other selects a different step. It
also let the crashed-member attribution fire on a counter that had never moved -
a limit of zero matched at every throwing transform and rendered an unrelated
member's ILAst.
The flag now gates both halves, so steps exist exactly when recording is on, and
it lives on the pane instance rather than a static the background decompile read
across threads.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
The pane used to split the pipeline across two languages: the ILAst language
stepped the IL transforms, the C# language stepped the AST transforms, and
nothing showed the seam between them, so a step index meant a different thing
depending on which language happened to be selected. Recording both halves into
one Stepper makes an index replayable across the whole pipeline; a limit that
lands in the IL phase has no C# to print, so the halted function is rendered as
ILAst instead.
Which function that is takes some care, because a member group's EndStep is the
next member's first step: a halt standing on a member's opening step belongs to
the member that just finished, a transform that throws where the limit was aimed
has to hand over the ILAst it half-transformed (what the ILAst language showed
as "ILAst after the crash"), and a step recorded on a helper function the
pipeline has not attached yet belongs to that function's own tree.
Retention stays opt-in twice over: the decompiler records IL steps only when
asked to, and the pane asks only while its view is on screen. Every kept step
pins the ILAst it captured, which for one type runs to tens of thousands of
nodes, so a closed pane would be paying for a tree nobody displays.
What is left of the ILAst language is its typed-IL dump, which runs no
transforms at all. That stays, as TypedILLanguage. IDebugStepProvider was down
to a single implementation and is removed.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Twenty shapes that no fixture covered: nesting at depth three and at position
zero, inner discards on either side, property and no-conversion targets, and
deconstruction inside try, switch, if/else and while. All but one already
decompile correctly - they are checked in so a future change to
DeconstructionTransform cannot silently drop them.
The one that does not is left commented out with a pointer to #4059 rather than
as a red test: two back-to-back deconstructions share their out-slot
temporaries, and neither is recognized.
#4059
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
The assembly list answered DragOver with the Move effect only. OLE
intersects that answer with the effects the drag source permits, and
sources such as FileLocator Pro only permit Copy, so the negotiation
ended in None and the drop was refused although CF_HDROP was present
and the file path resolved fine. Explorer permits Move, which is why
drops from Explorer kept working. The WPF ILSpy offered Move, Copy and
Link and let the source pick; restore that.
Assisted-by: Claude:claude-fable-5:Claude Code
A merging obfuscator can leave a module referencing two versions of the same
assembly. Loading both split every type they declare into two definitions that
compare unequal, so a signature naming such a type through one reference stopped
matching a base method naming it through the other, and a genuine override was
printed as virtual. ac0ef8a11 (#3253) dropped the lower-version duplicates, but
nothing covered that, and neither of the existing fixture kinds can carry the
three assemblies the situation needs.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Two test fixtures carried their own copy of the 32-byte signature, which
would silently drift from the real one. The signature is now an internal
member of SingleFileBundle and ILSpy.Tests gets internals access to the
decompiler assembly, matching what ILSpyX already grants it.
Assisted-by: Claude:claude-fable-5:Claude Code
IsBundle scanned up to but excluding the last position at which a full
signature fits, so a signature occupying the final 32 bytes of the region
was never compared. Windows hid this: a memory-mapped view there reports
the page-rounded region size, leaving trailing zero bytes after the file.
On Linux and macOS the view length is the exact file length, and the
LoadedPackage bundle tests, whose synthetic bundles end with the
signature, failed there with FromBundle returning null. Real bundles keep
apphost code after the signature, which is why this stayed latent.
Assisted-by: Claude:claude-fable-5:Claude Code
A single-file bundle manifest is attacker-controlled. Opening a compressed
entry pre-allocated a MemoryStream of the declared decompressed size
through an unchecked long-to-int cast, then inflated the whole deflate
stream before comparing lengths. A few-byte payload declaring ~2 GB thus
forced a ~2 GB allocation up front, sizes at or above 2 GB wrapped to a
negative capacity, and a decompression bomb was expanded in full before
the mismatch was noticed (CWE-789, CWE-197).
Grow the buffer only with bytes the deflate stream actually produces and
stop reading one byte past the declared size, which already proves the
entry corrupt. Reject declared sizes that cannot fit a single in-memory
buffer as invalid bundle data. Entry offsets need no extra check: the
UnmanagedMemoryStream over the mapping already validates them against the
view length.
Assisted-by: Claude:claude-fable-5:Claude Code
A .resources file's resource count, type count, name lengths, binary
resource lengths and serialized-object lengths all come from the file
and were only checked for being non-negative before sizing an allocation.
A crafted file can therefore request multi-gigabyte arrays from a
few-hundred-byte payload (CWE-789), turning a click on a resource node
into an out-of-memory condition. The serialization-format kind was
additionally an assert-only check that vanishes in Release builds.
Each element of these counts occupies at least one byte in the stream, so
a value needing more bytes than remain after the current position cannot
be honest. Reject it with the same BadImageFormatException the callers
already handle, and promote the format-kind assert into a real check.
Assisted-by: Claude:claude-fable-5:Claude Code
The JSON parser's value/object/array readers are mutually recursive with
no depth limit, so input nested tens of thousands of levels deep overflows
the stack with an uncatchable StackOverflowException (CWE-674) that kills
the process. This is reachable through DotNetCorePathFinder, which parses
the .deps.json shipped next to an opened assembly, so a crafted manifest
beside a target turns dependency resolution into a clean process kill.
Thread a depth counter through the readers and throw a catchable
JsonParseException once nesting passes a fixed cap. The cap (64) matches
the System.Text.Json default and is far beyond any real dependency graph.
Assisted-by: Claude:claude-fable-5:Claude Code
The search icon tests only exercised the type and field delegations; the
method, property and event arms and the namespace LocationImage fallback
for top-level types were untested.
Assisted-by: Claude:claude-fable-5:Claude Code
The derived-types entries, the compare pane, and the analyzer tree nodes
still built their icons from bare base images (or private duplicates of
the helper logic), losing the accessibility/static overlays and the
kind-specific glyphs (enum value, literal, readonly field, indexer,
P/Invoke, virtual and extension methods). The WPF frontend routes all of
these through the tree nodes' static GetIcon helpers; do the same so
every pane composes icons identically by construction.
Assisted-by: Claude:claude-fable-5:Claude Code
The WPF frontend uses a type-only overlay mapper that shows protected
internal types with the plain protected badge, while members get the
combined protected-internal badge; the Avalonia frontend ran both through
the shared Images.GetOverlay and so badged types differently. Restore the
type-only mapping in TypeTreeNode.GetIcon, which now also covers search
results and every other caller of the helper.
Assisted-by: Claude:claude-fable-5:Claude Code
The search result factory built icons from the bare base images, bypassing
Images.GetIcon, so search results lost the private/internal/protected and
static mini-overlays (and flattened interfaces, structs, enums and delegates
to the class icon; constructors, operators and indexers to the plain member
icons). Delegate to the tree nodes' GetIcon helpers instead, as the WPF
frontend's SearchResultFactory did, so search icons match the assembly tree
by construction. TypeTreeNode and EventTreeNode get the same static GetIcon
extraction the other member tree nodes already had.
Assisted-by: Claude:claude-fable-5:Claude Code
The pane deduped new entries by comparing IModule instances, but analyzer
results live in the type system each analyzer run builds, so the same
member analysed from a result row and from the assembly tree (or from a
re-run analysis after its rows were collapsed away) never matched and got
a second top-level row. The loaded MetadataFile is the identity that
survives across type systems.
Assisted-by: Claude:claude-fable-5:Claude Code
Making a conversion implicit by unwrapping it hands the operand to a
different target type, and a default literal takes its value from that
type: "S? x = new S?(default)" holds a value, while "S? x = default" is
null. Unwrapping the nullable constructor around a shortened literal
therefore turned "S? x = default(S)" into a null nullable. The literal is
spelled out again whenever unwrapping moves it to a type other than the one
it was shortened from.
Converting a using resource to the declared variable type is unconditional
now (except when the declaration says "var", which supplies no type): the
declaration always spells the type out, so any conversion to it may stay
implicit, which is also what shortens default(T) there.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Mutating the DefaultValueExpression is enough here; ConvertTo already hands
out mutated input nodes elsewhere (UnwrapChild), so building a replacement
node and copying the annotations over bought nothing.
The operator special case is easy to mistake for a cosmetic preference,
because the null literal is accepted in the same position: it converts only
to reference and nullable types, so it still narrows operator overload
resolution, whereas the default literal converts to everything and C#
rejects it outright for every binary operator except == and !=.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Shortening default(T) is the same problem as removing the redundant cast
around a lambda whose delegate type the context already fixes, so it uses
the same mechanism: ConvertTo makes the explicit type implicit when the
conversion is an identity conversion and the caller allows an implicit
one. The literal keeps the type it was shortened from, so any later
conversion to a different type - or any context that requires an explicit
type, such as an overload resolution recheck falling back to CastArguments
- can spell default(T) out again. That keeps the value intact where the
bare literal would change it, e.g. "object o = default(SomeStruct)", which
boxes a non-null struct while "default" would be null.
Because the shortened literal resolves to DefaultLiteralResolveResult,
CallBuilder's existing overload resolution recheck sees a real default
literal and rejects ambiguous calls on its own; no separate bookkeeping
about which arguments may stay untyped is needed. Only the contexts that
supply no target type at all restore the explicit form: an awaited
expression, and arguments of operator methods, which later become operator
or cast syntax rather than calls.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
The pretty-print comparison already treats blank lines, comment-only lines
and preprocessor directives as ignorable, but only when scoring a single
diff entry: they still sat in the line collections handed to the aligner. A
run of #if/#else/#endif around a statement could then push the aligner into
matching an adjacent brace as inserted-and-deleted, failing a test whose
decompiled output was in fact correct. Drop those lines before diffing so
they cannot skew the alignment.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The port dropped IMemberTreeNode from AnalyzerEntityTreeNode, so the
member-based context-menu entries (Analyze, Copy name, ...) no longer
recognised analyzer rows and a result row could not be promoted to a
top-level entry. Top-level rows keep the entry hidden: re-analysing
them is a no-op, and Remove is the entry for those rows.
Assisted-by: Claude:claude-fable-5:Claude Code
The WPF SharpTreeView bound ApplicationCommands.Delete at class level, so
Delete deleted the top-level selection of any tree whose nodes opt in via
CanDelete/Delete -- which is how a top-level analyzer entry was removed
from the Analyzer pane. The Avalonia tree never received that binding;
only the assembly list pane carried a hand-rolled Delete handler for
assemblies, so the analyzer pane lost the key entirely even though its
nodes still implement the deletion overrides.
Moving the gesture back into SharpTreeView restores it for every tree
and lets the pane-specific handler (with its own reselect logic) go.
Assisted-by: Claude:claude-fable-5:Claude Code
WPF translated XButton1/XButton2 into BrowseBack/BrowseForward
commands by itself, so the WPF frontend got the behaviour for free
and the buttons never reached the control under the pointer as a
click. Avalonia has no such translation and KeyBinding cannot express
pointer buttons, so only Alt+Left / Alt+Right survived the migration.
MainWindow now swallows the X-button press while it tunnels (Dock
would otherwise activate the pane under the pointer, AvaloniaEdit
would focus the editor or toggle a folding marker) and routes the
release to the existing DockWorkspace navigation commands.
Navigating also no longer moves the active pane to the editor: the
history target is usually the already-active tab, and Dock's
ActiveDockable setter re-runs InitActiveDockable -> SetFocusedDockable
even for an unchanged value, so re-activating it only moved the
focus. WPF's ActiveTabPage setter was a no-op for the same value.
Assisted-by: Claude:claude-fable-5:Claude Code
Avalonia 12 treats plain Enter/Space on a ListBoxItem as selection
input: the container marks the KeyDown handled before it bubbles, so
SharpTreeView.OnKeyDown never saw the keys and its activation handling
(navigate to the member from an analyzer row, toggle a checkable row)
was dead. Override ShouldTriggerSelection -- the extension point added
for this in Avalonia 12 -- to suppress the selection trigger exactly
for the case OnKeyDown activates instead: a single selected row that
is the row the key landed on. Multi-row selections keep the default
collapse-to-focused-row behaviour.
Assisted-by: Claude:claude-fable-5:Claude Code
DerivedTypesEntryNode.Filter reported Recurse, but the cascade's
Recurse handling force-loads the entry's lazy children and hides the
entry when all of them are hidden. A leaf derived type has no children,
so every entry under "Derived Types" ended up hidden, and the hiding
propagated up the whole derived chain. The WPF tree showed these
entries as matches; Match restores that and also keeps the entries'
children lazy instead of eagerly scanning the assembly list for each
level of the chain.
Assisted-by: Claude:claude-fable-5:Claude Code