Skip to content

Reconstruct nested deconstruction designations - #3950

Merged
siegfriedpammer merged 11 commits into
masterfrom
nested-deconstruction
Aug 7, 2026
Merged

Reconstruct nested deconstruction designations#3950
siegfriedpammer merged 11 commits into
masterfrom
nested-deconstruction

Conversation

@siegfriedpammer

@siegfriedpammer siegfriedpammer commented Aug 1, 2026

Copy link
Copy Markdown
Member

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 explicit inner.Deconstruct(out var a, out var b); call. The IL pattern node (MatchInstruction with IsDeconstructCall sub-patterns), its invariants, and the statement/expression builders all already support nested patterns — only DeconstructionTransform never built them.

Change (two commits — the chain-matching mechanism, then the rule changes that let chains nest): in DeconstructionTransform, MatchDeconstruction now consumes chained Deconstruct calls — 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 order StatementBuilder/ExpressionBuilder pair 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 explicit Deconstruct call and now sugars (pinned by the LocalVariable_NoConversion_Custom_UnrelatedAssignmentAfter fixture).

One production change lands outside the transform, as its own commit carrying its two pointer-target Pretty fixtures: DeconstructInstruction.IsAssignment falls back to stobj.Type when 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 from ldloca to ldloc, and stack-slot temporaries carry imprecise types (the container's element type is authoritative; the match variable is retyped so the IsDeconstructTuple invariant holds). One production change again lands outside the transform, in its own commit: TupleType.FromUnderlyingType threw NullReferenceException on 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.Tuple sources, the two bail-out shapes ElementDeconstructedAfterBarrier/OuterElementUsedTwice, two foreach forms incl. KeyValuePair extension Deconstruct; the two pointer-target forms ride in the pointer commit) and 18 Correctness cases with printed Deconstruct calls pinning evaluation order at runtime, including member hiding (NestedDeconstruction_HiddenDeconstructMethod pins the BindsOnElementType gate), 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

@siegfriedpammer
siegfriedpammer force-pushed the fix-3803-nested-deconstruction branch from 773733f to 555e364 Compare August 1, 2026 20:29
@siegfriedpammer
siegfriedpammer force-pushed the nested-deconstruction branch 7 times, most recently from 554f0cf to f962833 Compare August 2, 2026 05:44
@christophwille

Copy link
Copy Markdown
Member

Code review

What this does

DeconstructionTransform now reconstructs nested designations. MatchDeconstruction consumes chained Deconstruct calls (including Roslyn's defensive copy for struct elements) into a recursive DeconstructionCall tree, BuildPatternMatch turns that into nested MatchInstruction sub-patterns, and leaves get flat depth-first indices so the existing conversion/assignment matching keeps working unchanged. Three matching rules relax as a consequence, plus IsConsumableByEnclosingDeconstruction defers an inner call to its enclosing one (blocks are walked back to front, so the inner call is reached first).

The IL-side design is sound: depth-first leaf indexing is exactly the order ConstructTuple / ConstructDesignation consume assignments; nested receiver variables end up with LoadCount == SubPatterns.Count, so MatchInstruction.HasDesignator stays false and ValidatePattern is satisfied; and DeconstructResultInstruction correctly keeps per-call indices while the lookup keeps flat ones - two separate index spaces that never mix.

Verification

Beyond reading the diff (macOS, net11.0, Debug):

  • TDD claim holds. Built the test-only commit (f8694ae63) on its own: 6 DeconstructionTests variants fail there, all 6 green on the implementation commit. Red-then-green, as described.
  • No regressions. Full suite on f96283358 and on the base 555e364d8, failure sets diffed: identical, zero new failures, on 3332 tests. (The environmental failures are macOS-only - net40 targets, legacy Roslyn reference assemblies, Windows ILAsm - and match on both sides.)
  • Riskiest new path checked. The relaxed "unrelated assignment ends the pattern" rule can leave a later element without an assignment, so the Fix #3803: Crash on nested deconstruction of custom structs #3949 forwarding fixup pulls its load into the deconstruct instruction. Fed the transform an interleaved case (x = a; z = GetInt(); y = b;) and the output is correct - the element is forwarded through a fresh variable and statement order is preserved.
  • The IsAssignment hunk is load-bearing. Reverted it in isolation: the 6 runnable variants fail, with Pointer_NoConversion_Tuple and Pointer_Nested_Custom regressing to unsugared form.

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 BindsOnElementType, evaluation-order pinning with side-effecting LHS targets, checked/nullable conversions, in parameter and conditional sources, and the two bail-out paths (ElementDeconstructedAfterBarrier, OuterElementUsedTwice). Worth updating the description - the extra cases are the strongest argument for the PR.

Findings

Nothing blocking; no correctness defect found. Comments left at the relevant lines.

  1. DeconstructionTransform.cs:591 - the biggest user-visible win in this PR is untested and unadvertised. allowUnrelatedAssignments is keyed on rootCall != null, not on nesting, so it also fixes flat deconstructions. Measured on the base branch: var (a, b) = GetSource<MyInt?, MyInt>(); int v = GetInt(); decompiles as an explicit Deconstruct call today, and sugars correctly with this PR. Every fixture for the rule is nested, so a later narrowing would silently regress the flat form.
  2. DeconstructInstruction.cs:266 - second production change, outside the stated scope. The description says the change is "all in DeconstructionTransform". This hunk independently enables deconstruction into pointer targets, including for plain tuples with no nesting at all.
  3. DeconstructionTransform.cs:345 - the bail-out comment overstates what the dry run proves. It establishes that the chain is consumable, not that the enclosing attempt will succeed; MatchConversions/MatchAssignments can still reject there, and the back-to-front walk gives no second chance. I could not construct a case that actually loses sugar, so this is about the comment, not a defect.

Minor: MatchDeconstruction (line 311) returns bool but the result is discarded at its only call site, which tests rootCall != null instead - void would read more honestly.

Conventions

Commit messages, Assisted-by: trailer, ASCII-only, en-US, self-contained comments, no new files needing license headers, warning-clean build. The two-commit split (spec, then implementation) matches the repo's TDD rule and genuinely verifies.

Comment thread ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs
Comment thread ICSharpCode.Decompiler/IL/Instructions/DeconstructInstruction.cs
Comment thread ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs Outdated
@siegfriedpammer
siegfriedpammer force-pushed the fix-3803-nested-deconstruction branch 2 times, most recently from 159eaf0 to 63585d1 Compare August 2, 2026 12:52
@siegfriedpammer
siegfriedpammer force-pushed the nested-deconstruction branch 2 times, most recently from 7b67a41 to 72b880f Compare August 2, 2026 14:11
@siegfriedpammer
siegfriedpammer changed the base branch from fix-3803-nested-deconstruction to master August 2, 2026 14:36
@siegfriedpammer
siegfriedpammer force-pushed the nested-deconstruction branch 5 times, most recently from b93b51e to 59f8bfc Compare August 5, 2026 03:55
Comment thread ICSharpCode.Decompiler.Tests/TestCases/Correctness/DeconstructionTests.cs Outdated
@siegfriedpammer
siegfriedpammer force-pushed the nested-deconstruction branch 7 times, most recently from 1f6efcc to 33c0e98 Compare August 6, 2026 19:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 DeconstructionTransform to consume chained/nested Deconstruct calls and nested tuple-designation temporaries into recursive match patterns with depth-first leaf indexing.
  • Improve pointer-target deconstruction detection by fixing DeconstructInstruction.IsAssignment expected-type inference for stobj into pointer targets (including stack-slot-erased pointer types).
  • Fix TupleType.FromUnderlyingType to avoid NullReferenceException on 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.

Comment thread ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs
Comment thread ICSharpCode.Decompiler/IL/Instructions/DeconstructInstruction.cs Outdated
Comment thread ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs
Comment thread ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs
Comment thread ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs
Comment thread ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs Outdated
Comment thread ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs Outdated
Comment thread ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs
Comment thread ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs
Comment thread ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs Outdated
Comment thread ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs
@christophwille

Copy link
Copy Markdown
Member

Code review summary (high-effort multi-agent review)

Reviewed 17e6649ba...33c0e986d (5 changed files). 18 candidate findings were pooled from four independent finder passes; each was adversarially verified by a separate agent against the PR head. 17 survived, 1 was refuted; the 10 most severe are posted as inline comments at their code locations. All are in DeconstructionTransform.cs unless noted.

Correctness (recompilation / behavior)

  1. DeconstructInstruction.cs:266 -- stobj fallback accepts sign-mismatched pointer stores. The fallback replaces the UnknownType sentinel; StObj.Type is sign-agnostic (from the store opcode), so (*p, y) = t; can be emitted where the original required an explicit (uint) cast -- output no longer recompiles (CS0266).
  2. :544 -- BindsOnElementType misses competing extension methods. It guards only against instance-method hiding; a more-specific extension Deconstruct in scope means the folded nested designation rebinds to a different method than the IL calls -- changed runtime behavior.
  3. :636 -- reachable ArgumentException (duplicate tupleNodes key). A self-referential element store on a by-name-matched System.ValueTuple polyfill/obfuscated class crashes the whole method's decompilation instead of falling back.
  4. :211 -- unused explicit Deconstruct() call chains are folded into discard designations ((_, (_, _)) = o;), misrepresenting source that master reproduced verbatim.

Sugar-loss regressions (output quality)

  1. :381 -- the backward walk in TryFindEnclosingTupleDesignation crosses into an adjacent preceding deconstruction's element stores, so nested reconstruction silently fails whenever two deconstruction runs are adjacent. Walk should stay within the same container tree.
  2. :215 -- deconstructionResults[0] == null check runs before the escaped-tuple-node retry, so a wrongly-nested first element aborts instead of being demoted and retried -- a regression vs. master's flat sugar.
  3. :726 (anchored at 737) -- MatchConversions hard-rejects the whole call-rooted pattern on a trailing unrelated conversion, inconsistent with the new allowUnrelatedAssignments break in MatchAssignments.
  4. :645 -- AllUsesAreTupleElementReads misses Rest-chained reads, so nested designations with inner tuples of 8+ elements are refused.
  5. :130 (plausible, no concrete trigger constructed) -- a skipped inner position gets no second chance if intermediate transforms rewrite the block so the enclosing match no longer fires; the dry-run doc comment itself concedes this window.

Efficiency

  1. :318 -- IsConsumableByEnclosingDeconstruction re-runs the full dry run per inner statement (k+1 full matches of one pattern, with per-run allocations). Memoizing the last dry-run result over the consecutive back-to-front walk would remove the quadratic rework.

Refuted during verification: the concern that break-and-commit on unrelated assignments could leave unconsumed Deconstruct results ill-formed -- the LoadCount <= 1 constraint in MatchDeconstructionCall plus the forwarding fixup covers all cases.

Seven further low-severity confirmed findings (minor duplication and micro-inefficiencies) were dropped under the report cap.

🤖 Generated with Claude Code

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
C# only accepts System.ValueTuple as a tuple when it is a struct, so a class of
that name is an unrelated type and rendering it with tuple syntax describes it
as something it is not. It also made a tuple appear to contain itself, which no
struct can, and the deconstruction transform then registered the same variable
as a node of its tuple tree twice and threw ArgumentException, failing the whole
method instead of leaving the statements alone.

The check has accepted classes since tuples were added to the type system,
alongside a name comparison against "ValueType" that was corrected later.

Assisted-by: Claude:claude-opus-5[1m]: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
Deferring an inner deconstruction to its enclosing one used to be decided by
matching the enclosing pattern in full, once per inner statement of the same
pattern, discarding everything but the end position.

The same decisions are available without it. A nested Deconstruct call can only
be consumed by an enclosing one that is the immediately preceding statement,
looking through the defensive copy of a struct element; anything else in between
is a barrier that stops the enclosing from reaching this position, so it matches
here instead. That leaves the case where the enclosing call is adjacent but
cannot match anyway, which is decided by the constraint MatchDeconstructionCall
already places on its out-parameters.

The tuple-designation branch no longer needs the position the enclosing run
starts at, so the backward walk that searched for it is gone with it. The added
fixtures pin reconstruction across adjacent deconstructions, whose element
stores that walk used to step through.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code

Only defer to an enclosing designation that can reach this position

The temporaries and element reads of a nested tuple designation are stored back
to back, so a statement of any other kind between the temporary and a read of it
stops the enclosing pattern from consuming that read. Deferring anyway lost the
deconstruction entirely: the enclosing attempt fails and the back-to-front walk
does not return to the position that stepped aside for it, so the reads were left
as the plain element accesses they came from, which master reconstructs.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
@christophwille

Copy link
Copy Markdown
Member

Re-review of the updated branch

Follow-up to the review from this morning, checked against the current head (0007566). Verification: built ilspycmd from this branch and from the merge-base side by side, decompiled targeted repros for the disputed findings (Debug and Release), and ran the full DeconstructionTests matrix locally -- Pretty, Correctness and Ugly runners, all 24 configurations green.

Verified fixed

  • stobj pointer element type (DeconstructInstruction.IsAssignment): the definition-chain recovery works, (*p, value) = GetTuple<uint, int>(); reconstructs, and the _UInt/_Byte fixtures pin the sign-agnostic-opcode symptom. Pointer-arithmetic targets stay unsugared but byte-identical to the merge-base (pre-existing un-lifted case, noted in the thread).
  • Competing extension Deconstruct (BindsOnElementType): requiring the element type to be the receiver type itself is the right conservative rule; the residual risk of a same-receiver-type extension in a closer scope is exactly the exposure the flat (x, y) = o; sugar has always had, so nesting is now at parity or stricter.
  • Duplicate tuple node -> fixed one layer down in TupleType.IsTupleCompatible: requiring a struct matches C# (Roslyn rejects a System.ValueTuple class with CS8182), and it removes the only route to a self-containing "tuple". The FromUnderlyingType IsDefaultOrEmpty guard correctly handles the default-array case its new callers can hit.
  • Deferral precision and cost (former dry run): the O(1) adjacency/shape rules in IsConsumableByEnclosingDeconstruction check out, the "costs sugar, never correctness" argument holds (the deferral mutates nothing), and the barrier fixtures pin the shapes the dry run used to get wrong. This also resolves the quadratic re-matching finding.
  • ElementAtOrDefault replaced by the indexed InstructionAt helper at all call sites.
  • The eager-LoadCount reasoning documented at the forwarding fixup (fresh tuple E_i variables never reach the lookup because their loads only materialize when the delayed actions run) was checked and holds, including the Debug.Assert claim.

Accepted as argued

Withdrawn

  • MatchConversions rejecting a trailing unrelated conversion: could not be reproduced from any C#-reachable shape against the current head; details in the thread.

Still open (1)

  • Escape-retry unreachable for a first-position element: the regression from the morning review still reproduces on the current head -- var (inner, x) = t; with inner read only via inner.Item1/inner.Item2 loses the flat sugar the merge-base produces, because deconstructionResults[0] == null returns before EscapedTupleNodes() can trigger the doNotNest retry. Repro, explanation of why the earlier probes did not trigger it, and a suggested reorder are in the thread reply.

This is an AI-generated re-review (Claude), posted on Christoph's behalf.

A nested designation whose temporary is still read elsewhere is retried with
that variable demoted to a designator leaf. The check that the first tuple
element must be assigned ran before that retry, and every leaf of a wrongly
nested first element precedes the assigned ones, so the pattern looked like it
started mid-way and was rejected before the retry could restore it. The flat
deconstruction was lost for a shape that has one.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
@siegfriedpammer
siegfriedpammer merged commit fdc4c7c into master Aug 7, 2026
15 checks passed
@siegfriedpammer
siegfriedpammer deleted the nested-deconstruction branch August 7, 2026 14:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants