Reconstruct nested deconstruction designations - #3950
Conversation
773733f to
555e364
Compare
554f0cf to
f962833
Compare
Code reviewWhat this does
The IL-side design is sound: depth-first leaf indexing is exactly the order VerificationBeyond reading the diff (macOS,
Test coverage is considerably better than the description advertises ("9 Pretty fixtures ... plus class-inner and depth-3 Correctness cases"): there are ~18 of each, including member hiding pinning FindingsNothing blocking; no correctness defect found. Comments left at the relevant lines.
Minor: ConventionsCommit messages, |
159eaf0 to
63585d1
Compare
7b67a41 to
72b880f
Compare
b93b51e to
59f8bfc
Compare
23e4db9 to
1f6efcc
Compare
The transform is about to be extended substantially; annotating it first keeps the null contracts of the matcher explicit, where "no match" is expressed by a null out-argument throughout. The matching state fields are non-null only while a match is in progress, which the codebase's null! idiom expresses; MatchConversion additionally gets the null check its caller's ElementAtOrDefault already implies. Assisted-by: Claude:claude-opus-5:Claude Code
GetTupleElementTypes returns a default ImmutableArray when the type is not tuple-compatible, so reading Length threw NullReferenceException instead of taking the documented return-null path. Assisted-by: Claude:claude-fable-5:Claude Code
Deconstruction into a pointer target ((*p, value) = tuple;) stayed an explicit Deconstruct call: a store through a pointer (or through a target whose pointer type got erased in a stack slot) does not infer a ByReferenceType, so IsAssignment reported an unknown expected type and the transform's conversion check rejected the assignment. The type of the store itself is just as precise, so use it as the expected type. Of the three IsAssignment call sites only the transform's MatchAssignment consumes the expected type; CheckInvariant and GetAssignmentIndex discard it, so this widens what the transform accepts without weakening the invariant check. Assisted-by: Claude:claude-opus-5:Claude Code
An assignment whose value is not one of the deconstruction's elements used to reject the whole match, so a custom deconstruction followed by any unrelated assignment stayed an explicit Deconstruct call. For a pattern rooted in a Deconstruct call the element list is fixed by the call's out-arguments, so such an assignment simply ends the pattern and stays after the deconstruct instruction. Tuple-rooted patterns keep rejecting: their element list is discovered from the assignments, so ending early would misread a suffix of the assignments as the whole pattern and fabricate discards for the elements before it. Assisted-by: Claude:claude-opus-5:Claude Code
A nested designation, var (x, (a, b)) = o;, is lowered to a chain of Deconstruct calls - the inner call taking the outer call's out-argument as its target, through a defensive copy where the element is a struct - and decompiled as a flat deconstruction followed by an explicit Deconstruct call. The IL pattern node, its invariants and the C# builders already support nested patterns; only the transform never built them. MatchDeconstruction now consumes the chain into a tree of match patterns. The leaves get flat indices in depth-first order, which is the order in which StatementBuilder and ExpressionBuilder pair pattern variables with assignments, so the conversion and assignment matching runs unchanged on top of a nested pattern. Two matching rules follow from the chain being consumed: a call pattern no longer needs a matched assignment, because single-use leaves are covered by the forwarding fixup in MatchAssignments; and a pattern is not rooted on an element of an enclosing deconstruction, because blocks are processed back to front, so the inner call is visited first and would otherwise consume the pattern piecemeal, starving the outer call. That guard runs the enclosing match as a dry run, which is precise: a barrier statement between the calls or an element with further uses makes it fail, and the inner deconstruction is then still transformed on its own. Assisted-by: Claude:claude-opus-5:Claude Code
A nested designation rebinds Deconstruct on the element's static type when the output is recompiled, while the explicit call it replaces is bound at the call site. Where a derived element type declares a Deconstruct of the same arity as the called method, and the source deconstructs through a base-typed view, the two bindings differ, so the sugared output calls the wrong method - a divergence the runtime fixture demonstrates on optimized builds, where copy propagation elides the view. Nesting is therefore only applied when the method the call binds to is the one a designation would rebind to; otherwise the call stays explicit, where its receiver cast preserves the binding. Assisted-by: Claude:claude-opus-5:Claude Code
Element index resolution serves both pattern roots: a registered result of a Deconstruct call, or an element read of a tuple, which it discovers on first sight and then owns. In an attempt rooted in a Deconstruct call the tuple branch must not engage - it overwrites the call's result bookkeeping and rewires the element read to a fresh variable that the pattern never defines. The shape that reaches it is a tuple whose element is custom-deconstructed with discarded leaves, followed by an unrelated assignment: the tuple-rooted attempt fails, the call-rooted one runs at the element's position, and, now that an unrelated assignment ends a call pattern instead of rejecting it, the mixed match is no longer rejected on the way out. Assisted-by: Claude:claude-opus-5:Claude Code
A nested designation over tuples, var (x, (a, b)) = t;, is lowered to one temporary per nested designation - parents before children - followed by the element reads in depth-first leaf order, and decompiled as a flat deconstruction plus separate element statements. The temporaries are now consumed into a tree of tuple nodes before the conversions and assignments are matched, and the leaves get the same flat depth-first indices the Deconstruct-call chain hands out, so conversion and assignment matching runs unchanged. Two properties of the lowered IL shape the matcher to it: earlier transforms rewrite non-escaping element reads from ldloca to ldloc, and the temporaries are stack slots whose type is imprecise, so the container's element type is authoritative and the match variable is retyped to keep the tuple pattern's invariant. An element that escapes the deconstruction - used after the statement, so the pattern cannot consume all its reads - demotes back to a designator leaf and the match is retried, which restores the flat deconstruction the escaping read needs. The guard against consuming a pattern piecemeal extends to the new shape: an element read whose container is stored by an earlier element read defers to the match starting at that store. Assisted-by: Claude:claude-opus-5:Claude Code
1f6efcc to
33c0e98
Compare
There was a problem hiding this comment.
Pull request overview
This PR enhances ILSpy’s decompiler pipeline by reconstructing nested deconstruction syntax (both custom Deconstruct-call chains and nested tuple designations) into a single structured DeconstructInstruction, allowing the C# statement/expression builders to emit sugared nested patterns like var (x, (a, b)) = ...; instead of flattening into explicit follow-up calls/element statements.
Changes:
- Extend
DeconstructionTransformto consume chained/nestedDeconstructcalls and nested tuple-designation temporaries into recursive match patterns with depth-first leaf indexing. - Improve pointer-target deconstruction detection by fixing
DeconstructInstruction.IsAssignmentexpected-type inference forstobjinto pointer targets (including stack-slot-erased pointer types). - Fix
TupleType.FromUnderlyingTypeto avoidNullReferenceExceptionon non-tuple input by correctly handling default/empty tuple element arrays.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| ICSharpCode.Decompiler/TypeSystem/TupleType.cs | Prevents NRE in FromUnderlyingType by using IsDefaultOrEmpty on tuple element arrays. |
| ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs | Core change: matches and builds nested deconstruction patterns (call chains + tuple nesting) and integrates conversions/assignments. |
| ICSharpCode.Decompiler/IL/Instructions/DeconstructInstruction.cs | Improves assignment recognition for pointer-target stobj by falling back to stobj.Type. |
| ICSharpCode.Decompiler.Tests/TestCases/Pretty/DeconstructionTests.cs | Adds Pretty fixtures covering nested deconstruction shapes, tuple nesting, unrelated-assignment termination, and pointer targets. |
| ICSharpCode.Decompiler.Tests/TestCases/Correctness/DeconstructionTests.cs | Adds runtime-pinned correctness cases validating evaluation order and nested tuple/custom deconstruction behavior. |
Suppressed comments (2)
ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs:602
- ElementAtOrDefault(pos) on block.Instructions will enumerate via LINQ. A simple bounds check with indexing avoids the overhead and keeps this matching loop allocation-free.
while (MatchTupleElementStore(block.Instructions.ElementAtOrDefault(pos),
out var temp, out var container, out var containerType, out int index))
ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs:782
- MatchAssignments uses ElementAtOrDefault(pos) in a loop; that LINQ call can be avoided by directly indexing with a bounds check.
while (MatchAssignment(block.Instructions.ElementAtOrDefault(pos), out var targetType, out var valueInst, out var addAssignment))
{
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| var inst = block.Instructions.ElementAtOrDefault(pos); | ||
| if (inst != null && inst.MatchStLoc(out var copy, out var copiedValue) | ||
| && copiedValue.MatchLdLoc(result) | ||
| && copy.StoreCount == 1 | ||
| && copy.LoadCount + copy.AddressCount == 1) | ||
| { | ||
| receiver = copy; | ||
| pos++; | ||
| inst = block.Instructions.ElementAtOrDefault(pos); | ||
| } |
| // Pointer-typed targets (or targets whose pointer type got erased in a | ||
| // stack slot) do not infer a ByReferenceType; the type of the store | ||
| // itself is just as precise. | ||
| expectedType = stobj.Type; |
There was a problem hiding this comment.
Correctness (confirmed): IsAssignment's stobj fallback replaces the UnknownType sentinel that rejected stores whose target type cannot be inferred as ByReferenceType. StObj.Type comes from the IL store opcode and is sign-agnostic relative to the true pointer element type, so a sign-mismatched pointer store now passes the implicit-conversion check that previously rejected the transform.
Failure scenario: For var (el, y) = t; *p = (uint)el; with (int, int) t and uint* p, the int-to-uint cast is a no-op in IL (stind.i4, so StObj.Type = int32). InferType of the pointer target yields PointerType, not ByReferenceType, so expectedType becomes int32 and IsCompatibleImplicitConversion sees an identity conversion. The transform sugars this into (*p, y) = t;, which does not recompile (CS0266: cannot implicitly convert int to uint). On master, expectedType was UnknownType, the conversion check failed, and correct unsugared code was emitted.
Found by high-effort multi-agent review; independently verified.
| return MatchLdLocOrLdLoca(target, out var v) && v == receiver; | ||
| } | ||
|
|
||
| static bool BindsOnElementType(IMethod method, IType elementType) |
There was a problem hiding this comment.
Correctness (confirmed): BindsOnElementType only guards against instance-method hiding; it returns true (method.IsStatic) for any extension-method Deconstruct as long as no instance method of the same arity exists on the element's type chain. It therefore does not detect a more-specific competing extension method that a recompiled nested designation would bind to instead of the one the IL actually calls.
Failure scenario: IL calls extension E1.Deconstruct(this Base, out int, out int) on an element whose static type is Derived, while E2.Deconstruct(this Derived, out int, out int) is also in scope. The transform folds the explicit call into var (x, (a, b)) = o;, and recompiling that source resolves to E2 instead of E1 -- the decompiled program has different runtime behavior than the original assembly.
Found by high-effort multi-agent review; independently verified.
| containerNode.NestedElements[index] = node; | ||
| // The temporary's store reads one element of the container. | ||
| containerNode.MatchedAccessCount++; | ||
| this.tupleNodes.Add(temp, node); |
There was a problem hiding this comment.
Correctness (confirmed): MatchNestedTupleDesignations calls tupleNodes.Add(temp, node) where temp can already be present as the container node registered by ResolveTupleContainer, throwing ArgumentException (duplicate key) instead of bailing out. Reachable via a self-referential element store when a reference type named System.ValueTuple`2 (which IsTupleCompatible accepts by name) is instantiated recursively, e.g. ValueTuple<int, C> where C contains itself, so the elementType/temp.Type equivalence guard passes.
Failure scenario: Decompiling an assembly that ships its own class System.ValueTuple (polyfill or obfuscated code) containing t = t.Item2; makes DeconstructionTransform throw ArgumentException "An item with the same key has already been added", so the whole method fails to decompile with an error stub instead of falling back to the flat assignment output.
Found by high-effort multi-agent review; independently verified.
| // Without any assignment the statement is a plain Deconstruct call, unless a nested | ||
| // deconstruction was consumed: then all leaves are single-use elements handled by | ||
| // the forwarding fixup in MatchAssignments. | ||
| if (!anyAssignments && !(rootCall != null && rootCall.NestedCalls.Any(c => c != null))) |
There was a problem hiding this comment.
Correctness (confirmed): The removed return startPos != pos invariant (a deconstruction requires at least one matched assignment) is exempted for call-rooted patterns containing a nested call, so a chain of explicit source-level Deconstruct() call statements whose out-results are never consumed is now folded into a deconstruction declaration.
Failure scenario: Source that literally writes o.Deconstruct(out var i, out var inner); inner.Deconstruct(out var a, out var b); with all results unused decompiles as a discard deconstruction like (_, (_, _)) = o; instead of the explicit call statements. Master kept the calls verbatim because zero matched assignments rejected the pattern; the decompiled output now misrepresents the shape of the original code.
Found by high-effort multi-agent review; independently verified.
| if (!MatchTupleElementStore(store, out _, out _, out _, out _)) | ||
| return false; | ||
| enclosingPos = store.ChildIndex; | ||
| while (enclosingPos > 0 && MatchTupleElementStore(block.Instructions[enclosingPos - 1], out _, out _, out _, out _)) |
There was a problem hiding this comment.
Correctness (confirmed): TryFindEnclosingTupleDesignation's backward walk steps over any MatchTupleElementStore match regardless of container, so it crosses into an adjacent preceding deconstruction's element stores. The dry run then starts at the wrong position, fails, and the nested designation is dismembered instead of reconstructed.
Failure scenario: Two back-to-back deconstructions with no intervening init statement (sources already materialized, e.g. parameters): var (a, inner1) = t1; var (b, (c, d)) = t2;. At the stloc c position, the walk starts from inner2's store and keeps stepping back over stloc inner1 and stloc a, landing enclosingPos at the first deconstruction's start. The dry run from there fails, so IsConsumableByEnclosingDeconstruction returns false and (c, d) = inner2 is transformed standalone. When the walk later reaches inner2's store, its uses are no longer tuple element reads, so nesting is impossible: output is var inner2 = t2.Item2; (var b, _) = t2; var (c, d) = inner2; instead of var (b, (c, d)) = t2; -- the nested reconstruction silently fails whenever two deconstruction statement runs are adjacent. The walk should only step back over stores belonging to the same container tree (e.g. same root container), not any tuple element store.
Found by high-effort multi-agent review; independently verified.
| return false; | ||
| // first tuple element may not be discarded, | ||
| // otherwise we would run this transform on a suffix of the actual pattern. | ||
| if (deconstructionResults[0] == null) |
There was a problem hiding this comment.
Quality regression (confirmed): The deconstructionResults[0] == null check runs before the escaped-tuple-node retry, so a wrongly-nested first element aborts the match instead of being demoted via doNotNest and retried -- losing sugar master already produces.
Failure scenario: For var (inner, x) = GetTuple<(int, int), int>(); Console.WriteLine(inner.Item1 + inner.Item2); Console.WriteLine(x);, MatchNestedTupleDesignations consumes stloc inner as a nested-designation temporary (all of inner's uses are tuple element reads, satisfying AllUsesAreTupleElementReads), but the reads inside WriteLine are never consumed by the pattern, so leaves 0..1 stay unassigned and this check returns false before EscapedTupleNodes() can add inner to doNotNest and retry. The retry would succeed as a flat match. Master sugars this as var (inner, x) = t;; the PR emits raw Item1/Item2 element stores. Moving the escape check before the [0] == null check restores the match.
Found by high-effort multi-agent review; independently verified.
| { | ||
| foreach (var use in temp.AddressInstructions.Concat<ILInstruction>(temp.LoadInstructions)) | ||
| { | ||
| if (!(use.Parent is LdFlda elementAccess && elementAccess.Parent is LdObj)) |
There was a problem hiding this comment.
Correctness (confirmed): AllUsesAreTupleElementReads only accepts the shallow LdObj(LdFlda(use)) shape, so Rest-chained element reads of long tuples (cardinality >= 8) disqualify a nested designation temporary.
Failure scenario: A nested tuple designation whose inner tuple has 8 or more elements, e.g. var (a, (e1, e2, e3, e4, e5, e6, e7, e8)) = t;. The inner temporary's reads of elements 8+ are lowered as ldobj(ldflda Item1(ldflda Rest(ldloca temp))): the use's parent is the Rest LdFlda whose own parent is another LdFlda, not LdObj, so AllUsesAreTupleElementReads returns false and MatchNestedTupleDesignations refuses the nesting -- even though MatchTupleFieldAccess (used by FindIndex/MatchTupleElementRead, whose doc comment explicitly says Rest chains are flattened) would consume those reads fine. The outer match then falls apart into the flat two-statement form instead of the nested designation. The helper should walk the LdFlda chain (or reuse MatchTupleElementRead on the enclosing LdObj) instead of checking only the direct parent pair.
Found by high-effort multi-agent review; independently verified.
| // are visited before the position its matching starts at; matching them on their own | ||
| // would consume the pattern piecemeal. Defer to the enclosing attempt where one | ||
| // exists (see the guard for the precision guarantees). | ||
| if (IsConsumableByEnclosingDeconstruction(block, pos)) |
There was a problem hiding this comment.
Correctness (plausible): With greedy per-position matching removed, an inner deconstruction position is skipped when a dry run says an enclosing match will consume it -- but the enclosing attempt runs only after all intermediate positions are visited by the whole transform pipeline, which can rewrite the block so the enclosing match no longer fires, and the skipped position gets no second chance. (The doc comment on IsConsumableByEnclosingDeconstruction itself concedes the dry run cannot promise the enclosing attempt still matches.)
Failure scenario: A nested shape where an intermediate statement between the inner and outer pattern is rewritten by another statement transform (e.g. inlining a single-use temp) after the dry run succeeds: the outer MatchDeconstructionSequence fails when finally attempted, and the inner deconstruction was already skipped, so the decompiled output regresses to raw Deconstruct calls / tuple element reads with compiler temporaries, where master at least produced the flat (a, b) = temp; sugar for the inner part.
Found by high-effort multi-agent review; verifier judged the mechanism real but could not construct a concrete trigger.
| // The dry run leaves the matcher state behind, which is safe because it runs before | ||
| // the attempt at this position, and both that attempt and Run reset it. It does not | ||
| // modify the block: all rewrites are delayed actions. | ||
| return MatchDeconstructionSequence(block, enclosingPos, out int endPos, out _, out _, out _, out _) |
There was a problem hiding this comment.
Efficiency (confirmed): IsConsumableByEnclosingDeconstruction re-runs the full MatchDeconstructionSequence dry run for every inner statement of the same pattern, matching each nested deconstruction O(k) times.
Details: A nested deconstruction with k inner statements (nested Deconstruct calls, defensive copies, designation temp stores) triggers k separate full dry runs from the same enclosingPos -- each allocating the conversions Dictionary, conversionStLocs List, DeconstructionCall arrays, and delayedActions closures that are then discarded -- plus the final real match: k+1 full matches of one pattern, and each dry run can itself loop on escape retries. On methods dense with tuple/deconstruction code this is quadratic wasted work. Cheaper: since the back-to-front walk visits the k inner positions consecutively, memoize the last dry-run result (block, enclosingPos, endPos) and reuse it instead of re-matching.
Found by high-effort multi-agent review; independently verified.
| @@ -330,11 +737,17 @@ bool MatchConversions(Block block, ref int pos, | |||
| return true; | |||
There was a problem hiding this comment.
Correctness (confirmed): (Anchored here because the affected loop -- if (index <= previousIndex) return false; at ~line 726, just above -- is unchanged context the diff cannot comment on.) MatchConversions still hard-rejects a call-rooted pattern when an unrelated conversion-shaped statement follows the Deconstruct calls -- inconsistent with the new allowUnrelatedAssignments handling one phase later.
Failure scenario: A nested Deconstruct-call chain whose leaves are all single-use (the new anyAssignments == false + NestedCalls path), followed by an unrelated widening assignment such as long l = someInt;: MatchConversions matches the stloc-of-Conv shape, FindIndex(ldloc someInt) returns -1, and if (index <= previousIndex) return false; rejects the entire sequence instead of ending the conversion run -- so no deconstruction is built at all and the output shows raw explicit Deconstruct calls. The same trailing statement without a conv (e.g. int l = GetInt();) is handled gracefully by the new break in MatchAssignments. MatchConversions needs the same "index < 0 for a call-rooted pattern ends the run" treatment (stop consuming, leave the statement after the deconstruction) rather than returning false.
Found by high-effort multi-agent review; independently verified.
Code review summary (high-effort multi-agent review)Reviewed Correctness (recompilation / behavior)
Sugar-loss regressions (output quality)
Efficiency
Refuted during verification: the concern that break-and-commit on unrelated assignments could leave unconsumed Deconstruct results ill-formed -- the Seven further low-severity confirmed findings (minor duplication and micro-inefficiencies) were dropped under the report cap. 🤖 Generated with Claude Code |
Builds on #3949 (merged), rebased onto current master.
After #3949, nested deconstruction decompiled without crashing but unsugared:
var (x, (a, b)) = o;came out as a flat deconstruction followed by an explicitinner.Deconstruct(out var a, out var b);call. The IL pattern node (MatchInstructionwithIsDeconstructCallsub-patterns), its invariants, and the statement/expression builders all already support nested patterns — onlyDeconstructionTransformnever built them.Change (two commits — the chain-matching mechanism, then the rule changes that let chains nest): in
DeconstructionTransform,MatchDeconstructionnow consumes chainedDeconstructcalls — including the defensive copy Roslyn emits for struct elements — into nested match patterns, recursively. Leaves get flat indices in depth-first order, which is exactly the orderStatementBuilder/ExpressionBuilderpair assignments with designators, so conversion/assignment matching runs unchanged on top. Three matching-rule adjustments follow from the chain being consumed: a call pattern no longer needs a matched assignment (single-use leaves are covered by the existing forwarding fixup); an unrelated assignment ends a call pattern instead of rejecting it (tuple patterns still reject — their element list is discovered from the assignments, ending early would misread a suffix); and a pattern is not rooted on an element of an enclosing deconstruction (blocks are processed back to front — the inner call is visited first and would otherwise sugar piecemeal, starving the outer call).The unrelated-assignment rule is keyed on the pattern being call-rooted, not on nesting, so it also fixes flat custom deconstructions:
var (a, b) = GetSource(...);followed by any unrelated assignment previously decompiled as an explicitDeconstructcall and now sugars (pinned by theLocalVariable_NoConversion_Custom_UnrelatedAssignmentAfterfixture).One production change lands outside the transform, as its own commit carrying its two pointer-target Pretty fixtures:
DeconstructInstruction.IsAssignmentfalls back tostobj.Typewhen a pointer target was materialized through a stack slot (whose type erases to unknown during target evaluation). This independently enables deconstruction into pointer targets —(*p, value) = tuple;— including plain tuples with no nesting at all.Nested tuples too: the second half of the series extends the nesting to pure tuple chains (fixtures, then the temporary-consuming mechanism, then the deferral guard) —
var (x, (a, b)) = GetTuple<int, (int, int)>();, which Roslyn lowers to one temporary per nested designation (parents before children) plus element reads in depth-first leaf order, previously decompiled as a flat deconstruction plus separate element statements. The matcher now consumes the temporaries into a tuple-node tree with flat depth-first leaf indices (the same design as the call nesting). An element variable that escapes the deconstruction (used after the statement) demotes back to a designator leaf via a blacklist-and-retry, and a dry-run guard — the tuple analogue of the call path's — defers inner flat matches to the enclosing attempt. Two IL realities drove the matcher details: earlier transforms rewrite non-escaping element reads fromldlocatoldloc, and stack-slot temporaries carry imprecise types (the container's element type is authoritative; the match variable is retyped so theIsDeconstructTupleinvariant holds). One production change again lands outside the transform, in its own commit:TupleType.FromUnderlyingTypethrewNullReferenceExceptionon non-tuple input instead of returning null as documented.Scope: call-under-call and tuple-in-tuple nesting, any depth, assignment + foreach forms, discards, conversions on leaves. Mixed containers (a call pattern absorbing tuple elements, or a custom-deconstructed element inside a tuple pattern) intentionally stay flat, as do by-ref extension receivers (
Deconstruct(this in S, ...)).Tests: each feature's fixtures land ahead of its implementation and are red until it. For the call nesting: 20 Pretty fixtures (the flat unrelated-assignment form, struct/class inners, both-elements-nested, depth 3, inner discard, nested/typed/nullable conversions,
System.Tuplesources, the two bail-out shapesElementDeconstructedAfterBarrier/OuterElementUsedTwice, two foreach forms incl.KeyValuePairextension Deconstruct; the two pointer-target forms ride in the pointer commit) and 18 Correctness cases with printedDeconstructcalls pinning evaluation order at runtime, including member hiding (NestedDeconstruction_HiddenDeconstructMethodpins theBindsOnElementTypegate), side-effecting LHS targets, checked/nullable conversions,in-parameter, conditional and generic-constraint sources, and the tuple-with-custom-element corruption repro. For the tuple nesting: 6 Pretty fixtures (depth 2 and 3, both elements nested, conversions on inner leaves, the escaping-element case pinned per config, foreach) and 5 Correctness cases with runtime-value pins. Every commit in the series builds, and at every point the only failing tests are the not-yet-implemented spec fixtures (verified per intermediate). Full decompiler suite at the head: 3332 tests, 0 failures.Output polish for #3803/#3388-adjacent cases; closes nothing by itself.
🤖 Generated with Claude Code