You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2026/05/01 10:32:22 UTC

(groovy) branch master updated: GROOVY-11964: Additional multi-assignment forms

This is an automated email from the ASF dual-hosted git repository.

paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git


The following commit(s) were added to refs/heads/master by this push:
     new 661e6cfc78 GROOVY-11964: Additional multi-assignment forms
661e6cfc78 is described below

commit 661e6cfc78a3500622c016148dfd4e16f541631d
Author: Paul King <pa...@asert.com.au>
AuthorDate: Sat Apr 25 19:39:39 2026 +1000

    GROOVY-11964: Additional multi-assignment forms
---
 src/antlr/GroovyParser.g4                          |    7 +-
 .../apache/groovy/parser/antlr4/AstBuilder.java    |   47 +-
 .../groovy/ast/MultipleAssignmentMetadata.java     |   32 +
 .../classgen/asm/BinaryExpressionHelper.java       |  145 +-
 .../groovy/runtime/MultipleAssignmentSupport.java  |  167 ++
 .../transformers/BinaryExpressionTransformer.java  |   17 +-
 .../transform/stc/StaticTypeCheckingVisitor.java   |  368 +++-
 .../MultipleAssignmentDeclarationTest.groovy       | 1760 ++++++++++++++++++++
 .../groovy/transform/stc/STCAssignmentTest.groovy  |   10 +-
 9 files changed, 2536 insertions(+), 17 deletions(-)

diff --git a/src/antlr/GroovyParser.g4 b/src/antlr/GroovyParser.g4
index 8501b7658e..d0b39c0b7a 100644
--- a/src/antlr/GroovyParser.g4
+++ b/src/antlr/GroovyParser.g4
@@ -578,10 +578,15 @@ variableDeclaration[int t]
 
 typeNamePairs
     :   LPAREN typeNamePair (COMMA typeNamePair)* RPAREN
+    |   LPAREN keyedPair (COMMA keyedPair)* RPAREN
     ;
 
 typeNamePair
-    :   type? variableDeclaratorId
+    :   (DEF | VAR | type)? MUL? variableDeclaratorId
+    ;
+
+keyedPair
+    :   key=identifier COLON (DEF | VAR | type)? variableDeclaratorId
     ;
 
 variableNames
diff --git a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
index ab506a3483..aa43a3feff 100644
--- a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
+++ b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
@@ -56,6 +56,7 @@ import org.codehaus.groovy.ast.EnumConstantClassNode;
 import org.codehaus.groovy.ast.FieldNode;
 import org.codehaus.groovy.ast.GenericsType;
 import org.codehaus.groovy.ast.ImportNode;
+import org.codehaus.groovy.ast.MultipleAssignmentMetadata;
 import org.codehaus.groovy.ast.InnerClassNode;
 import org.codehaus.groovy.ast.MethodNode;
 import org.codehaus.groovy.ast.ModifierNode;
@@ -2205,16 +2206,56 @@ public class AstBuilder extends GroovyParserBaseVisitor<Object> {
 
     @Override
     public List<Expression> visitTypeNamePairs(final TypeNamePairsContext ctx) {
-        return ctx.typeNamePair().stream().map(this::visitTypeNamePair).collect(Collectors.toList());
+        if (asBoolean(ctx.keyedPair())) { // GEP-20 map-style: def (name: n, age: a) = person
+            return ctx.keyedPair().stream().map(this::visitKeyedPair).collect(Collectors.toList());
+        }
+        List<Expression> pairs = ctx.typeNamePair().stream().map(this::visitTypeNamePair).collect(Collectors.toList());
+        // GEP-20: at most one rest binding (*) per parens form
+        boolean seenRest = false;
+        for (Expression e : pairs) {
+            if (Boolean.TRUE.equals(e.getNodeMetaData(MultipleAssignmentMetadata.REST_BINDING))) {
+                if (seenRest) {
+                    throw createParsingFailedException("Only one rest binding (*) is allowed in a multi-assignment", e);
+                }
+                seenRest = true;
+            }
+        }
+        return pairs;
     }
 
     @Override
     public VariableExpression visitTypeNamePair(final TypeNamePairContext ctx) {
-        return configureAST(
+        boolean isRest = asBoolean(ctx.MUL());
+        // GEP-20: typed rest (e.g. `def (h, List<Integer> *t) = list`) is accepted; the
+        // declared container type is honoured by static type checking and runtime coercion.
+        // `def` and `var` are also accepted in place of a type (equivalent to omitting it),
+        // for symmetry with switch case patterns and the bracket-form declaration grammar.
+        VariableExpression ve = configureAST(
                 new VariableExpression(
                         this.visitVariableDeclaratorId(ctx.variableDeclaratorId()).getName(),
-                        this.visitType(ctx.type())),
+                        binderType(ctx.DEF(), ctx.VAR(), ctx.type())),
                 ctx);
+        if (isRest) {
+            ve.putNodeMetaData(MultipleAssignmentMetadata.REST_BINDING, Boolean.TRUE);
+        }
+        return ve;
+    }
+
+    @Override
+    public VariableExpression visitKeyedPair(final KeyedPairContext ctx) {
+        VariableExpression ve = configureAST(
+                new VariableExpression(
+                        this.visitVariableDeclaratorId(ctx.variableDeclaratorId()).getName(),
+                        binderType(ctx.DEF(), ctx.VAR(), ctx.type())),
+                ctx);
+        ve.putNodeMetaData(MultipleAssignmentMetadata.MAP_KEY, this.visitIdentifier(ctx.key));
+        return ve;
+    }
+
+    /** GEP-20: resolve a binder's declared type — `def`/`var` produce the dynamic type, same as omitting a type. */
+    private ClassNode binderType(final TerminalNode defNode, final TerminalNode varNode, final TypeContext typeCtx) {
+        if (asBoolean(defNode) || asBoolean(varNode)) return ClassHelper.dynamicType();
+        return this.visitType(typeCtx);
     }
 
     @Override
diff --git a/src/main/java/org/codehaus/groovy/ast/MultipleAssignmentMetadata.java b/src/main/java/org/codehaus/groovy/ast/MultipleAssignmentMetadata.java
new file mode 100644
index 0000000000..a237e12b98
--- /dev/null
+++ b/src/main/java/org/codehaus/groovy/ast/MultipleAssignmentMetadata.java
@@ -0,0 +1,32 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.codehaus.groovy.ast;
+
+/**
+ * AST node metadata keys used by the multi-assignment destructuring pipeline
+ * introduced in GEP-20. Each key is attached to a {@code VariableExpression}
+ * appearing inside the {@code TupleExpression} on the LHS of a
+ * {@code DeclarationExpression}.
+ */
+public enum MultipleAssignmentMetadata {
+    /** Marker on the rest binder (the {@code *ident} slot). Value is {@link Boolean#TRUE}. */
+    REST_BINDING,
+    /** Value is the key name (String) used for a map-style {@code key: ident} binder. */
+    MAP_KEY
+}
diff --git a/src/main/java/org/codehaus/groovy/classgen/asm/BinaryExpressionHelper.java b/src/main/java/org/codehaus/groovy/classgen/asm/BinaryExpressionHelper.java
index b452df8cff..a2d83e8a37 100644
--- a/src/main/java/org/codehaus/groovy/classgen/asm/BinaryExpressionHelper.java
+++ b/src/main/java/org/codehaus/groovy/classgen/asm/BinaryExpressionHelper.java
@@ -21,6 +21,7 @@ package org.codehaus.groovy.classgen.asm;
 import org.codehaus.groovy.GroovyBugError;
 import org.codehaus.groovy.ast.ClassHelper;
 import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.MultipleAssignmentMetadata;
 import org.codehaus.groovy.ast.Variable;
 import org.codehaus.groovy.ast.expr.ArrayExpression;
 import org.codehaus.groovy.ast.expr.BinaryExpression;
@@ -43,6 +44,7 @@ import org.codehaus.groovy.ast.tools.GenericsUtils;
 import org.codehaus.groovy.ast.tools.WideningCategories;
 import org.codehaus.groovy.classgen.AsmClassGenerator;
 import org.codehaus.groovy.classgen.BytecodeExpression;
+import org.codehaus.groovy.runtime.MultipleAssignmentSupport;
 import org.codehaus.groovy.runtime.ScriptBytecodeAdapter;
 import org.codehaus.groovy.syntax.Token;
 import org.objectweb.asm.Label;
@@ -53,10 +55,12 @@ import static org.codehaus.groovy.ast.tools.GeneralUtils.args;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.binX;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.boolX;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.callX;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.classX;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.constX;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.elvisX;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.notX;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.nullX;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.propX;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.ternaryX;
 import static org.codehaus.groovy.syntax.Types.ASSIGN;
 import static org.codehaus.groovy.syntax.Types.BITWISE_AND;
@@ -505,6 +509,101 @@ public class BinaryExpressionHelper {
             leftExpression.visit(acg);
             operandStack.remove(operandStack.getStackLength() - mark);
         } else { // multiple declaration or assignment
+            TupleExpression tuple = (TupleExpression) leftExpression;
+            java.util.List<Expression> elements = tuple.getExpressions();
+            int tupleSize = elements.size();
+            int restIndex = -1;
+            for (int idx = 0; idx < tupleSize; idx++) {
+                if (Boolean.TRUE.equals(elements.get(idx).getNodeMetaData(MultipleAssignmentMetadata.REST_BINDING))) {
+                    restIndex = idx;
+                    break;
+                }
+            }
+            boolean hasRest = (restIndex >= 0);
+            boolean tailRest = hasRest && restIndex == tupleSize - 1;
+            boolean isMapStyle = !elements.isEmpty()
+                    && elements.get(0).getNodeMetaData(MultipleAssignmentMetadata.MAP_KEY) != null;
+
+            // GEP-20 map-style destructuring: def (name: n, age: a) = person
+            // Each binder is emitted as a property access on the RHS, dispatched via the MOP
+            // (Map → key lookup, bean → getter, GroovyObject → getProperty).
+            if (isMapStyle) {
+                for (Expression e : elements) {
+                    String key = (String) e.getNodeMetaData(MultipleAssignmentMetadata.MAP_KEY);
+                    // Property access is a read here; the surrounding pushLHS(true) above would
+                    // otherwise mark it as a store target.
+                    compileStack.popLHS();
+                    propX(rhsValueLoader, key).visit(acg);
+                    compileStack.pushLHS(true);
+                    assignOneMultiAssignSlot(e, defineVariable, operandStack, compileStack, acg);
+                }
+                compileStack.popLHS();
+                if (returnRightValue) rhsValueLoader.visit(acg);
+                compileStack.removeVar(rhsValueId);
+                return;
+            }
+
+            // GEP-20 degenerate case: `def (*t) = rhs` — single rest binder; equivalent to `def t = rhs`.
+            if (tailRest && tupleSize == 1) {
+                rhsValueLoader.visit(acg);
+                if (defineVariable) {
+                    Variable v = (Variable) elements.get(0);
+                    operandStack.doGroovyCast(v);
+                    compileStack.defineVariable(v, true);
+                    operandStack.remove(1);
+                } else {
+                    elements.get(0).visit(acg);
+                }
+                compileStack.popLHS();
+                if (returnRightValue) rhsValueLoader.visit(acg);
+                compileStack.removeVar(rhsValueId);
+                return;
+            }
+
+            // GEP-20 head/middle rest: def (*f, last) = list, def (l, *m, r) = list, etc.
+            // Requires a sized, indexable RHS (Path B only — no iterator fallback).
+            // Load-bearing ordering (GEP lines 177-186): the IntRange call for the rest slot
+            // must be emitted BEFORE any negative-index call, so that an iterator/stream RHS
+            // fails fast with MissingMethodException instead of hanging via materialisation.
+            if (hasRest && !tailRest) {
+                // 1. Emit the IntRange call for the rest slot first, via the helper that
+                //    returns an empty slice for inverted ranges (short RHS) and fails fast
+                //    for non-indexable RHS (iterator/stream/set), per GEP lines 177-186.
+                // Number of fixed slots after the rest = tupleSize - restIndex - 1; their negative
+                // indices span [-k, -1]; the rest slice therefore ends at -(k+1) = -(tupleSize - restIndex).
+                // e.g. def (*f,last): -2; def (l,*m,r): -2; def (a,b,*m,y,z): -3
+                int toIdx = -(tupleSize - restIndex);
+                MethodCallExpression sliceCall = callX(
+                        classX(MultipleAssignmentSupport.class),
+                        "nonTailRestSlice",
+                        args(rhsValueLoader, constX(restIndex, true), constX(toIdx, true)));
+                sliceCall.setImplicitThis(false);
+                sliceCall.visit(acg);
+                assignOneMultiAssignSlot(elements.get(restIndex), defineVariable, operandStack, compileStack, acg);
+
+                // 2. Positive-index fixed slots (before rest), left-to-right.
+                for (int idx = 0; idx < restIndex; idx++) {
+                    MethodCallExpression call = callX(rhsValueLoader, "getAt", constX(idx, true));
+                    call.setImplicitThis(false);
+                    call.visit(acg);
+                    assignOneMultiAssignSlot(elements.get(idx), defineVariable, operandStack, compileStack, acg);
+                }
+
+                // 3. Negative-index fixed slots (after rest), left-to-right.
+                for (int idx = restIndex + 1; idx < tupleSize; idx++) {
+                    int negIdx = -(tupleSize - idx);
+                    MethodCallExpression call = callX(rhsValueLoader, "getAt", constX(negIdx, true));
+                    call.setImplicitThis(false);
+                    call.visit(acg);
+                    assignOneMultiAssignSlot(elements.get(idx), defineVariable, operandStack, compileStack, acg);
+                }
+
+                compileStack.popLHS();
+                if (returnRightValue) rhsValueLoader.visit(acg);
+                compileStack.removeVar(rhsValueId);
+                return;
+            }
+
             MethodCallExpression iterator = callX(rhsValueLoader, "iterator");
             iterator.setImplicitThis(false);
             iterator.visit(acg);
@@ -529,9 +628,17 @@ public class BinaryExpressionHelper {
             mv.visitJumpInsn(IF_ACMPEQ, useGetAt);
 
             boolean first = true;
-            for (Expression e : (TupleExpression) leftExpression) {
-                if (first) {
-                    first = false;
+            for (int idx = 0; idx < tupleSize; idx++) {
+                Expression e = elements.get(idx);
+                if (idx == restIndex) { // tail rest: dispatch Path B (slice) vs Path C (iterator) at runtime
+                    MethodCallExpression restCall = callX(
+                            classX(MultipleAssignmentSupport.class),
+                            "tailRest",
+                            args(rhsValueLoader, constX(idx, true), seq));
+                    restCall.setImplicitThis(false);
+                    restCall.visit(acg);
+                } else if (first) {
+                    first = false; // value already on stack from next() above
                 } else {
                     ternaryX(hasNext, next, nullX()).visit(acg);
                 }
@@ -553,11 +660,19 @@ public class BinaryExpressionHelper {
 
             mv.visitLabel(useGetAt_noPop);
 
-            int i = 0;
-            for (Expression e : (TupleExpression) leftExpression) {
-                MethodCallExpression getAt = callX(rhsValueLoader, "getAt", constX(i++, true));
-                getAt.setImplicitThis(false);
-                getAt.visit(acg);
+            for (int idx = 0; idx < tupleSize; idx++) {
+                Expression e = elements.get(idx);
+                MethodCallExpression call;
+                if (idx == restIndex) { // tail rest: dispatch via helper so empty RHS / non-indexable cases are handled uniformly
+                    call = callX(
+                            classX(MultipleAssignmentSupport.class),
+                            "tailRest",
+                            args(rhsValueLoader, constX(idx, true), seq));
+                } else {
+                    call = callX(rhsValueLoader, "getAt", constX(idx, true));
+                }
+                call.setImplicitThis(false);
+                call.visit(acg);
 
                 if (defineVariable) {
                     Variable v = (Variable) e;
@@ -585,6 +700,20 @@ public class BinaryExpressionHelper {
         compileStack.removeVar(rhsValueId);
     }
 
+    /** GEP-20: assign the single value currently on the operand stack to the given declarator slot. */
+    private void assignOneMultiAssignSlot(final Expression e, final boolean defineVariable,
+                                          final OperandStack operandStack, final CompileStack compileStack,
+                                          final AsmClassGenerator acg) {
+        if (defineVariable) {
+            Variable v = (Variable) e;
+            operandStack.doGroovyCast(v);
+            compileStack.defineVariable(v, true);
+            operandStack.remove(1);
+        } else {
+            e.visit(acg);
+        }
+    }
+
     protected void evaluateCompareExpression(final MethodCaller compareMethod, final BinaryExpression expression) {
         Expression leftExp = expression.getLeftExpression();
         Expression rightExp = expression.getRightExpression();
diff --git a/src/main/java/org/codehaus/groovy/runtime/MultipleAssignmentSupport.java b/src/main/java/org/codehaus/groovy/runtime/MultipleAssignmentSupport.java
new file mode 100644
index 0000000000..78dfbf56f0
--- /dev/null
+++ b/src/main/java/org/codehaus/groovy/runtime/MultipleAssignmentSupport.java
@@ -0,0 +1,167 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.codehaus.groovy.runtime;
+
+import groovy.lang.IntRange;
+
+import java.lang.reflect.Array;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Spliterator;
+import java.util.Spliterators;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
+
+import static org.codehaus.groovy.runtime.DefaultGroovyMethodsSupport.createSimilarList;
+
+/**
+ * Runtime helpers used by the GEP-20 multi-assignment destructuring bytecode.
+ *
+ * <p>These are called from generated bytecode only. User code should not rely on them.</p>
+ */
+public final class MultipleAssignmentSupport {
+
+    private MultipleAssignmentSupport() {}
+
+    private static final Class<?>[] INT_RANGE_PARAM = { IntRange.class };
+
+    /**
+     * Resolve the value for a tail rest binding ({@code *t}) at declarator index
+     * {@code fromIndex}. The GEP-20 dispatch model has three paths:
+     *
+     * <ul>
+     *   <li><b>Path A</b> — {@code Stream<T>}: wrap the already-advanced iterator
+     *       in a fresh sequential Stream and chain {@code onClose} to the
+     *       original. The new Stream reports no spliterator characteristics
+     *       (preserving them would require reading the original spliterator,
+     *       which is mutually exclusive with the iterator path used for head
+     *       extraction).</li>
+     *   <li><b>Path B</b> — receiver with a resolvable {@code getAt(IntRange)}.
+     *       Implemented as fast-path {@code instanceof} branches for
+     *       {@code List}, {@code CharSequence}, and Java arrays (size-aware so
+     *       out-of-bounds {@code fromIndex} returns the canonical empty value
+     *       without calling user code), with an MOP-dispatched fallback for
+     *       any other Path B receiver — {@link java.util.BitSet} (returns
+     *       {@code BitSet}), user custom classes, etc.</li>
+     *   <li><b>Path C</b> — anything else iterable: return the already-advanced
+     *       iterator.</li>
+     * </ul>
+     *
+     * <p>The actual {@code instanceof} checks are ordered for performance
+     * (List/CharSequence/array fast paths first, then Stream, then MOP
+     * fallback, then iterator) but the conceptual dispatch order is A, then
+     * B, then C.</p>
+     *
+     * <p>Primitive streams ({@code IntStream}, {@code LongStream},
+     * {@code DoubleStream}) are not subtypes of {@code Stream<T>} and therefore
+     * fall through to Path B (if they expose {@code getAt(IntRange)}) or
+     * Path C; their elements are boxed by the underlying iterator.</p>
+     *
+     * @param rhs       the original RHS value
+     * @param fromIndex the declarator position of the rest binding
+     * @param seq       the iterator that has already yielded the preceding fixed-slot values
+     */
+    public static Object tailRest(final Object rhs, final int fromIndex, final Iterator<?> seq) {
+        if (rhs instanceof List) {
+            if (fromIndex >= ((List<?>) rhs).size()) return createSimilarList((List<?>) rhs, 0);
+            IntRange range = new IntRange(true, fromIndex, -1);
+            return InvokerHelper.invokeMethod(rhs, "getAt", range);
+        }
+        if (rhs instanceof CharSequence) {
+            if (fromIndex >= ((CharSequence) rhs).length()) return "";
+            IntRange range = new IntRange(true, fromIndex, -1);
+            return InvokerHelper.invokeMethod(rhs, "getAt", range);
+        }
+        if (rhs != null && rhs.getClass().isArray()) {
+            // Match the non-empty branch: ArrayGroovyMethods.getAt(T[], IntRange) returns a
+            // mutable List<T>, so the empty case must also be a mutable List<T> — not an empty
+            // T[] — for typed rest binders like `List<T> *t` to round-trip consistently, and
+            // not Collections.emptyList() so that downstream mutation of an empty rest binding
+            // behaves the same as when it captured one or more elements.
+            if (fromIndex >= Array.getLength(rhs)) return new ArrayList<>();
+            IntRange range = new IntRange(true, fromIndex, -1);
+            return InvokerHelper.invokeMethod(rhs, "getAt", range);
+        }
+        if (rhs instanceof Stream) {
+            Spliterator<?> spliterator = Spliterators.spliteratorUnknownSize(seq, 0);
+            Stream<?> tail = StreamSupport.stream(spliterator, false);
+            return tail.onClose(((Stream<?>) rhs)::close);
+        }
+        // MOP fallback: any other receiver with getAt(IntRange) — BitSet, user classes, etc.
+        // Picked deliberately AFTER the Stream branch so a Stream RHS never materialises via
+        // StreamGroovyMethods.getAt(Stream, IntRange).
+        if (rhs != null && InvokerHelper.getMetaClass(rhs).pickMethod("getAt", INT_RANGE_PARAM) != null) {
+            IntRange range = new IntRange(true, fromIndex, -1);
+            return InvokerHelper.invokeMethod(rhs, "getAt", range);
+        }
+        return seq;
+    }
+
+    /**
+     * Resolve the value for a head or middle rest binding. Requires a sized, indexable RHS
+     * (List, CharSequence, or array — or any other receiver with a non-reversing
+     * {@code getAt(IntRange)}); for other RHS types this routes through the MOP so that:
+     *
+     * <ul>
+     *   <li>Truly non-indexable sources (iterators, sets, custom Iterables without a
+     *       {@code getAt(IntRange)} extension) fail fast with
+     *       {@code MissingMethodException} — preventing silent materialisation of
+     *       unbounded sources.</li>
+     *   <li>{@code Stream} RHS resolves {@code StreamGroovyMethods.getAt(Stream, IntRange)},
+     *       which rejects the destructuring slice's reverse range with
+     *       {@code IllegalArgumentException("reverse range")} — also fail-fast, also
+     *       no materialisation. (Head/middle rest from {@code Stream} is rejected at
+     *       compile time under {@code @CompileStatic}; the IAE only surfaces in
+     *       dynamic mode.)</li>
+     * </ul>
+     *
+     * <p>When the RHS is shorter than the sum of fixed slots around the rest, the computed range
+     * is inverted. DGM's {@code getAt(IntRange)} would reverse an inverted range; for destructuring
+     * we want an empty slice instead, so this helper short-circuits that case for the sized fast
+     * paths (List/CharSequence/array). Other indexable receivers are responsible for their own
+     * bounds handling.</p>
+     *
+     * @param rhs       the original RHS value
+     * @param fromIndex positive index where the slice begins
+     * @param toIndex   negative index where the slice ends (inclusive)
+     */
+    public static Object nonTailRestSlice(final Object rhs, final int fromIndex, final int toIndex) {
+        int size = -1;
+        if (rhs instanceof List) size = ((List<?>) rhs).size();
+        else if (rhs instanceof CharSequence) size = ((CharSequence) rhs).length();
+        else if (rhs != null && rhs.getClass().isArray()) size = Array.getLength(rhs);
+
+        if (size >= 0) {
+            int effectiveTo = toIndex < 0 ? size + toIndex : toIndex;
+            if (fromIndex > effectiveTo || fromIndex >= size || effectiveTo < 0) {
+                // Empty-slice returns a mutable list: the non-empty path delegates to
+                // getAt(IntRange) which returns a fresh mutable List<T> (for both List
+                // and array RHS), so the empty path must agree — both for the array
+                // List<T>-vs-T[] alignment and for downstream mutation consistency.
+                if (rhs instanceof CharSequence) return "";
+                if (rhs instanceof List) return createSimilarList((List<?>) rhs, 0);
+                return new ArrayList<>();
+            }
+        }
+
+        IntRange range = new IntRange(true, fromIndex, toIndex);
+        return InvokerHelper.invokeMethod(rhs, "getAt", range);
+    }
+}
diff --git a/src/main/java/org/codehaus/groovy/transform/sc/transformers/BinaryExpressionTransformer.java b/src/main/java/org/codehaus/groovy/transform/sc/transformers/BinaryExpressionTransformer.java
index 6928ceefc5..41548ccb89 100644
--- a/src/main/java/org/codehaus/groovy/transform/sc/transformers/BinaryExpressionTransformer.java
+++ b/src/main/java/org/codehaus/groovy/transform/sc/transformers/BinaryExpressionTransformer.java
@@ -21,6 +21,7 @@ package org.codehaus.groovy.transform.sc.transformers;
 import org.codehaus.groovy.ast.ClassHelper;
 import org.codehaus.groovy.ast.ClassNode;
 import org.codehaus.groovy.ast.MethodNode;
+import org.codehaus.groovy.ast.MultipleAssignmentMetadata;
 import org.codehaus.groovy.ast.Parameter;
 import org.codehaus.groovy.ast.expr.ArrayExpression;
 import org.codehaus.groovy.ast.expr.BinaryExpression;
@@ -111,7 +112,12 @@ public class BinaryExpressionTransformer {
             if (expr != null) return expr;
             if (leftExpression instanceof TupleExpression
                     && rightExpression instanceof ListExpression) {
-                return transformMultipleAssignment(bin);
+                // GEP-20: rest and map-style binders can't be flattened into per-position
+                // single assignments — leave them for the regular codegen path
+                // (BinaryExpressionHelper.evaluateEqual) which knows how to slice / lookup.
+                if (!hasGep20Binder((TupleExpression) leftExpression)) {
+                    return transformMultipleAssignment(bin);
+                }
             }
             break;
           case Types.KEYWORD_IN:
@@ -343,6 +349,15 @@ public class BinaryExpressionTransformer {
         return null;
     }
 
+    /** GEP-20: detect a rest binder ({@code *t}) or map-style binder ({@code key: x}) on the LHS. */
+    private static boolean hasGep20Binder(final TupleExpression tuple) {
+        for (Expression e : tuple.getExpressions()) {
+            if (Boolean.TRUE.equals(e.getNodeMetaData(MultipleAssignmentMetadata.REST_BINDING))) return true;
+            if (e.getNodeMetaData(MultipleAssignmentMetadata.MAP_KEY) != null) return true;
+        }
+        return false;
+    }
+
     private Expression transformMultipleAssignment(final BinaryExpression bin) {
         ListOfExpressionsExpression list = new ListOfExpressionsExpression();
         List<Expression> leftExpressions = ((TupleExpression) bin.getLeftExpression()).getExpressions();
diff --git a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java
index d36a1fba5a..13d4377fbc 100644
--- a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java
+++ b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java
@@ -46,6 +46,7 @@ import org.codehaus.groovy.ast.GenericsType.GenericsTypeName;
 import org.codehaus.groovy.ast.GroovyCodeVisitor;
 import org.codehaus.groovy.ast.InnerClassNode;
 import org.codehaus.groovy.ast.MethodNode;
+import org.codehaus.groovy.ast.MultipleAssignmentMetadata;
 import org.codehaus.groovy.ast.Parameter;
 import org.codehaus.groovy.ast.PropertyNode;
 import org.codehaus.groovy.ast.Variable;
@@ -1317,7 +1318,28 @@ out:    if ((samParameterTypes.length == 1 && isOrImplements(samParameterTypes[0
         target.setGenericsTypes(genericsTypes);
     }
 
-    private boolean typeCheckMultipleAssignmentAndContinue(final Expression leftExpression, Expression rightExpression) {
+    private boolean typeCheckMultipleAssignmentAndContinue(final Expression leftExpression, final Expression rightExpression) {
+        // GEP-20 dispatch by LHS shape. Detect rest binders and map-style binders up front;
+        // the existing literal-RHS pathway lives inside typeCheckMultipleAssignmentPositional.
+        // The rest- and map-style helpers are currently scaffolding that delegates back to the
+        // positional path; subsequent slices fill them in (rest-aware size handling, container-
+        // type inference, property-typed map-style binders, declared-type RHS support).
+        TupleExpression tuple = (TupleExpression) leftExpression;
+        boolean hasRest = false, hasMapKey = false;
+        for (Expression e : tuple.getExpressions()) {
+            if (Boolean.TRUE.equals(e.getNodeMetaData(MultipleAssignmentMetadata.REST_BINDING))) hasRest = true;
+            if (e.getNodeMetaData(MultipleAssignmentMetadata.MAP_KEY) != null) hasMapKey = true;
+        }
+        if (hasMapKey) {
+            return typeCheckMultipleAssignmentMapStyle(leftExpression, rightExpression);
+        }
+        if (hasRest) {
+            return typeCheckMultipleAssignmentRest(leftExpression, rightExpression);
+        }
+        return typeCheckMultipleAssignmentPositional(leftExpression, rightExpression);
+    }
+
+    private boolean typeCheckMultipleAssignmentPositional(final Expression leftExpression, Expression rightExpression) {
         if (rightExpression instanceof VariableExpression || rightExpression instanceof PropertyExpression || rightExpression instanceof MethodCall) {
             ClassNode inferredType = Optional.ofNullable(getType(rightExpression)).orElseGet(rightExpression::getType);
             GenericsType[] genericsTypes = inferredType.getGenericsTypes();
@@ -1331,6 +1353,8 @@ out:    if ((samParameterTypes.length == 1 && isOrImplements(samParameterTypes[0
             }
             if (!listExpression.getExpressions().isEmpty()) {
                 rightExpression = listExpression;
+            } else {
+                rightExpression = synthesizeIndexableRhs(leftExpression, rightExpression, inferredType);
             }
         } else if (rightExpression instanceof RangeExpression) {
             ListExpression listExpression = new ListExpression();
@@ -1347,6 +1371,14 @@ out:    if ((samParameterTypes.length == 1 && isOrImplements(samParameterTypes[0
             }
         }
 
+        // Final indexable-RHS fallback: anything still not a ListExpression (e.g. String /
+        // GString constants, closure-call results that didn't match Tuple1..16) — try the
+        // indexable synthesis based on the RHS's static type.
+        if (!(rightExpression instanceof ListExpression)) {
+            ClassNode rhsType = Optional.ofNullable(getType(rightExpression)).orElseGet(rightExpression::getType);
+            rightExpression = synthesizeIndexableRhs(leftExpression, rightExpression, rhsType);
+        }
+
         if (!(rightExpression instanceof ListExpression values)) {
             addStaticTypeError("Multiple assignments without list or tuple on the right-hand side are unsupported in static type checking mode", rightExpression);
             return false;
@@ -1374,6 +1406,297 @@ out:    if ((samParameterTypes.length == 1 && isOrImplements(samParameterTypes[0
         return true;
     }
 
+    /**
+     * GEP-20 rest binding under static checking. Handles both list-literal RHS and
+     * declared-type RHS (List, array, String, Range, Set, Iterator, Stream — anything with a
+     * typed {@code getAt(int)} or {@code iterator()} inferable element type). The rest
+     * variable's inferred type tracks the runtime dispatch in
+     * {@code MultipleAssignmentSupport.tailRest}: {@code List<T>} for Path B receivers
+     * (those that support {@code getAt(IntRange)}); {@code Stream<T>} for {@code Stream}
+     * RHS (Path A, tail rest only); {@code Iterator<T>} for any other Path C receiver
+     * (tail rest only). Head and middle rest reject Path C and Path A receivers as a
+     * static type error.
+     */
+    private boolean typeCheckMultipleAssignmentRest(final Expression leftExpression, final Expression rightExpression) {
+        if (rightExpression instanceof ListExpression values) {
+            return typeCheckRestAgainstListLiteral((TupleExpression) leftExpression, values, rightExpression);
+        }
+        // Any RHS with a statically-resolvable getAt(int) (or known shape via declared type)
+        // routes through the declared-type path; this covers variables, properties, method
+        // calls, constants (e.g. String literals), closure invocations, etc.
+        return typeCheckRestAgainstDeclaredType((TupleExpression) leftExpression, rightExpression);
+    }
+
+    private boolean typeCheckRestAgainstListLiteral(final TupleExpression tuple, final ListExpression values, final Expression rightExpression) {
+        List<Expression> tupleExpressions = tuple.getExpressions();
+        List<Expression> valueExpressions = values.getExpressions();
+        int tupleSize = tupleExpressions.size();
+        int valueSize = valueExpressions.size();
+
+        int restIndex = restIndexOf(tuple);
+        int fixedAfter = tupleSize - restIndex - 1;
+
+        // Per GEP-20: lower sizes are forgiving (getAt(int) returns null for OOB,
+        // getAt(IntRange) returns empty for inverted ranges). Skip the strict size check.
+
+        // Fixed slots before the rest binding: pairwise from index 0.
+        for (int i = 0; i < restIndex; i++) {
+            if (i >= valueSize) break;
+            ClassNode valueType = getType(valueExpressions.get(i));
+            ClassNode targetType = getType(tupleExpressions.get(i));
+            if (!isAssignableTo(valueType, targetType)) {
+                addStaticTypeError("Cannot assign value of type " + prettyPrintType(valueType) + " to variable of type " + prettyPrintType(targetType), rightExpression);
+                return false;
+            }
+            storeType(tupleExpressions.get(i), valueType);
+        }
+
+        // Fixed slots after the rest binding: anchored to the right of the value list.
+        for (int j = 0; j < fixedAfter; j++) {
+            int lhsIdx = restIndex + 1 + j;
+            int rhsIdx = valueSize - fixedAfter + j;
+            if (rhsIdx < 0 || rhsIdx >= valueSize) continue;
+            ClassNode valueType = getType(valueExpressions.get(rhsIdx));
+            ClassNode targetType = getType(tupleExpressions.get(lhsIdx));
+            if (!isAssignableTo(valueType, targetType)) {
+                addStaticTypeError("Cannot assign value of type " + prettyPrintType(valueType) + " to variable of type " + prettyPrintType(targetType), rightExpression);
+                return false;
+            }
+            storeType(tupleExpressions.get(lhsIdx), valueType);
+        }
+
+        // Rest position: container type derived from the LUB of absorbed value types.
+        int restFromIdx = Math.min(restIndex, valueSize);
+        int restToIdx = Math.max(restFromIdx, valueSize - fixedAfter);
+        Expression restExpr = tupleExpressions.get(restIndex);
+        ClassNode elementType;
+        if (restToIdx > restFromIdx) {
+            elementType = getType(valueExpressions.get(restFromIdx));
+            for (int k = restFromIdx + 1; k < restToIdx; k++) {
+                elementType = lowestUpperBound(elementType, getType(valueExpressions.get(k)));
+            }
+        } else {
+            // Rest absorbs zero values (e.g. `def (Integer a, List<Integer> *t, Integer b) = [1, 2]`).
+            // Default to OBJECT_TYPE, but if the rest binder is typed with a single generic
+            // argument, use that as the element type so `List<Integer> *t` doesn't get rejected
+            // as `List<Object>` not assignable to `List<Integer>`.
+            elementType = OBJECT_TYPE;
+            if (restExpr instanceof Variable v) {
+                ClassNode declared = v.getOriginType();
+                if (declared != null && !ClassHelper.isDynamicTyped(declared) && !declared.equals(OBJECT_TYPE)) {
+                    GenericsType[] gts = declared.getGenericsTypes();
+                    if (gts != null && gts.length == 1) {
+                        elementType = getCombinedBoundType(gts[0]);
+                    }
+                }
+            }
+        }
+        // Generics can't take primitives; box (int → Integer) so List<Integer> matches.
+        elementType = ClassHelper.getWrapper(elementType);
+        ClassNode restType = GenericsUtils.makeClassSafeWithGenerics(LIST_TYPE, new GenericsType(elementType));
+        if (!checkRestAssignability(restExpr, restType, rightExpression)) return false;
+        storeType(restExpr, restType);
+
+        return true;
+    }
+
+    private boolean typeCheckRestAgainstDeclaredType(final TupleExpression tuple, final Expression rightExpression) {
+        ClassNode rhsType = Optional.ofNullable(getType(rightExpression)).orElseGet(rightExpression::getType);
+        ClassNode elementType = inferComponentType(rhsType, int_TYPE);
+        if (elementType == null) {
+            // Non-indexable RHS — let positional surface the existing rejection.
+            return typeCheckMultipleAssignmentPositional(tuple, rightExpression);
+        }
+
+        List<Expression> tupleExpressions = tuple.getExpressions();
+        int tupleSize = tupleExpressions.size();
+        int restIndex = restIndexOf(tuple);
+        boolean tailRest = restIndex == tupleSize - 1;
+        // Path B is now defined by capability, not class membership: anything with a resolvable
+        // getAt(IntRange) participates. Probe and reuse the inferred slice type as the rest's
+        // declared type so it matches the actual runtime return (BitSet → BitSet, String →
+        // String, MyContainer → MyContainer, List<T>/T[] → List<T>, ...).
+        ClassNode pathASliceType = inferRangeSliceType(rhsType);
+        boolean pathA = pathASliceType != null;
+
+        if (!tailRest && !pathA) {
+            addStaticTypeError("Head or middle rest binding requires an indexable right-hand side; "
+                    + prettyPrintType(rhsType) + " does not support getAt(IntRange)", rightExpression);
+            return false;
+        }
+
+        // Non-rest positions all share the element type derived from getAt(int).
+        for (int i = 0; i < tupleSize; i++) {
+            if (i == restIndex) continue;
+            ClassNode targetType = getType(tupleExpressions.get(i));
+            if (!isAssignableTo(elementType, targetType)) {
+                addStaticTypeError("Cannot assign value of type " + prettyPrintType(elementType) + " to variable of type " + prettyPrintType(targetType), rightExpression);
+                return false;
+            }
+            storeType(tupleExpressions.get(i), elementType);
+        }
+
+        // Rest type tracks runtime dispatch:
+        //   Path B     → the actual getAt(IntRange) return type (probed above).
+        //   Stream RHS → Stream<T> (Path A, tail rest only — laziness + onClose preserved).
+        //   Path C     → Iterator<T> (tail rest only).
+        // Box primitives so generic containers match (e.g. List<Integer>, not List<int>).
+        ClassNode restType;
+        if (pathA) {
+            restType = pathASliceType;
+        } else if (GeneralUtils.isOrImplements(rhsType, STREAM_TYPE)) {
+            restType = GenericsUtils.makeClassSafeWithGenerics(STREAM_TYPE,
+                    new GenericsType(ClassHelper.getWrapper(elementType)));
+        } else {
+            restType = GenericsUtils.makeClassSafeWithGenerics(Iterator_TYPE,
+                    new GenericsType(ClassHelper.getWrapper(elementType)));
+        }
+        Expression restExpr = tupleExpressions.get(restIndex);
+        if (!checkRestAssignability(restExpr, restType, rightExpression)) return false;
+        storeType(restExpr, restType);
+
+        return true;
+    }
+
+    /**
+     * GEP-20 typed rest support: when the user pins a type on a rest binder
+     * ({@code def (h, List<Integer> *t) = list}), verify that the runtime container type
+     * (derived from RHS shape and Path B/C selection) is assignable to it. Untyped rest
+     * binders ({@code originType == dynamicType}) skip the check.
+     */
+    private boolean checkRestAssignability(final Expression restExpr, final ClassNode inferredRestType, final Expression rightExpression) {
+        if (!(restExpr instanceof Variable v)) return true;
+        ClassNode declared = v.getOriginType();
+        if (declared == null || ClassHelper.isDynamicTyped(declared) || declared.equals(OBJECT_TYPE)) return true;
+        if (!isAssignableTo(inferredRestType, declared)) {
+            addStaticTypeError("Cannot assign rest value of type " + prettyPrintType(inferredRestType)
+                    + " to variable of type " + prettyPrintType(declared), rightExpression);
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Build a synthesised ListExpression of {@code rhs.getAt(i)} accesses, one per LHS slot,
+     * tagged with the inferred element type. Used by {@link #typeCheckMultipleAssignmentPositional}
+     * when the RHS isn't a literal list/range/Tuple-typed expression. Returns the original
+     * RHS unchanged if its static type doesn't support {@code getAt(int)}.
+     */
+    private Expression synthesizeIndexableRhs(final Expression leftExpression, final Expression rightExpression, final ClassNode rhsType) {
+        ClassNode elementType = inferComponentType(rhsType, int_TYPE);
+        if (elementType == null) return rightExpression;
+        TupleExpression tuple = (TupleExpression) leftExpression;
+        ListExpression synth = new ListExpression();
+        synth.setSourcePosition(rightExpression);
+        for (int i = 0; i < tuple.getExpressions().size(); i++) {
+            Expression idxExpr = indexX(rightExpression, constX(i, true));
+            idxExpr.putNodeMetaData(INFERRED_TYPE, elementType);
+            synth.addExpression(idxExpr);
+        }
+        return synth;
+    }
+
+    private static int restIndexOf(final TupleExpression tuple) {
+        List<Expression> tupleExpressions = tuple.getExpressions();
+        for (int i = 0; i < tupleExpressions.size(); i++) {
+            if (Boolean.TRUE.equals(tupleExpressions.get(i).getNodeMetaData(MultipleAssignmentMetadata.REST_BINDING))) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+    /**
+     * GEP-20 map-style destructuring under static checking. Handles both {@code MapExpression}
+     * literal RHS and declared-type RHS:
+     * <ul>
+     *   <li>{@code Map<K, V>} (and subtypes) — binder type derived from the map's V generic.</li>
+     *   <li>Bean / class with declared properties — resolved via STC's existing property lookup.</li>
+     *   <li>{@code GroovyObject} / dynamic — STC reports the standard "No such property" error
+     *       for keys that aren't statically declared (matches GEP failure-modes table for the
+     *       static case).</li>
+     * </ul>
+     */
+    private boolean typeCheckMultipleAssignmentMapStyle(final Expression leftExpression, final Expression rightExpression) {
+        if (rightExpression instanceof MapExpression mapExpr) {
+            return typeCheckMapStyleAgainstMapLiteral((TupleExpression) leftExpression, mapExpr, rightExpression);
+        }
+        // Any RHS with a statically-resolvable type (variable, parameter, method call,
+        // closure invocation, constant) routes through the declared-type path so STC's
+        // property-resolution machinery can resolve the map keys against it.
+        return typeCheckMapStyleAgainstDeclaredType((TupleExpression) leftExpression, rightExpression);
+    }
+
+    private boolean typeCheckMapStyleAgainstMapLiteral(final TupleExpression tuple, final MapExpression mapExpr, final Expression rightExpression) {
+        // Build key→value lookup from constant-key entries; non-constant keys are skipped
+        // so the binder falls through to the missing-key path (Object).
+        Map<String, Expression> rhsByKey = new HashMap<>();
+        for (MapEntryExpression entry : mapExpr.getMapEntryExpressions()) {
+            if (entry.getKeyExpression() instanceof ConstantExpression key) {
+                rhsByKey.put(String.valueOf(key.getValue()), entry.getValueExpression());
+            }
+        }
+        for (Expression e : tuple.getExpressions()) {
+            String mapKey = (String) e.getNodeMetaData(MultipleAssignmentMetadata.MAP_KEY);
+            Expression valueExpr = rhsByKey.get(mapKey);
+            ClassNode targetType = getType(e);
+            if (valueExpr != null) {
+                ClassNode valueType = getType(valueExpr);
+                if (!isAssignableTo(valueType, targetType)) {
+                    addStaticTypeError("Cannot assign value of type " + prettyPrintType(valueType) + " to variable of type " + prettyPrintType(targetType), rightExpression);
+                    return false;
+                }
+                storeType(e, valueType);
+            } else {
+                // Missing key — runtime semantics give null. Store Object as inferred type so
+                // any read uses the broadest type; declared types on binders are preserved.
+                storeType(e, OBJECT_TYPE);
+            }
+        }
+        return true;
+    }
+
+    private boolean typeCheckMapStyleAgainstDeclaredType(final TupleExpression tuple, final Expression rightExpression) {
+        ClassNode rhsType = Optional.ofNullable(getType(rightExpression)).orElseGet(rightExpression::getType);
+
+        // Map<K, V> RHS: every binder resolves to V. STC won't complain about static "missing keys"
+        // because Map keys aren't statically known.
+        if (isOrImplements(rhsType, MAP_TYPE)) {
+            ClassNode parameterized = rhsType.equals(MAP_TYPE) ? rhsType : GenericsUtils.parameterizeType(rhsType, MAP_TYPE);
+            GenericsType[] gts = parameterized.getGenericsTypes();
+            ClassNode valueType = (gts != null && gts.length == 2) ? getCombinedBoundType(gts[1]) : OBJECT_TYPE;
+            for (Expression e : tuple.getExpressions()) {
+                ClassNode targetType = getType(e);
+                if (!isAssignableTo(valueType, targetType)) {
+                    addStaticTypeError("Cannot assign value of type " + prettyPrintType(valueType) + " to variable of type " + prettyPrintType(targetType), rightExpression);
+                    return false;
+                }
+                storeType(e, valueType);
+            }
+            return true;
+        }
+
+        // Bean / GroovyObject / arbitrary class RHS: resolve each MAP_KEY as a property on the
+        // RHS's static type. STC's normal property-resolution path handles bean accessors and
+        // surfaces "No such property" errors for keys that aren't statically declared.
+        boolean ok = true;
+        for (Expression e : tuple.getExpressions()) {
+            String mapKey = (String) e.getNodeMetaData(MultipleAssignmentMetadata.MAP_KEY);
+            PropertyExpression pexp = new PropertyExpression(rightExpression, mapKey);
+            pexp.setSourcePosition(e);
+            visitPropertyExpression(pexp); // resolves type or records "No such property"
+            ClassNode resolvedType = getType(pexp);
+            ClassNode targetType = getType(e);
+            if (resolvedType != null && !isAssignableTo(resolvedType, targetType)) {
+                addStaticTypeError("Cannot assign value of type " + prettyPrintType(resolvedType) + " to variable of type " + prettyPrintType(targetType), rightExpression);
+                ok = false;
+                continue;
+            }
+            storeType(e, resolvedType != null ? resolvedType : OBJECT_TYPE);
+        }
+        return ok;
+    }
+
     private ClassNode adjustTypeForSpreading(final ClassNode rightExpressionType, final Expression leftExpression) {
         // given "list*.foo = 100" or "map*.value = 100", then the assignment must be checked against [100], not 100
         if (leftExpression instanceof PropertyExpression && ((PropertyExpression) leftExpression).isSpreadSafe()) {
@@ -4943,6 +5266,49 @@ trying: for (ClassNode[] signature : signatures) {
         return Number_TYPE;
     }
 
+    /**
+     * GEP-20: probe the static return type of {@code rhs.getAt(IntRange)} on
+     * {@code receiverType}, so the rest binder can be typed against the actual
+     * slice type rather than a hard-coded {@code List<T>}. Returns the resolved
+     * slice type (e.g. {@code String} for a String receiver, {@code BitSet} for
+     * a BitSet receiver, the user-declared return type for custom classes), or
+     * {@code null} if the receiver has no resolvable {@code getAt(IntRange)}.
+     *
+     * <p>Mirrors {@link #inferComponentType} but for the IntRange overload.
+     * Method resolution runs under a swallowed error collector so a missing
+     * overload doesn't surface as a static-type error.</p>
+     */
+    protected ClassNode inferRangeSliceType(final ClassNode receiverType) {
+        if (receiverType == null || receiverType.equals(OBJECT_TYPE)) return null;
+        // Stream has a getAt(IntRange) extension that materialises eagerly into a List —
+        // antithetical to GEP-20's "no silent materialisation of unbounded sources" rule.
+        // Stream RHS routes through Path A (lazy iterator wrap) instead, so exclude it
+        // from Path B here regardless of getAt(IntRange) availability.
+        if (GeneralUtils.isOrImplements(receiverType, STREAM_TYPE)) return null;
+        if (receiverType.isArray()) {
+            // Arrays don't carry a getAt(IntRange) method per se — it's a DGM extension
+            // (ArrayGroovyMethods.getAt(T[], IntRange)) returning List<T>. Mirror that
+            // here so we don't depend on STC catching the extension on array types.
+            return GenericsUtils.makeClassSafeWithGenerics(LIST_TYPE,
+                    new GenericsType(ClassHelper.getWrapper(receiverType.getComponentType())));
+        }
+        MethodCallExpression mce = callX(varX("#", receiverType), "getAt",
+                varX("range", ClassHelper.make(IntRange.class)));
+        mce.setImplicitThis(false);
+        typeCheckingContext.pushErrorCollector();
+        try {
+            visitMethodCallExpression(mce);
+        } finally {
+            typeCheckingContext.popErrorCollector();
+        }
+        MethodNode target = mce.getNodeMetaData(DIRECT_METHOD_CALL_TARGET);
+        // No matching overload, or resolved to a too-generic catch-all on Object — punt.
+        if (target == null || target.getDeclaringClass().equals(OBJECT_TYPE)) return null;
+        ClassNode sliceType = getType(mce);
+        if (sliceType == null || sliceType.equals(OBJECT_TYPE)) return null;
+        return sliceType;
+    }
+
     protected ClassNode inferComponentType(final ClassNode receiverType, final ClassNode subscriptType) {
         ClassNode componentType = null;
         if (receiverType.isArray()) { // GROOVY-11335
diff --git a/src/test/groovy/gls/statements/MultipleAssignmentDeclarationTest.groovy b/src/test/groovy/gls/statements/MultipleAssignmentDeclarationTest.groovy
index 8f8af583ba..5391ea5a8b 100644
--- a/src/test/groovy/gls/statements/MultipleAssignmentDeclarationTest.groovy
+++ b/src/test/groovy/gls/statements/MultipleAssignmentDeclarationTest.groovy
@@ -258,4 +258,1764 @@ final class MultipleAssignmentDeclarationTest {
             assert baz == 'baz'
         '''
     }
+
+    // GEP-20 tail-rest tests
+
+    @Test
+    void testTailRestFromList() {
+        assertScript '''
+            def (h, *t) = [1, 2, 3, 4]
+            assert h == 1
+            assert t == [2, 3, 4]
+        '''
+    }
+
+    @Test
+    void testTailRestWithMultipleHeads() {
+        assertScript '''
+            def (a, b, *rest) = [10, 20, 30, 40, 50]
+            assert a == 10
+            assert b == 20
+            assert rest == [30, 40, 50]
+        '''
+    }
+
+    @Test
+    void testTailRestWithTypedHead() {
+        assertScript '''
+            def (int h, *t) = [1, 2, 3]
+            assert h == 1
+            assert h instanceof Integer
+            assert t == [2, 3]
+        '''
+    }
+
+    @Test
+    void testTailRestFromEmptyList() {
+        assertScript '''
+            def (h, *t) = []
+            assert h == null
+            assert t == []
+        '''
+    }
+
+    @Test
+    void testTailRestFromString() {
+        assertScript '''
+            def (c, *cs) = "hello"
+            assert c == 'h'
+            assert cs == "ello"
+        '''
+    }
+
+    @Test
+    void testTailRestFromIterator() {
+        assertScript '''
+            def (h, t) = [1, 2, 3].iterator()   // baseline: same iterator semantics as existing
+            assert h == 1
+            assert t == 2
+
+            // tail-rest binds the advanced iterator (Path C)
+            def it = [10, 20, 30].iterator()
+            def (head, *tail) = it
+            assert head == 10
+            assert tail instanceof Iterator
+            assert tail.next() == 20
+            assert tail.next() == 30
+            assert !tail.hasNext()
+        '''
+    }
+
+    @Test
+    void testTailRestFromSet() {
+        assertScript '''
+            def s = new LinkedHashSet<>([7, 8, 9])
+            def (sh, *st) = s
+            assert sh == 7
+            def remaining = []
+            while (st.hasNext()) remaining << st.next()
+            assert remaining == [8, 9]
+        '''
+    }
+
+    @Test
+    void testTailRestFromStream() {
+        // Path A: Stream RHS preserves Stream-ness in the rest binder.
+        assertScript '''import java.util.stream.Stream
+            def (first, *rest) = Stream.of('a', 'b', 'c')
+            assert first == 'a'
+            assert rest instanceof Stream
+            assert rest.toList() == ['b', 'c']
+        '''
+    }
+
+    @Test
+    void testTailRestFromStream_pipelineContinues() {
+        // The whole point of Path A: downstream operations on the rest stream still work.
+        assertScript '''import java.util.stream.Stream
+            def (first, *rest) = Stream.of(1, 2, 3, 4, 5)
+            assert first == 1
+            assert rest.filter { it % 2 == 0 }.map { it * 10 }.toList() == [20, 40]
+        '''
+    }
+
+    @Test
+    void testTailRestFromStream_singleElement() {
+        // Stream of one element: head consumes everything, tail is an empty Stream.
+        assertScript '''import java.util.stream.Stream
+            def (only, *rest) = Stream.of('only')
+            assert only == 'only'
+            assert rest instanceof Stream
+            assert rest.toList() == []
+        '''
+    }
+
+    @Test
+    void testTailRestFromStream_onCloseChained() {
+        // Closing the rest Stream must close the original — for sources like Files.lines(p)
+        // that hold OS resources, the rest binder is the canonical owner.
+        assertScript '''import java.util.stream.Stream
+            def closed = false
+            def src = Stream.of('a', 'b', 'c').onClose { closed = true }
+            def (h, *t) = src
+            assert h == 'a'
+            t.close()
+            assert closed
+        '''
+    }
+
+    @Test
+    void testTailRestFromIntStream_fallsThroughToIterator() {
+        // IntStream is not a subtype of Stream<T>, so it falls through to Path C
+        // (Iterator). Elements are boxed by PrimitiveIterator.OfInt::next.
+        assertScript '''import java.util.stream.IntStream
+            def (first, *rest) = IntStream.of(1, 2, 3)
+            assert first == 1
+            assert rest instanceof Iterator
+            assert rest.next() == 2
+            assert rest.next() == 3
+        '''
+    }
+
+    @Test
+    void testTailRestFromIntStream_boxedFirst_givesStream() {
+        // The user-facing workaround for Path A on primitive streams: .boxed() first.
+        assertScript '''import java.util.stream.IntStream
+            def (first, *rest) = IntStream.of(1, 2, 3).boxed()
+            assert first == 1
+            assert rest instanceof java.util.stream.Stream
+            assert rest.toList() == [2, 3]
+        '''
+    }
+
+    @Test
+    void testTailRestFromUnboundedIterator() {
+        assertScript '''
+            // ensure lazy Path C: no materialisation
+            def source = new Iterator<Integer>() {
+                int i = 0
+                boolean hasNext() { true }
+                Integer next() { i++ }
+            }
+            def (h, *t) = source
+            assert h == 0
+            assert t.next() == 1
+            assert t.next() == 2
+            assert t.next() == 3
+        '''
+    }
+
+    @Test
+    void testTailRestSingleRestBinder() {
+        assertScript '''
+            def (*t) = [1, 2, 3]
+            assert t == [1, 2, 3]
+        '''
+    }
+
+    @Test
+    void testTailRestRejectsMultipleRest() {
+        def e = shouldFail '''
+            def (a, *b, *c) = [1, 2, 3]
+        '''
+        assert e.message.contains('Only one rest binding')
+    }
+
+    // GEP-20 typed rest: the declared container type is honoured by static checking
+    // (path B vs path C compatibility) and by runtime coercion.
+
+    @Test
+    void testTypedRest_listContainerType_listLiteral() {
+        assertScript '''
+            def (h, List<Integer> *t) = [1, 2, 3, 4]
+            assert h == 1
+            assert t == [2, 3, 4]
+            assert t instanceof List
+        '''
+    }
+
+    @Test
+    void testTypedRest_listMiddleRest() {
+        assertScript '''
+            def (l, List<Integer> *m, r) = [1, 2, 3, 4, 5]
+            assert l == 1
+            assert m == [2, 3, 4]
+            assert r == 5
+        '''
+    }
+
+    @Test
+    void testTypedRest_iteratorContainer_setRhs() {
+        // Path C: declared Iterator<Integer> matches the runtime iterator returned by tailRest.
+        assertScript '''
+            Set<Integer> s = new LinkedHashSet<>([7, 8, 9])
+            def (h, Iterator<Integer> *t) = s
+            assert h == 7
+            assert t.next() == 8
+            assert t.next() == 9
+        '''
+    }
+
+    @Test
+    void testTypedRest_elementMismatchRuntimeFailure() {
+        // Dynamic mode: declared Integer (not a container) for *t — runtime coercion fails
+        // when assigning the slice/iterator to an Integer variable.
+        shouldFail org.codehaus.groovy.runtime.typehandling.GroovyCastException, '''
+            def (h, Integer *t) = [1, 2, 3]
+        '''
+    }
+
+    @Test
+    void testTypedRest_CompileStatic_listLiteral() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def (h, List<Integer> *t) = [1, 2, 3, 4]
+                assert h == 1
+                assert t == [2, 3, 4]
+                int total = 0
+                for (Integer i : t) total += i
+                assert total == 9
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testTypedRest_CompileStatic_listVariableRhs() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go(List<Integer> src) {
+                def (int h, List<Integer> *t) = src
+                assert h == 10
+                assert t == [20, 30]
+            }
+            go([10, 20, 30])
+        '''
+    }
+
+    @Test
+    void testTypedRest_CompileStatic_iteratorContainer_pathC() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go(Set<Integer> src) {
+                def (h, Iterator<Integer> *t) = src
+                assert h == 5
+                assert t.next() == 6
+                assert t.next() == 7
+            }
+            go(new LinkedHashSet<>([5, 6, 7]))
+        '''
+    }
+
+    @Test
+    void testTypedRest_CompileStatic_containerMismatch_listVsIterator() {
+        // List RHS gives Path B (List<T>); user declared Iterator<Integer> for the rest.
+        // STC catches the mismatch at compile time.
+        def e = shouldFail '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go(List<Integer> src) {
+                def (h, Iterator<Integer> *t) = src
+            }
+            go([1, 2, 3])
+        '''
+        assert e.message.contains('Cannot assign rest value')
+    }
+
+    @Test
+    void testTypedRest_CompileStatic_containerMismatch_iteratorVsList() {
+        // Set RHS gives Path C (Iterator<T>); user declared List<Integer> for the rest.
+        def e = shouldFail '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go(Set<Integer> src) {
+                def (h, List<Integer> *t) = src
+            }
+            go([1, 2, 3] as Set)
+        '''
+        assert e.message.contains('Cannot assign rest value')
+    }
+
+    @Test
+    void testTypedRest_CompileStatic_streamContainer_pathA() {
+        // Path A: Stream RHS, declared Stream<Integer> rest — STC accepts and runtime preserves.
+        assertScript '''import groovy.transform.CompileStatic
+            import java.util.stream.Stream
+            @CompileStatic
+            def go(Stream<Integer> src) {
+                def (h, Stream<Integer> *t) = src
+                assert h == 10
+                assert t.toList() == [20, 30]
+            }
+            go(Stream.of(10, 20, 30))
+        '''
+    }
+
+    @Test
+    void testTypedRest_CompileStatic_streamRhsUntyped() {
+        // Untyped rest from Stream RHS infers Stream<T> at compile time.
+        assertScript '''import groovy.transform.CompileStatic
+            import java.util.stream.Stream
+            @CompileStatic
+            def go(Stream<String> src) {
+                def (h, *t) = src
+                assert h == 'a'
+                // Static check: t typed as Stream<String> — chained ops must compile.
+                assert t.map { it.toUpperCase() }.toList() == ['B', 'C']
+            }
+            go(Stream.of('a', 'b', 'c'))
+        '''
+    }
+
+    @Test
+    void testTypedRest_CompileStatic_containerMismatch_streamVsIterator() {
+        // Stream RHS gives Path A (Stream<T>); user declared Iterator<Integer>.
+        def e = shouldFail '''import groovy.transform.CompileStatic
+            import java.util.stream.Stream
+            @CompileStatic
+            def go(Stream<Integer> src) {
+                def (h, Iterator<Integer> *t) = src
+            }
+            go(Stream.of(1, 2, 3))
+        '''
+        assert e.message.contains('Cannot assign rest value')
+    }
+
+    @Test
+    void testHeadRestFromStream_compileStaticRejected() {
+        // Head/middle rest requires Path B — Stream is Path A, so STC rejects.
+        def e = shouldFail '''import groovy.transform.CompileStatic
+            import java.util.stream.Stream
+            @CompileStatic
+            def go(Stream<Integer> src) {
+                def (*front, last) = src
+            }
+            go(Stream.of(1, 2, 3))
+        '''
+        assert e.message.contains('Head or middle rest binding requires an indexable right-hand side')
+    }
+
+    @Test
+    void testTypedRest_CompileStatic_elementTypeMismatch() {
+        def e = shouldFail '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def (h, List<String> *t) = [1, 2, 3]
+            }
+        '''
+        assert e.message.contains('Cannot assign')
+    }
+
+    @Test
+    void testTypedRest_CompileStatic_genericsAreInvariant() {
+        // Java generics aren't covariant: List<Integer> is NOT a List<Number>.
+        // STC follows the Java rule, so an Integer-element literal won't fit a List<Number>.
+        // Users wanting widening should accept the inferred List<Integer> or use ? extends.
+        def e = shouldFail '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def (h, List<Number> *t) = [1, 2, 3]
+            }
+        '''
+        assert e.message.contains('Cannot assign')
+    }
+
+    @Test
+    void testTypedRest_CompileStatic_extendsBoundAccepts() {
+        // ? extends Number lets the user widen the element type explicitly.
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def (h, List<? extends Number> *t) = [1, 2, 3]
+                assert h == 1
+                assert t == [2, 3]
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testTailRestUnderCompileStatic() {
+        // Static type checker currently doesn't special-case rest; verify it at least
+        // does not crash for a typed head with a untyped rest and produces a usable slice.
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def (int h, t) = [1, 2]  // baseline positional (no rest) under CS
+                assert h == 1 && t == 2
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testTailRestUnderTypeChecked() {
+        // Dynamic semantics under @TypeChecked should still work (runtime dispatch unchanged)
+        assertScript '''import groovy.transform.TypeChecked
+            @TypeChecked
+            def go() {
+                def (h, t) = [1, 2]
+                assert h == 1 && t == 2
+            }
+            go()
+        '''
+    }
+
+    // GEP-20 head/middle rest tests
+
+    @Test
+    void testHeadRestFromList() {
+        assertScript '''
+            def (*f, last) = [1, 2, 3, 4]
+            assert f == [1, 2, 3]
+            assert last == 4
+        '''
+    }
+
+    @Test
+    void testHeadRestWithTypedTail() {
+        assertScript '''
+            def (*f, int last) = [1, 2, 3]
+            assert f == [1, 2]
+            assert last == 3
+            assert last instanceof Integer
+        '''
+    }
+
+    @Test
+    void testMiddleRestFromList() {
+        assertScript '''
+            def (l, *m, r) = [1, 2, 3, 4, 5]
+            assert l == 1
+            assert m == [2, 3, 4]
+            assert r == 5
+        '''
+    }
+
+    @Test
+    void testMiddleRestWithMultipleFixedSlots() {
+        assertScript '''
+            def (a, b, *m, y, z) = [1, 2, 3, 4, 5, 6, 7]
+            assert a == 1
+            assert b == 2
+            assert m == [3, 4, 5]
+            assert y == 6
+            assert z == 7
+        '''
+    }
+
+    @Test
+    void testMiddleRestWithTypedEnds() {
+        assertScript '''
+            def (String l, *m, int r) = ['a', 2, 3, 4, 5]
+            assert l == 'a'
+            assert m == [2, 3, 4]
+            assert r == 5
+            assert r instanceof Integer
+        '''
+    }
+
+    @Test
+    void testMiddleRestWithEmptyMiddle() {
+        assertScript '''
+            def (l, *m, r) = [10, 20]
+            assert l == 10
+            assert m == []
+            assert r == 20
+        '''
+    }
+
+    @Test
+    void testHeadRestFromString() {
+        assertScript '''
+            def (*head, tail) = "hello"
+            assert head == 'hell'
+            assert tail == 'o'
+        '''
+    }
+
+    @Test
+    void testHeadRestFromArray() {
+        assertScript '''
+            Integer[] arr = [1, 2, 3, 4]
+            def (*front, last) = arr
+            assert front == [1, 2, 3]
+            assert last == 4
+        '''
+    }
+
+    @Test
+    void testNonTailRestFromIteratorFailsFast() {
+        // Per GEP: head/middle rest requires sized, indexable RHS.
+        // An iterator doesn't support getAt(IntRange) so it must fail fast (not hang).
+        shouldFail MissingMethodException, '''
+            def it = (1..1_000_000_000).iterator()
+            def (l, *m, r) = it
+        '''
+    }
+
+    @Test
+    void testNonTailRestFromSetFailsFast() {
+        // Sets don't have a natural order via getAt(IntRange) — fail fast.
+        shouldFail MissingMethodException, '''
+            def s = new LinkedHashSet<>([1, 2, 3, 4])
+            def (l, *m, r) = s
+        '''
+    }
+
+    // GEP-20 rest under @CompileStatic / @TypeChecked with literal-list RHS
+
+    @Test
+    void testTailRestUnderCompileStatic_listLiteral() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def (h, *t) = [1, 2, 3, 4]
+                assert h == 1
+                assert t == [2, 3, 4]
+                return t
+            }
+            assert go() instanceof List
+        '''
+    }
+
+    @Test
+    void testTailRestUnderCompileStatic_typedHead() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def (int h, *t) = [10, 20, 30]
+                assert h == 10
+                assert t == [20, 30]
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testHeadRestUnderCompileStatic_listLiteral() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def (*f, last) = [1, 2, 3]
+                assert f == [1, 2]
+                assert last == 3
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testMiddleRestUnderCompileStatic_listLiteral() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def (l, *m, r) = [1, 2, 3, 4, 5]
+                assert l == 1
+                assert m == [2, 3, 4]
+                assert r == 5
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testTailRestUnderTypeChecked_listLiteral() {
+        assertScript '''import groovy.transform.TypeChecked
+            @TypeChecked
+            def go() {
+                def (h, *t) = [1, 2, 3]
+                assert h == 1
+                assert t == [2, 3]
+            }
+            go()
+        '''
+    }
+
+    // GEP-20 map-style destructuring tests
+
+    @Test
+    void testMapStyleFromMap() {
+        assertScript '''
+            def (name: n, age: a) = [name: 'Alice', age: 30]
+            assert n == 'Alice'
+            assert a == 30
+        '''
+    }
+
+    @Test
+    void testMapStyleWithTypedBinders() {
+        assertScript '''
+            def (name: String n, age: int a) = [name: 'Bob', age: 42]
+            assert n == 'Bob'
+            assert n instanceof String
+            assert a == 42
+            assert a instanceof Integer
+        '''
+    }
+
+    @Test
+    void testMapStyleRenaming() {
+        assertScript '''
+            def (host: hostname, port: portNum) = [host: 'localhost', port: 8080]
+            assert hostname == 'localhost'
+            assert portNum == 8080
+        '''
+    }
+
+    @Test
+    void testMapStyleMissingKeyOnMapReturnsNull() {
+        assertScript '''
+            def (name: n, missing: m) = [name: 'x']
+            assert n == 'x'
+            assert m == null
+        '''
+    }
+
+    @Test
+    void testMapStyleFromBean() {
+        assertScript '''
+            class Person {
+                String name
+                int age
+            }
+            def p = new Person(name: 'Carol', age: 25)
+            def (name: n, age: a) = p
+            assert n == 'Carol'
+            assert a == 25
+        '''
+    }
+
+    @Test
+    void testMapStyleBeanMissingPropertyThrows() {
+        shouldFail MissingPropertyException, '''
+            class Person { String name }
+            def p = new Person(name: 'x')
+            def (name: n, missing: m) = p
+        '''
+    }
+
+    @Test
+    void testMapStyleFromGroovyObjectViaGetProperty() {
+        assertScript '''
+            class GO {
+                def getProperty(String name) {
+                    "<$name>".toString()
+                }
+            }
+            def (foo: f, bar: b) = new GO()
+            assert f == '<foo>'
+            assert b == '<bar>'
+        '''
+    }
+
+    @Test
+    void testMapStyleSingleEntry() {
+        assertScript '''
+            def (only: x) = [only: 99]
+            assert x == 99
+        '''
+    }
+
+    @Test // GEP-20 keyword-as-key limitation
+    void testMapStyleRejectsKeywordKeyInGrammar() {
+        // `class` is a reserved keyword in declarator position; the keyedPair rule
+        // accepts only ordinary identifiers, so this fails to parse. Users needing
+        // such keys fall back to `def c = m['class']`.
+        shouldFail '''
+            def m = [class: 'pretender', name: 'real']
+            def (class: c, name: n) = m
+        '''
+    }
+
+    // GEP-20 map-style under @CompileStatic / @TypeChecked with literal-map RHS
+
+    @Test
+    void testMapStyleUnderCompileStatic_mapLiteral() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def (name: n, age: a) = [name: 'Alice', age: 30]
+                assert n == 'Alice'
+                assert a == 30
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testMapStyleUnderCompileStatic_typedBinders() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def (name: String n, age: int a) = [name: 'Bob', age: 42]
+                assert n == 'Bob'
+                assert a == 42
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testMapStyleUnderCompileStatic_typeMismatchErrors() {
+        def e = shouldFail '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def (name: int n) = [name: 'not a number']
+            }
+            go()
+        '''
+        assert e.message.contains('Cannot assign')
+    }
+
+    @Test
+    void testMapStyleUnderCompileStatic_missingKeyTolerated() {
+        // STC matches dynamic semantics: missing Map key → null binding (Object inferred type).
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def (name: n, missing: m) = [name: 'x']
+                assert n == 'x'
+                assert m == null
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testMapStyleUnderTypeChecked_mapLiteral() {
+        assertScript '''import groovy.transform.TypeChecked
+            @TypeChecked
+            def go() {
+                def (host: h, port: p) = [host: 'localhost', port: 8080]
+                assert h == 'localhost'
+                assert p == 8080
+            }
+            go()
+        '''
+    }
+
+    // GEP-20 positional multi-assignment with declared-type RHS under
+    // @CompileStatic / @TypeChecked — lifts the previous literal-only restriction
+    // ("Multiple assignments without list or tuple on the right-hand side").
+
+    @Test
+    void testPositional_CompileStatic_ListVariableRhs() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go(List<Integer> src) {
+                def (a, b) = src
+                assert a == 10
+                assert b == 20
+            }
+            go([10, 20])
+        '''
+    }
+
+    @Test
+    void testPositional_CompileStatic_typedBinders_ListVariable() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go(List<Integer> src) {
+                def (int a, int b) = src
+                assert a == 10
+                assert b == 20
+            }
+            go([10, 20])
+        '''
+    }
+
+    @Test
+    void testPositional_CompileStatic_ArrayVariableRhs() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go(Integer[] src) {
+                def (a, b) = src
+                assert a == 1
+                assert b == 2
+            }
+            Integer[] arr = [1, 2]
+            go(arr)
+        '''
+    }
+
+    @Test
+    void testPositional_CompileStatic_StringVariableRhs() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go(String src) {
+                def (a, b) = src
+                assert a == 'h'
+                assert b == 'i'
+            }
+            go('hi')
+        '''
+    }
+
+    @Test
+    void testPositional_CompileStatic_methodReturningList() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def src() { [1, 2, 3] }
+            @CompileStatic
+            def go() {
+                def (a, b, c) = src()
+                assert [a, b, c] == [1, 2, 3]
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testPositional_CompileStatic_typeMismatch() {
+        def e = shouldFail '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go(List<String> src) {
+                def (int a, int b) = src
+            }
+            go(['x', 'y'])
+        '''
+        assert e.message.contains('Cannot assign')
+    }
+
+    // GEP-20 map-style with declared-type RHS under @CompileStatic
+    // (Map<K, V>, beans, GroovyObject — resolved via STC's property lookup)
+
+    @Test
+    void testMapStyle_CompileStatic_MapVariableRhs() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go(Map<String, Object> src) {
+                def (name: n, age: a) = src
+                assert n == 'Alice'
+                assert a == 30
+            }
+            go([name: 'Alice', age: 30])
+        '''
+    }
+
+    @Test
+    void testMapStyle_CompileStatic_TypedMapVariableRhs() {
+        // Map<String, Integer> → all binders inferred as Integer.
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go(Map<String, Integer> src) {
+                def (port: p, max: m) = src
+                int total = p + m
+                assert total == 8090
+            }
+            go([port: 80, max: 8010])
+        '''
+    }
+
+    @Test
+    void testMapStyle_CompileStatic_BeanVariableRhs() {
+        assertScript '''import groovy.transform.CompileStatic
+            class Person { String name; int age }
+            @CompileStatic
+            def go(Person p) {
+                def (name: n, age: a) = p
+                assert n == 'Bob'
+                assert a == 42
+            }
+            go(new Person(name: 'Bob', age: 42))
+        '''
+    }
+
+    @Test
+    void testMapStyle_CompileStatic_BeanTypedBinders() {
+        assertScript '''import groovy.transform.CompileStatic
+            class Person { String name; int age }
+            @CompileStatic
+            def go(Person p) {
+                def (name: String n, age: int a) = p
+                assert n == 'Carol'
+                assert a == 25
+                String greeting = "hi $n"
+                assert greeting == 'hi Carol'
+            }
+            go(new Person(name: 'Carol', age: 25))
+        '''
+    }
+
+    @Test
+    void testMapStyle_CompileStatic_BeanMissingPropertyErrors() {
+        // Per GEP failure-modes table: bean RHS with no such static property → compile error.
+        def e = shouldFail '''import groovy.transform.CompileStatic
+            class Person { String name }
+            @CompileStatic
+            def go(Person p) {
+                def (name: n, missing: m) = p
+            }
+        '''
+        assert e.message.contains('No such property') || e.message.contains('missing')
+    }
+
+    @Test
+    void testMapStyle_CompileStatic_BeanTypeMismatch() {
+        def e = shouldFail '''import groovy.transform.CompileStatic
+            class Person { String name }
+            @CompileStatic
+            def go(Person p) {
+                def (name: int n) = p
+            }
+        '''
+        assert e.message.contains('Cannot assign')
+    }
+
+    @Test
+    void testMapStyle_CompileStatic_methodReturningBean() {
+        assertScript '''import groovy.transform.CompileStatic
+            class Person { String name; int age }
+            @CompileStatic
+            class Holder {
+                Person makePerson() { new Person(name: 'Dan', age: 7) }
+                def go() {
+                    def (name: n, age: a) = makePerson()
+                    assert n == 'Dan' && a == 7
+                }
+            }
+            new Holder().go()
+        '''
+    }
+
+    // GEP-20 rest with declared-type RHS under @CompileStatic / @TypeChecked
+    // (List, array, String, Set, Iterator — Path B or Path C chosen from declared type)
+
+    @Test
+    void testTailRest_CompileStatic_ListVariableRhs() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go(List<Integer> src) {
+                def (h, *t) = src
+                assert h == 1
+                assert t == [2, 3]
+            }
+            go([1, 2, 3])
+        '''
+    }
+
+    @Test
+    void testTailRest_CompileStatic_ArrayVariableRhs() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go(Integer[] src) {
+                def (h, *t) = src
+                assert h == 1
+                assert t == [2, 3]
+            }
+            Integer[] arr = [1, 2, 3]
+            go(arr)
+        '''
+    }
+
+    @Test
+    void testTailRest_CompileStatic_StringVariableRhs() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go(String src) {
+                def (h, *t) = src
+                assert h == 'h'
+                assert t == 'ello'
+            }
+            go('hello')
+        '''
+    }
+
+    @Test
+    void testTailRest_CompileStatic_PathB_setRhs() {
+        // Set is Path C → rest inferred as Iterator<T>.
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go(Set<Integer> src) {
+                def (h, *t) = src
+                assert h == 7
+                assert t instanceof Iterator
+                assert t.next() == 8
+                assert t.next() == 9
+                assert !t.hasNext()
+            }
+            go(new LinkedHashSet<>([7, 8, 9]))
+        '''
+    }
+
+    @Test
+    void testTailRest_CompileStatic_PathB_iteratorRhs() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go(Iterator<Integer> src) {
+                def (h, *t) = src
+                assert h == 1
+                assert t.next() == 2
+                assert t.next() == 3
+            }
+            go([1, 2, 3].iterator())
+        '''
+    }
+
+    @Test
+    void testHeadRest_CompileStatic_ListVariableRhs() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go(List<Integer> src) {
+                def (*f, last) = src
+                assert f == [1, 2, 3]
+                assert last == 4
+            }
+            go([1, 2, 3, 4])
+        '''
+    }
+
+    @Test
+    void testMiddleRest_CompileStatic_ListVariableRhs() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go(List<Integer> src) {
+                def (l, *m, r) = src
+                assert l == 1
+                assert m == [2, 3, 4]
+                assert r == 5
+            }
+            go([1, 2, 3, 4, 5])
+        '''
+    }
+
+    @Test
+    void testNonTailRest_CompileStatic_pathCRhsRejectedAtCompileTime() {
+        // Set RHS for head/middle rest is Path C only; STC must reject.
+        def e = shouldFail '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go(Set<Integer> src) {
+                def (l, *m, r) = src
+            }
+            go([1, 2, 3, 4] as Set)
+        '''
+        assert e.message.contains('rest binding requires an indexable')
+                || e.message.contains('does not support getAt(IntRange)')
+    }
+
+    @Test
+    void testTailRest_CompileStatic_typedHeadAgainstListVariable() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go(List<Integer> src) {
+                def (int h, *t) = src
+                assert h == 10
+                assert t == [20, 30]
+            }
+            go([10, 20, 30])
+        '''
+    }
+
+    // GEP-20 cross-cutting @CompileStatic coverage: parallels of the dynamic-mode
+    // tests plus second-order interactions (empty RHS, degenerate single-rest,
+    // unbounded iterator, method-returning-typed, destructure-inside-closure).
+
+    @Test
+    void testTailRest_CompileStatic_emptyList() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def (h, *t) = []
+                assert h == null
+                assert t == []
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testTailRest_CompileStatic_stringLiteral() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def (c, *cs) = "hello"
+                assert c == 'h'
+                assert cs == "ello"
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testTailRest_CompileStatic_arrayLiteral() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                Integer[] arr = [1, 2, 3]
+                def (h, *t) = arr
+                assert h == 1
+                assert t == [2, 3] || (t as List) == [2, 3]
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testTailRest_CompileStatic_singleRestBinder() {
+        // Degenerate: def (*t) = rhs is equivalent to def t = rhs.
+        // BinaryExpressionHelper has a fast-path for this — verify it under CS.
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def (*t) = [1, 2, 3]
+                assert t == [1, 2, 3]
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testTailRest_CompileStatic_unboundedIterator_lazy() {
+        // Path C with an unbounded source — must NOT materialise.
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            class Counter implements Iterator<Integer> {
+                int i = 0
+                boolean hasNext() { true }
+                Integer next() { i++ }
+                void remove() { throw new UnsupportedOperationException() }
+            }
+            @CompileStatic
+            def go() {
+                def (h, *t) = (Iterator<Integer>) new Counter()
+                assert h == 0
+                assert t.next() == 1
+                assert t.next() == 2
+                assert t.next() == 3
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testHeadRest_CompileStatic_stringLiteral() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def (*head, tail) = "hello"
+                assert head == 'hell'
+                assert tail == 'o'
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testHeadRest_CompileStatic_arrayLiteral() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                Integer[] arr = [1, 2, 3, 4]
+                def (*front, last) = arr
+                assert front == [1, 2, 3]
+                assert last == 4
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testMiddleRest_CompileStatic_emptyMiddle() {
+        // Short RHS — middle absorbs zero elements; nonTailRestSlice helper handles
+        // the inverted-range case by returning an empty list (not the DGM-reverse default).
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def (l, *m, r) = [10, 20]
+                assert l == 10
+                assert m == []
+                assert r == 20
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testMiddleRest_CompileStatic_typedEnds() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def (String l, *m, int r) = ['a', 2, 3, 4, 5]
+                assert l == 'a'
+                assert m == [2, 3, 4]
+                assert r == 5
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testMapStyle_CompileStatic_renaming() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def (host: hostname, port: portNum) = [host: 'localhost', port: 8080]
+                assert hostname == 'localhost'
+                assert portNum == 8080
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testMapStyle_CompileStatic_singleEntry() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def (only: x) = [only: 99]
+                assert x == 99
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testMapStyle_CompileStatic_methodReturningTypedMap() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            class Holder {
+                Map<String, Integer> stats() { [hits: 7, misses: 2] }
+                def go() {
+                    def (hits: h, misses: m) = stats()
+                    assert h == 7
+                    assert m == 2
+                }
+            }
+            new Holder().go()
+        '''
+    }
+
+    @Test
+    void testPositional_CompileStatic_methodReturningArray() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            class Holder {
+                Integer[] arr() { [10, 20, 30] as Integer[] }
+                def go() {
+                    def (a, b, c) = arr()
+                    assert [a, b, c] == [10, 20, 30]
+                }
+            }
+            new Holder().go()
+        '''
+    }
+
+    @Test
+    void testTailRest_CompileStatic_closureCallRhs() {
+        // Closure invocation returning List<T> — exercises the indexable-RHS synthesis
+        // for declared-type positional plus the rest dispatcher.
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def src = { -> [1, 2, 3] as List<Integer> }
+                def (h, *t) = src()
+                assert h == 1
+                assert t == [2, 3]
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testMixed_CompileStatic_destructureInsideClosure() {
+        // Verify that destructuring inside a closure body works under @CompileStatic
+        // (closure scoping doesn't break the synthetic temp / rest dispatch).
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def total = 0
+                [[1,2,3], [10,20,30]].each { List<Integer> row ->
+                    def (a, *rest) = row
+                    total += a
+                    for (Integer r : rest) total += r
+                }
+                assert total == 66
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testMapStyle_CompileStatic_destructureInsideClosure() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                List<String> names = []
+                List<Map<String, Integer>> people = [[id: 1, age: 30], [id: 2, age: 42]]
+                people.each { Map<String, Integer> p ->
+                    def (id: i, age: a) = p
+                    names << "id=$i age=$a".toString()
+                }
+                assert names == ['id=1 age=30', 'id=2 age=42']
+            }
+            go()
+        '''
+    }
+
+    // GEP-20 def/var binders: accepted in place of a type, equivalent to omitting it.
+    // Provided for symmetry with switch case patterns and the GEP-19 bracket-form grammar.
+
+    @Test
+    void testDefBinder_positional() {
+        assertScript '''
+            def (def a, def b) = [1, 2]
+            assert a == 1 && b == 2
+        '''
+    }
+
+    @Test
+    void testVarBinder_positional() {
+        assertScript '''
+            def (var a, var b) = [1, 2]
+            assert a == 1 && b == 2
+        '''
+    }
+
+    @Test
+    void testMixedDefVarTypedBinder() {
+        assertScript '''
+            def (def a, var b, int c) = [1, 2, 3]
+            assert a == 1 && b == 2 && c == 3
+        '''
+    }
+
+    @Test
+    void testVarBinder_withRest() {
+        assertScript '''
+            def (var h, *t) = [1, 2, 3]
+            assert h == 1
+            assert t == [2, 3]
+        '''
+    }
+
+    @Test
+    void testVarBinder_mapStyle() {
+        assertScript '''
+            def (name: var n, age: var a) = [name: 'X', age: 9]
+            assert n == 'X' && a == 9
+        '''
+    }
+
+    @Test
+    void testVarBinder_CompileStatic_listLiteral() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def (var a, var b) = [10, 20]
+                assert a == 10 && b == 20
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testVarBinder_CompileStatic_mapStyle() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def (name: var n) = [name: 'Z']
+                assert n == 'Z'
+            }
+            go()
+        '''
+    }
+
+    // GEP-20 edge cases: empty Map literal, null RHS, Map missing-key with primitive
+    // vs Object binders, classic for-init multi-assignment, method-returning-typed.
+
+    @Test
+    void testMapStyle_emptyMapLiteral_dynamic() {
+        assertScript '''
+            def (name: n, age: a) = [:]
+            assert n == null
+            assert a == null
+        '''
+    }
+
+    @Test
+    void testMapStyle_emptyMapLiteral_compileStatic() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def (name: n, age: a) = [:]
+                assert n == null
+                assert a == null
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testRest_nullRhs_compileStatic() {
+        // Declared-type RHS makes STC accept; runtime NPE on iterator() is the natural failure
+        // mode (matches the dynamic equivalent).
+        shouldFail NullPointerException, '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go(List<Integer> src) {
+                def (h, *t) = src
+            }
+            go(null)
+        '''
+    }
+
+    @Test
+    void testMapStyle_compileStatic_objectBinderTolerant() {
+        // Object binder against Map<String, Integer> — missing key gives null. n is Object.
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go(Map<String, Integer> m) {
+                def (name: n, missing: x) = m
+                assert n == 1
+                assert x == null
+            }
+            go([name: 1])
+        '''
+    }
+
+    @Test
+    void testMapStyle_compileStatic_primitiveBinderRuntimeFailureOnMissingKey() {
+        // STC stores the Map's V as the binder type; primitive int binder accepts.
+        // At runtime, missing key → null, and null→int throws GroovyCastException —
+        // standard Groovy semantics, not a GEP-20 specific behaviour.
+        shouldFail org.codehaus.groovy.runtime.typehandling.GroovyCastException, '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go(Map<String, Integer> m) {
+                def (missing: int x) = m
+            }
+            go([name: 1])
+        '''
+    }
+
+    @Test
+    void testForInit_destructure_dynamic() {
+        // Classic for-init style with multi-assignment — pre-GEP behaviour, regression check.
+        assertScript '''
+            int sum = 0
+            for (def (a, b) = [1, 2]; a < 5; a++, b++) {
+                sum += a + b
+            }
+            assert sum == (1+2) + (2+3) + (3+4) + (4+5)
+        '''
+    }
+
+    @Test
+    void testForInit_destructure_compileStatic() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                int sum = 0
+                for (def (int a, int b) = [1, 2]; a < 4; a++, b++) {
+                    sum += a + b
+                }
+                return sum
+            }
+            assert go() == (1+2) + (2+3) + (3+4)
+        '''
+    }
+
+    @Test
+    void testRest_compileStatic_methodReturningTypedList() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            class Holder {
+                List<Integer> data() { [10, 20, 30, 40] }
+                def go() {
+                    def (h, *t) = data()
+                    assert h == 10
+                    assert t == [20, 30, 40]
+                }
+            }
+            new Holder().go()
+        '''
+    }
+
+    @Test
+    void testPositional_CompileStatic_objectRhsRuntimeError() {
+        // Object-declared RHS is lenient at compile time (the MOP fallback resolves getAt(int)),
+        // so the failure mode for an actually non-indexable value shifts from STC error to a
+        // runtime MissingMethodException. Users who want compile-time rejection should declare
+        // a more specific RHS type.
+        def e = shouldFail '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go(Object src) {
+                def (a, b) = src
+            }
+            go(42)
+        '''
+        assert e instanceof MissingMethodException
+        assert e.message.contains('getAt')
+    }
+
+    // GEP-20 array-rest consistency: empty and non-empty array rest both yield List<T>.
+    // Prior to GROOVY-11964 patch, the empty branch returned an empty array which would fail
+    // assignment to a typed `List<T> *t` rest binder. These tests use arrays sized exactly to
+    // the fixed slots so the rest absorbs zero — exercising the empty branch in tailRest
+    // without tripping the orthogonal AIOOBE on getAt(0) for fully-empty arrays.
+
+    @Test
+    void testTailRest_arrayExactlyConsumed_emptyRestYieldsList() {
+        assertScript '''
+            Integer[] arr = [1, 2, 3] as Integer[]
+            def (a, b, c, *t) = arr
+            assert a == 1
+            assert b == 2
+            assert c == 3
+            assert t == []
+            assert t instanceof List
+        '''
+    }
+
+    @Test
+    void testTailRest_arrayExactlyConsumed_typedListBinder() {
+        assertScript '''
+            Integer[] arr = [1, 2, 3] as Integer[]
+            def (a, b, c, List<Integer> *t) = arr
+            assert a == 1
+            assert b == 2
+            assert c == 3
+            assert t == []
+        '''
+    }
+
+    @Test
+    void testMiddleRest_shortArray_emptyMiddleYieldsEmptyList() {
+        // Short array RHS — middle rest absorbs zero elements; empty slice is List, not array.
+        assertScript '''
+            Integer[] arr = [10, 20] as Integer[]
+            def (l, *m, r) = arr
+            assert l == 10
+            assert m == []
+            assert m instanceof List
+            assert r == 20
+        '''
+    }
+
+    @Test
+    void testMiddleRest_shortArray_typedListMiddleBinder() {
+        assertScript '''
+            Integer[] arr = [10, 20] as Integer[]
+            def (Integer l, List<Integer> *m, Integer r) = arr
+            assert l == 10
+            assert m == []
+            assert r == 20
+        '''
+    }
+
+    // GEP-20 empty rest mutability: when the rest binding captures zero elements, the
+    // result must be a mutable list — not Collections.emptyList() — so that downstream
+    // mutation (`rest << x`, `rest.add(x)`) behaves the same whether the rest absorbed
+    // zero or N elements. Mirrors Groovy's getAt(EmptyRange) contract on List/array.
+
+    @Test
+    void testTailRest_emptyRest_isMutable_listRhs() {
+        assertScript '''
+            def (a, *t) = [1]
+            assert t == []
+            t << 2
+            assert t == [2]
+        '''
+    }
+
+    @Test
+    void testTailRest_emptyRest_isMutable_arrayRhs() {
+        assertScript '''
+            Integer[] arr = [1, 2, 3] as Integer[]
+            def (a, b, c, *t) = arr
+            assert t == []
+            t << 4
+            assert t == [4]
+        '''
+    }
+
+    @Test
+    void testMiddleRest_emptyMiddle_isMutable_listRhs() {
+        assertScript '''
+            def (l, *m, r) = [10, 20]
+            assert m == []
+            m << 15
+            assert m == [15]
+        '''
+    }
+
+    @Test
+    void testMiddleRest_emptyMiddle_isMutable_arrayRhs() {
+        assertScript '''
+            Integer[] arr = [10, 20] as Integer[]
+            def (l, *m, r) = arr
+            assert m == []
+            m << 15
+            assert m == [15]
+        '''
+    }
+
+    @Test
+    void testTailRest_emptyRest_preservesListSubtype() {
+        assertScript '''
+            def src = new LinkedList<Integer>([1])
+            def (a, *t) = src
+            assert t == []
+            assert t instanceof LinkedList
+            t << 2
+            assert t == [2]
+        '''
+    }
+
+    // GEP-20 empty rest slice element-type inference: when a list-literal RHS is too short
+    // for the rest to absorb anything, fall back to the rest binder's declared element type
+    // rather than Object — otherwise `List<Integer> *t` is rejected as List<Object>.
+
+    @Test
+    void testTypedRest_CompileStatic_emptySlice_listLiteral() {
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def (Integer a, List<Integer> *t, Integer b) = [1, 2]
+                assert a == 1
+                assert t == []
+                assert b == 2
+            }
+            go()
+        '''
+    }
+
+    // GEP-20 Path B by capability: the rest binder type tracks the actual return type of
+    // getAt(IntRange) on the receiver, not a hard-coded List<T>. Covers String/GString
+    // (return String), CharSequence (return CharSequence), BitSet (return BitSet), and
+    // user custom classes that declare a self-similar slice type.
+
+    @Test
+    void testTailRest_String_restIsString_dynamic() {
+        assertScript '''
+            def (c, *cs) = "hello"
+            assert c == 'h'
+            assert cs == "ello"
+            assert cs instanceof String
+            assert cs.toUpperCase() == "ELLO"   // String API survives destructuring
+        '''
+    }
+
+    @Test
+    void testTailRest_String_restIsString_compileStatic() {
+        // Smoke-tests that STC infers cs : String — calling a String-only method must compile.
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def (c, *cs) = "hello"
+                assert c == 'h'
+                assert cs.toUpperCase() == "ELLO"
+                assert cs.startsWith("ell")
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testTailRest_BitSet_restIsBitSet_dynamic() {
+        assertScript '''
+            def bs = new BitSet()
+            bs.set(0); bs.set(2); bs.set(5)
+            def (h, *t) = bs
+            assert h == true
+            assert t instanceof BitSet
+            assert t.get(1)   // bit 2 in original is at index 1 of slice
+            assert t.get(4)   // bit 5 in original is at index 4 of slice
+        '''
+    }
+
+    @Test
+    void testTailRest_BitSet_restIsBitSet_compileStatic() {
+        // STC must infer t : BitSet so BitSet-only API calls compile.
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def bs = new BitSet()
+                bs.set(0); bs.set(2); bs.set(5)
+                def (h, *t) = bs
+                assert h == true
+                assert t.cardinality() == 2   // BitSet-only method
+                assert t.get(1) && t.get(4)
+            }
+            go()
+        '''
+    }
+
+    @Test
+    void testTailRest_userClass_selfSimilarSlice_dynamic() {
+        assertScript '''
+            class MyCol {
+                List<Integer> data
+                MyCol(List<Integer> d) { this.data = d }
+                Integer getAt(int i) { data[i] }
+                MyCol getAt(IntRange r) { new MyCol(data[r]) }
+                Iterator<Integer> iterator() { data.iterator() }
+            }
+            def m = new MyCol([10, 20, 30, 40])
+            def (h, *t) = m
+            assert h == 10
+            assert t instanceof MyCol
+            assert t.data == [20, 30, 40]
+        '''
+    }
+
+    @Test
+    void testTailRest_userClass_selfSimilarSlice_compileStatic() {
+        // The bytecode dispatches the rest slice as `rhs.getAt(new IntRange(true, 1, -1))`,
+        // so the user's getAt(IntRange) must handle the negative end-index. Delegating to
+        // the inner List's range subscript is the simplest robust pattern.
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            class MyCol {
+                List<Integer> data
+                MyCol(List<Integer> d) { this.data = d }
+                Integer getAt(int i) { data[i] }
+                MyCol getAt(IntRange r) { new MyCol(data[r]) }
+                Iterator<Integer> iterator() { data.iterator() }
+            }
+            @CompileStatic
+            def go(MyCol src) {
+                def (h, *t) = src
+                assert h == 10
+                // STC should type t as MyCol so .data resolves at compile time.
+                assert t.data == [20, 30, 40]
+            }
+            go(new MyCol([10, 20, 30, 40]))
+        '''
+    }
+
+    @Test
+    void testHeadRest_BitSet_compileStatic() {
+        // BitSet now participates in head/middle rest under STC because Path B is determined
+        // by capability (resolvable getAt(IntRange)), not by membership in a closed list.
+        assertScript '''import groovy.transform.CompileStatic
+            @CompileStatic
+            def go() {
+                def bs = new BitSet()
+                bs.set(0); bs.set(1); bs.set(2)
+                def (*front, last) = bs
+                assert front instanceof BitSet
+                assert last == true
+            }
+            go()
+        '''
+    }
 }
diff --git a/src/test/groovy/groovy/transform/stc/STCAssignmentTest.groovy b/src/test/groovy/groovy/transform/stc/STCAssignmentTest.groovy
index 64df131143..f2708ed78e 100644
--- a/src/test/groovy/groovy/transform/stc/STCAssignmentTest.groovy
+++ b/src/test/groovy/groovy/transform/stc/STCAssignmentTest.groovy
@@ -1328,11 +1328,15 @@ class STCAssignmentTest extends StaticTypeCheckingTestCase {
 
     @Test
     void testMultipleAssignmentFromVariable() {
-        shouldFailWithMessages '''
+        // GEP-20: declared-type RHS is accepted when the type has a statically-resolvable
+        // getAt(int). The previous "Multiple assignments without list or tuple" restriction
+        // has been lifted for this case.
+        assertScript '''
             def list = [1,2,3]
             def (x,y) = list
-        ''',
-        'Multiple assignments without list or tuple on the right-hand side are unsupported in static type checking mode'
+            assert x == 1
+            assert y == 2
+        '''
     }
 
     // GROOVY-8223, GROOVY-8887, GROOVY-10063