Added Issue3877 test to PrettyTestRunner and new test case source to verify dictionary initialization with negative capacity. Updated SwitchOnStringTransform to skip processing when a negative dictionary capacity is detected.
When a null literal is the only argument a generic type argument could
be inferred from, and that type argument is an anonymous type, ILSpy
used to drop the type arguments entirely (they cannot be written
explicitly), producing 'Test(null)', which no longer compiles
(CS0411). A conditional expression whose never-taken branch creates an
instance of the anonymous type is the minimal C# expression that gives
a null value that type, so the argument now carries enough information
for type inference.
The instance expression is obtained by translating a synthesized
'newobj' with 'default.value' arguments through the existing pipeline
rather than assembling syntax by hand; the property values are typed
defaults, since the IL only contains ldnull. Anonymous types occurring
inside other constructed types (e.g. arrays) stay unexpressible and
keep the previous output.
Assisted-by: Claude:claude-fable-5:Claude Code
CS8196 also fires when the second reference sits inside a nested call
in another argument of the declaring call, e.g.
OutAndValue(out var a, UseAndReturn(out a)). The existing check already
covers this because it walks all descendants of the sibling arguments;
this fixture pins that behavior.
Assisted-by: Claude:claude-fable-5:Claude Code
Passing the same local as multiple out arguments of one call made
DeclareVariables turn the first use into an implicitly-typed declaration,
producing 'f(out var x, out x)'. Referencing an implicitly-typed out
variable in another argument of the declaring call is rejected by the
compiler (CS8196), because its type is only inferred once overload
resolution of that call has completed. The explicitly-typed form
'f(out int x, out x)' is valid, so fall back to the explicit type in
that case.
Assisted-by: Claude:claude-fable-5:Claude Code
Awaiting a dynamic value lowers GetAwaiter/IsCompleted/GetResult to
dynamic callsites, which async decompilation could not recognize:
await detection ran before DynamicCallSiteTransform, so the await was
emitted as a raw state machine (or crashed in AnalyzeAwaitBlock, #1388).
AnalyzeStateMachine now collapses the awaiter callsites per block, folds
the runtime ICriticalNotifyCompletion branch the compiler emits for an
awaiter not statically known to implement it into the canonical single
call, and re-joins the branch chains the collapse leaves so each dynamic
await sits in one block. DetectAwaitPattern matches the dynamic
GetAwaiter/IsCompleted/GetResult shape and emits `await expr`; a
synthesized dynamic GetResult method gives the await and its local the
dynamic type. DynamicCallSiteTransform also follows callsite targets
spilled into state-machine locals, so an awaited value flowing into a
dynamic callsite (e.g. d.Result = await ..., #1928) decompiles too.
Assisted-by: Claude:claude-fable-5:Claude Code
DynamicIsEventInstruction has no ExpressionBuilder visitor, so any is-event
diamond that survives the transform pipeline leaks as an "OpCode not supported"
comment. The collapse only fired for the plain statement form; every other
lowering the compiler emits for "d.Event += b" was missed:
- a copy-of-value temporary between the getmember cache and the diamond (modern
Roslyn emits it for the statement form),
- the result-used shape, where the diamond is a value-if nested inside the
consuming expression (a call argument, or a leave's value when returned),
- the result-returned shape as two leaves, "if (isevent) leave(add)" plus a
fall-through "leave(compound)" rather than an if/else, and
- the same two-leaves shape when the optimizer drops the getmember cache (legacy
csc and Roslyn <= 2.x): the is-event flag then feeds a single branch, inlining
folds it into the condition, and the compound leave is already collapsed, so
the cache-based entry never matched it and the branch leaked. These compilers
only run on Windows CI, so the gap was invisible on Linux.
The descendants walk handles the nested value-if once an over-strict
copy-of-value bounds guard (which never held for the compact return block) is
dropped. The two two-leaves shapes share a skeleton matcher and the add/remove
accessor-name check, and both validate the accessor name and arguments. Re-running
from the collapsed statement instead of the next one avoids indexing past the end
of a block that has shrunk to a single leave.
Assisted-by: Claude:claude-opus-4-8:Claude Code
A stackalloc initializer whose first element is written twice must not be
folded into a 'stackalloc int[] { ... }': doing so drops the earlier store,
which is observable when its value has side effects. HandleSequentialLocAllocInitializer
already rejects this (the extra store trips the LoadCount and elementCount
guards); the sequential-store offset matcher on its own would misclassify the
second offset-0 write. The IL is derived from a C# 'stackalloc int[] { a, b, c }'
disassembly with an extra offset-0 store injected, a shape no compiler emits.
Assisted-by: Claude:claude-fable-5:Claude Code
A second store to an already-written array element must prevent
HandleSimpleArrayInitializer from folding the stores into a collection
initializer: the earlier store would be dropped (a visible change when
its value has side effects), and without the nextMinimumIndex guard the
transform crashes on the colliding index. The nextMinimumIndex check
already enforces this; pin it with a Pretty fixture so the boundary
between a foldable initializer and a duplicate-write sequence stays
covered.
Assisted-by: Claude:claude-fable-5:Claude Code
The C# compiler lowers a foreach over an inline array into a for loop whose
body reads each element through <PrivateImplementationDetails>.InlineArrayElementRef(ref
buffer, i). That helper cannot be named in C#, so the decompiled for loop did
not compile.
Reconstruct the foreach at the AST level instead. Rewriting the unchecked
InlineArrayElementRef helper to the bounds-checked indexer buffer[i] would be
unsound for an out-of-bounds index, so the transform fires only when the loop
bound equals the inline array's length: that proves 0 <= i < length, matching
the exact shape the compiler emits and nothing else. A loop that does not match
keeps the faithful (if unnameable) helper call rather than silently gaining a
bounds check.
Assisted-by: Claude:claude-fable-5:Claude Code
A value-type constructor chains via 'this = new TSelf(...)', an ordinary
body statement, so a hoisted argument null-guard in front of it is legal
C# output as-is; folding it back only bought lifting the chain into a
this(...) initializer. That cosmetic gain does not justify the extra
stobj shape matching, so the guard now stays in the body for structs and
the gate reduces to the ChainedConstructorCallILOffset check.
Assisted-by: Claude:claude-fable-5:Claude Code
Per @siegfriedpammer: rather than rescue the hoisted for-initializer in
DeclareVariables, don't form the for-loop at all. TransformFor now bails when the
loop variable is a by-ref-like local used after the loop, leaving the while-loop
(matching source); the ref decl keeps its initializer, no CS8174. Reverts the
DeclareVariables change; test now expects the while form.
Assisted-by: Copilot:claude-opus-4.8:GitHub Copilot CLI
A ref local that is used after a for-loop has its declaration hoisted in front of
the loop, but its only initialization is the for-initializer ref-assignment. The
declaration was then emitted without an initializer (`ref T x;`), which does not
compile (CS8174).
When a by-ref-like local's matching assignment is the first for-initializer, move
the ref-assignment's value up into the declaration (`ref T x = ref expr;`) and
drop the for-initializer.
Assisted-by: Copilot:claude-opus-4.8:GitHub Copilot CLI
A type's leading field assignments are extracted to field declarations
only when every constructor that does not chain with this() agrees on
them. When they disagree, the analyzer gave up on the whole type, so the
remaining constructors' this()/base() calls were never lifted into
initializers -- a struct with two divergent constructors plus a chaining
one rendered the chain as `this = new TSelf(...)` instead of `: this(...)`.
Chain lifting does not depend on the shared-initializer extraction, so a
mismatch now just skips the extraction (the assignments stay in the
bodies) and the transform continues. Primary constructors still bail,
since their parameters drive the initializers that must be extracted.
Assisted-by: Claude:claude-opus-4-8:Claude Code
When a chained constructor-call argument contains a throwing null-check
(e.g. `value?.Length ?? throw ...`) and the argument is evaluated more
than once, the compiler hoists the null-check in front of the chained
call. The hoisted `if (value == null) throw ...;` then became the first
body statement, so MoveConstructorInitializer could not recognize the
chained call and left it as an illegal in-body `base..ctor(...)` /
`this..ctor(...)` (a parse error).
Fix it in the ILAst, where the `?? throw` shape already lives, rather
than re-deriving it on the C# AST: NullCoalescingTransform folds a guard
that directly precedes the chained call back into the first use of the
parameter as `if.notnull(ldloc param, throw)`. Nothing in a constructor
body can legally run before the chained call, so a statement preceding it
is necessarily compiler-hoisted; matching is by ILVariable identity, not
parameter name. The guard disappears before the AST transforms run, so
they need no change.
Reference types chain via a base/this..ctor CallInstruction; value types
chain via `this = new TSelf(...)`, i.e. stobj(ldthis, newobj TSelf(...)),
which ChainedConstructorCallILOffset does not report -- so the value-type
chain (including the case where this is spilled to a stack slot because
the guard sits between its load and the call) is matched directly.
Assisted-by: Claude:claude-opus-4-8:Claude Code
For an async iterator with an [EnumeratorCancellation] cancellation token, the
hoisted-local cleanup (stfld <>u__N(this, null)) can be emitted before the
combined CancellationTokenSource disposal in the set-result and catch blocks.
CheckSetResultReturnBlock and ValidateCatchBlock only consumed that cleanup after
the disposal, so the `pos + 2 == count` test missed the dispose pattern and the
analysis failed, leaving the raw state machine (catch (object), goto case, ...).
Allow the cleanup to appear before the combined-tokens disposal as well.
Assisted-by: Copilot:claude-opus-4.8:GitHub Copilot CLI
UnscopedRef only appeared on a params parameter anywhere in the
suite; a ref-returning method and property on a ref struct pin the
attribute round-trip.
Assisted-by: Claude:claude-fable-5:Claude Code
Static abstract interface members had no checked operator or
unsigned-right-shift coverage, and static abstract property getters
were never read as rvalues through the constraint.
Assisted-by: Claude:claude-fable-5:Claude Code
Compound assignments selecting between op_Addition and
op_CheckedAddition across a checked block boundary were not
covered.
Assisted-by: Claude:claude-fable-5:Claude Code
Generic attributes were only closed over simple types; constructed
generic, array, and enum type arguments exercise separate paths in
the custom-attribute blob decoder.
Assisted-by: Claude:claude-fable-5:Claude Code
SetsRequiredMembers had no coverage anywhere; required members
across a base/derived pair and in a generic abstract host were also
untested.
Assisted-by: Claude:claude-fable-5:Claude Code
readonly record struct had no coverage, and no record overrode a
synthesized member; user-declared ToString and PrintMembers pin the
synthesized-vs-user member detection.
Assisted-by: Claude:claude-fable-5:Claude Code
Only two plain ASCII u8 literals were covered; the empty literal and
a literal made of escape sequences pin the recovery heuristic
boundaries.
Assisted-by: Claude:claude-fable-5:Claude Code
The C# 6 index-initializer syntax was only covered through custom
indexers; a real Dictionary<,> initialized with [key] = value pins
the choice between the index form and the Add form.
Assisted-by: Claude:claude-fable-5:Claude Code
ConfigureAwait(false) inside an async iterator and await foreach
over ConfiguredCancelableAsyncEnumerable were not covered.
Assisted-by: Claude:claude-fable-5:Claude Code
Only interface-based disposal was covered; a using declaration over
a ref struct with a pattern-based Dispose exercises the recognition
heuristic without IDisposable.
Assisted-by: Claude:claude-fable-5:Claude Code
protected and protected internal default interface members, static
properties and field-like events in interfaces, and a generic
interface with a constrained generic default method were not
covered.
Assisted-by: Claude:claude-fable-5:Claude Code
The fixture had an iterator local function but no async local
function; the async state-machine-in-local-function shape, static
and capturing, was not covered.
Assisted-by: Claude:claude-fable-5:Claude Code
await inside a null-coalescing expression, as a do-while condition,
on a ValueTask, and inside an interpolated-string hole (the
DefaultInterpolatedStringHandler lowering) were not covered.
Assisted-by: Claude:claude-fable-5:Claude Code
dynamic used as a while-loop condition and the null-conditional
invocation operator on a dynamic receiver were not covered.
Assisted-by: Claude:claude-fable-5:Claude Code
AddChecked/MultiplyChecked/ConvertChecked nodes were not covered;
they are pretty-printed as a checked block around the tree-building
calls.
Assisted-by: Claude:claude-fable-5:Claude Code
Only group-by continuations were covered; a continuation introduced
by select-into exercises a different transparent-identifier reset.
Assisted-by: Claude:claude-fable-5:Claude Code
Virtual/override/sealed-override auto-properties, implicit and
explicit interface implementations, asymmetric accessor
accessibility, and auto-properties in structs were not covered.
Assisted-by: Claude:claude-fable-5:Claude Code
Anonymous types nested as members of other anonymous types (and read
back through the projection), ToString on an anonymous instance, and
explicit member projections were not covered.
Assisted-by: Claude:claude-fable-5:Claude Code
The lifted-operator matrix only used pure expressions; compound
assignment on nullable locals and boxing/unboxing conversions of
Nullable<T> were not covered.
Assisted-by: Claude:claude-fable-5:Claude Code
All iterator producers in the fixture used concrete element types;
a generic method pins the reconstruction of the generic state
machine type.
Assisted-by: Claude:claude-fable-5:Claude Code
Generic co-/contravariance had no test coverage at all: no out/in
variance modifier on any interface or delegate declaration and no
variant reference conversion appeared anywhere in the test suite.
The new fixture pins declarations (including a constrained covariant
interface and an explicit implementation of a variant interface) and
conversion shapes that must not produce explicit casts.
Assisted-by: Claude:claude-fable-5:Claude Code
All existing filter tests only read state; a filter that mutates a
ref local observed by the handler pins the ordering between the
filter expression and the handler body.
Assisted-by: Claude:claude-fable-5:Claude Code
Pins the reconstruction of a continue statement in a catch block
inside a foreach loop, which used to decompile to a goto targeting
a label at the end of the loop body. #2829 reported the goto form
and can be closed.
Assisted-by: Claude:claude-fable-5:Claude Code
The UnsafeCode fixture had no double-indirection coverage at all:
no pointer-to-pointer loads, stores, or indexing, and no fixed
statement over an element of a pointer array.
Assisted-by: Claude:claude-fable-5:Claude Code
Record AST transform groups and mutation steps through the C# pipeline, replay selected steps with the stepper, and carry modified-node ranges through output so the Debug Steps pane can highlight the selected mutation without replacing its full step tree.
Assisted-by: CodeAlta:gpt-5.5:CodeAlta
A field, auto-property, or event initializer is written once at its
declaration, but in IL it runs in every instance constructor that does not
chain to this(...) (and static initializers run in the static constructor).
The decompiler lifts the initializer from a single constructor, so its
breakpoint was emitted only there and the other constructors had none.
Two causes are addressed:
- The lift discarded the initializer's copies in the other constructors.
They are now kept on MemberInitializerInOtherConstructorsAnnotation and
replayed by SequencePointBuilder, mapping the same source location onto
each constructor's IL.
- PortablePdbWriter only emitted methods that DebugInfoGenerator discovered
through declaration syntax, so a constructor whose declaration is omitted
from the output (implicit default ctor, implicit static ctor, primary
ctor) dropped its generated points. Those functions are now emitted by
walking the sequence-point map directly.
PdbGen fixtures cover single, multiple, this()-chained, implicit, static,
primary-constructor, and field-like event initializers, pinning the
reconstructed breakpoint map against the C# compiler's.
Assisted-by: Claude:claude-opus-4-8:Claude Code
ILFunction.IsAsync is derived from the method signature, so .NET 11
runtime-async methods (MethodImplAsync bit, no MoveNext state machine)
report IsAsync without AsyncAwaitDecompiler ever populating
AsyncDebugInfo. Its Awaits then stays an uninitialized ImmutableArray,
and PortablePdbWriter threw an NRE building the MethodSteppingInformation
blob from that default struct. Runtime-async methods have no yield/resume
offsets to record, so guard on Awaits.IsDefault and omit the blob,
matching the C# compiler, which emits no stepping information for them
either. A genuinely zero-await classic state machine keeps an
initialized empty Awaits and is unaffected.
Assisted-by: Claude:claude-opus-4-8:Claude Code
DebugInfoGenerator asserted that every local variable's decompiled type
is equivalent to the metadata local-signature type at its slot. That
holds at IL-read time but not afterwards: variable splitting gives one
slot several typed variables, pinned-region locals are modeled as
pointers (int* vs int& pinned), and generic-context type-parameter
identity differs. The assertion therefore aborted PDB generation (Debug
builds) for ordinary inputs such as any method with a fixed statement.
The type is never written to the PDB - only the slot index and name are -
so the mismatch cannot affect the debugging experience. The slot index,
the only emitted value, is correct by construction: it is the IL
ldloc/stloc operand, sourced from the signature slot when the variable is
created and copied verbatim by SplitVariables; it is never reassigned.
Keep only the index-bound check and document why the type is not verified.
Assisted-by: Claude:claude-opus-4-8:Claude Code
Decompiler warnings (ILFunction.Warnings, e.g. the DetectPinnedRegions
block-duplication notice) are surfaced as an EmptyStatement carrying only
a comment. VisitEmptyStatement prints no semicolon for it, and
EmptyStatement derives its StartLocation/EndLocation from its Location
field, which is only set when that semicolon token is written. The
statement was therefore left without a text location, and
SequencePointBuilder then asserted on the empty start location while
generating PDB sequence points - aborting PDB generation for any assembly
whose decompilation emits such a warning (e.g. System.Net.Requests).
Point the empty statement at the comment it carries (already printed by
the time the node ends, so its location is known), falling back to the
collapsed end-of-last-token position. Every printed node then has a
location, so the SequencePointBuilder invariant holds without
special-casing, and the statement lines up with the text it represents.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The pretty DynamicTests exercised arithmetic and relational binary operators on
dynamic operands, but never the bitwise and shift operators (& | ^ << >>). Add a
BitwiseAndShiftBinaryOperators case so this operator family is pinned by a
round-trip test alongside the others.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The pretty DynamicTests only exercised += -= *= /= on a dynamic target, leaving
%= &= |= ^= <<= >>= unverified even though VisitDynamicCompoundAssign and
GetAssignmentOperatorTypeFromExpressionType already map them. Add them so the
full set of dynamic compound-assignment operators is pinned by a round-trip
test.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The correctness DynamicTests only ran binary + - * / on a dynamic operand, so
no test recompiled and executed decompiled output for any unary operator. That
gap is why ~ on a dynamic value (issue #3820) shipped uncompilable output
undetected: a correctness case round-trips the decompilation through the
compiler, so it fails the moment the decompiler emits something that does not
recompile.
Add ~, -, +, and ! cases. They pin the runtime semantics of the dynamic unary
path and would have caught the OnesComplement regression directly.
Assisted-by: Claude:claude-opus-4-8:Claude Code
VisitDynamicUnaryOperatorInstruction handled every dynamic unary operator
except ExpressionType.OnesComplement, so ~x on a dynamic operand fell through
to the unsupported-opcode error expression and produced uncompilable output
(an incomplete cast that fails to parse). Map it to the bitwise-complement
operator, like the sibling unary cases.
Assisted-by: Copilot:claude-opus-4.8:GitHub Copilot CLI