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/06/10 01:50:49 UTC

(groovy) branch master updated: GROOVY-12078: support old parameter values in @Ensures postconditions (incl. static methods)

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 d1386809d5 GROOVY-12078: support old parameter values in @Ensures postconditions (incl. static methods)
d1386809d5 is described below

commit d1386809d5bc322356b809df9aade37cd4babbd2
Author: Paul King <pa...@asert.com.au>
AuthorDate: Wed Jun 10 11:22:06 2026 +1000

    GROOVY-12078: support old parameter values in @Ensures postconditions (incl. static methods)
---
 .../ModifiesEnsuresValidationTransformation.java   |  11 +
 .../ast/visitor/AnnotationClosureVisitor.java      |  59 +++++-
 .../generation/OldVariableGenerationUtility.java   |  67 +++++-
 .../generation/PostconditionGenerator.java         |  59 +++++-
 .../other/ClosureExpressionValidationTests.groovy  |  10 +-
 .../contracts/tests/post/ModifiesTests.groovy      |  21 ++
 .../post/OldParameterPostconditionTests.groovy     | 230 +++++++++++++++++++++
 7 files changed, 431 insertions(+), 26 deletions(-)

diff --git a/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/ModifiesEnsuresValidationTransformation.java b/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/ModifiesEnsuresValidationTransformation.java
index a622cba377..f354e602b7 100644
--- a/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/ModifiesEnsuresValidationTransformation.java
+++ b/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/ModifiesEnsuresValidationTransformation.java
@@ -22,6 +22,7 @@ import org.apache.groovy.contracts.ast.visitor.AnnotationClosureVisitor;
 import org.codehaus.groovy.ast.ASTNode;
 import org.codehaus.groovy.ast.AnnotationNode;
 import org.codehaus.groovy.ast.MethodNode;
+import org.codehaus.groovy.ast.Parameter;
 import org.codehaus.groovy.control.CompilePhase;
 import org.codehaus.groovy.control.SourceUnit;
 import org.codehaus.groovy.syntax.SyntaxException;
@@ -68,6 +69,9 @@ public class ModifiesEnsuresValidationTransformation implements ASTTransformatio
         if (oldRefs == null || oldRefs.isEmpty()) return;
 
         for (String ref : oldRefs) {
+            // GROOVY-12078: @Modifies is a frame condition over fields; a parameter snapshot is not
+            // class state, so old.<parameter> is exempt from the @Modifies declaration
+            if (isParameter(methodNode, ref)) continue;
             if (!modifiesSet.contains(ref)) {
                 source.addError(new SyntaxException(
                         "@Ensures references old." + ref + " but @Modifies does not declare '" + ref + "' as modifiable",
@@ -75,4 +79,11 @@ public class ModifiesEnsuresValidationTransformation implements ASTTransformatio
             }
         }
     }
+
+    private static boolean isParameter(final MethodNode methodNode, final String name) {
+        for (Parameter parameter : methodNode.getParameters()) {
+            if (parameter.getName().equals(name)) return true;
+        }
+        return false;
+    }
 }
diff --git a/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/visitor/AnnotationClosureVisitor.java b/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/visitor/AnnotationClosureVisitor.java
index c251d984a8..d9ebe7c13d 100644
--- a/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/visitor/AnnotationClosureVisitor.java
+++ b/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/visitor/AnnotationClosureVisitor.java
@@ -510,13 +510,6 @@ public class AnnotationClosureVisitor extends BaseVisitor implements ASTNodeMeta
         @Override
         public void visitVariableExpression(VariableExpression expression) {
 
-            // GROOVY-12052: 'old' snapshots instance state, which a static method has none of
-            if (!secondPass && methodNode != null && methodNode.isStatic()
-                    && "old".equals(expression.getName())
-                    && AnnotationUtils.hasAnnotationOfType(annotationNode.getClassNode(), POSTCONDITION_TYPE_NAME)) {
-                addError("[groovy-contracts] 'old' is not supported in postconditions of static methods.", expression);
-            }
-
             // in case of a FieldNode, checks whether the FieldNode can be replaced with a Parameter
             Variable accessedVariable = getParameterCandidate(expression.getAccessedVariable());
             if (accessedVariable instanceof FieldNode fieldNode) {
@@ -542,6 +535,26 @@ public class AnnotationClosureVisitor extends BaseVisitor implements ASTNodeMeta
             super.visitVariableExpression(expression);
         }
 
+        /**
+         * Validates {@code old.<name>} access. GROOVY-12052/GROOVY-12078: a static method has no instance
+         * state, so {@code old} there may only snapshot a method parameter's entry value; an
+         * {@code old.<field>} (or any other non-parameter) reference is rejected.
+         *
+         * @param expression the property expression being visited
+         */
+        @Override
+        public void visitPropertyExpression(PropertyExpression expression) {
+            if (!secondPass && methodNode != null && methodNode.isStatic()
+                    && expression.getObjectExpression() instanceof VariableExpression objectVar
+                    && "old".equals(objectVar.getName())
+                    && AnnotationUtils.hasAnnotationOfType(annotationNode.getClassNode(), POSTCONDITION_TYPE_NAME)
+                    && getParameterByName(expression.getPropertyAsString()) == null) {
+                addError("[groovy-contracts] 'old' in a postcondition of a static method may only reference a method parameter.", expression);
+            }
+
+            super.visitPropertyExpression(expression);
+        }
+
         /**
          * Validates postfix operations and performs second-pass rewrites when needed.
          *
@@ -661,6 +674,14 @@ public class AnnotationClosureVisitor extends BaseVisitor implements ASTNodeMeta
             return variable;
         }
 
+        private Parameter getParameterByName(String name) {
+            if (name == null || methodNode == null) return null;
+            for (Parameter param : methodNode.getParameters()) {
+                if (name.equals(param.getName())) return param;
+            }
+            return null;
+        }
+
         /**
          * Executes the second validation pass that applies the deferred expression rewrites.
          *
@@ -756,6 +777,16 @@ public class AnnotationClosureVisitor extends BaseVisitor implements ASTNodeMeta
                             methodNode.putNodeMetaData(OLD_REFERENCES_KEY, oldRefs);
                         }
                         oldRefs.add(propName);
+                        // GROOVY-12078: a snapshotted parameter is typed by its declared type; a
+                        // parameter shadows a same-named field, matching the runtime precedence where
+                        // the parameter snapshot overrides the field snapshot
+                        Parameter parameter = getParameter(propName);
+                        if (parameter != null) {
+                            CastExpression adjusted = new CastExpression(parameter.getType(), expr);
+                            adjusted.setSourcePosition(expr);
+                            expr.setNodeMetaData(PROCESSED, Boolean.TRUE);
+                            return adjusted;
+                        }
                         ClassNode declaringClass = methodNode.getDeclaringClass();
                         if (declaringClass != null && declaringClass.getField(propName) != null) {
                             CastExpression adjusted = new CastExpression(declaringClass.getField(propName).getType(), expr);
@@ -768,5 +799,19 @@ public class AnnotationClosureVisitor extends BaseVisitor implements ASTNodeMeta
             }
             return expr.transformExpression(this);
         }
+
+        /**
+         * Returns the {@code methodNode} parameter named {@code name}, or {@code null} if there is none.
+         * Used to type an {@code old.<name>} access as the parameter's declared type.
+         *
+         * @param name the property name accessed via {@code old}
+         * @return the matching parameter, or {@code null}
+         */
+        private Parameter getParameter(String name) {
+            for (Parameter parameter : methodNode.getParameters()) {
+                if (parameter.getName().equals(name)) return parameter;
+            }
+            return null;
+        }
     }
 }
diff --git a/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/generation/OldVariableGenerationUtility.java b/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/generation/OldVariableGenerationUtility.java
index 8215072b63..0e14f100fa 100644
--- a/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/generation/OldVariableGenerationUtility.java
+++ b/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/generation/OldVariableGenerationUtility.java
@@ -18,11 +18,13 @@
  */
 package org.apache.groovy.contracts.generation;
 
+import org.apache.groovy.ast.tools.ImmutablePropertyUtils;
 import org.codehaus.groovy.ast.ClassHelper;
 import org.codehaus.groovy.ast.ClassNode;
 import org.codehaus.groovy.ast.FieldNode;
 import org.codehaus.groovy.ast.MethodNode;
 import org.codehaus.groovy.ast.Parameter;
+import org.codehaus.groovy.ast.expr.Expression;
 import org.codehaus.groovy.ast.expr.MapExpression;
 import org.codehaus.groovy.ast.expr.MethodCallExpression;
 import org.codehaus.groovy.ast.expr.VariableExpression;
@@ -30,6 +32,7 @@ import org.codehaus.groovy.ast.stmt.BlockStatement;
 import org.codehaus.groovy.ast.stmt.Statement;
 import org.objectweb.asm.Opcodes;
 
+import java.util.List;
 import java.util.Map;
 
 import static org.codehaus.groovy.ast.tools.GeneralUtils.args;
@@ -74,15 +77,10 @@ public class OldVariableGenerationUtility {
 
             final ClassNode fieldType = ClassHelper.getWrapper(fieldNode.getType());
 
-            if (fieldType.getName().startsWith("java.lang") || ClassHelper.isPrimitiveType(fieldType) || fieldType.getName().startsWith("java.math") ||
-                    fieldType.getName().startsWith("java.util") ||
-                    fieldType.getName().startsWith("java.sql") ||
-                    "groovy.lang.GString".equals(fieldType.getName()) ||
-                    "java.lang.String".equals(fieldType.getName())) {
+            if (isOldSnapshotType(fieldType)) {
 
-                MethodNode cloneMethod = fieldType.getMethod("clone", Parameter.EMPTY_ARRAY);
                 // if a clone classNode is available, the value is cloned
-                if (cloneMethod != null && fieldType.implementsInterface(ClassHelper.make("java.lang.Cloneable"))) {
+                if (isCloneable(fieldType)) {
 
                     final MethodCallExpression cloneField = callX(fieldX(fieldNode), "clone");
                     // return null if field is null
@@ -126,4 +124,59 @@ public class OldVariableGenerationUtility {
         preconditionMethodNode.setSynthetic(true);
 
     }
+
+    /**
+     * Returns an expression that snapshots {@code value} (of declared type {@code type}) for storage in
+     * the {@code old} map. The snapshot is only a defensive copy when one is needed and possible:
+     * <ul>
+     *   <li>a value of a known-immutable type (per {@link ImmutablePropertyUtils}: primitives/wrappers,
+     *       {@link String}, {@code BigInteger}/{@code BigDecimal}, the {@code java.time.*} types, enums,
+     *       {@code @Immutable}/{@code @KnownImmutable} types, ...) is stored by reference, since it cannot
+     *       change;</li>
+     *   <li>a mutable {@link Cloneable} value is defensively copied with a null-safe {@code clone()}, so an
+     *       in-place change in the method body is not also seen through {@code old};</li>
+     *   <li>any other value (e.g. a non-cloneable collection or a user type) cannot be generically copied,
+     *       so its reference is stored as a best effort.</li>
+     * </ul>
+     * Reuses the same immutability/clone detection as {@code @Immutable} rather than an ad-hoc list.
+     *
+     * @param type  the declared type of the value being snapshotted
+     * @param value the expression producing the value to snapshot
+     * @return the snapshot expression
+     */
+    public static Expression snapshotExpression(final ClassNode type, final Expression value) {
+        if (ImmutablePropertyUtils.isKnownImmutableType(type, List.of())) {
+            return value;
+        }
+        if (ImmutablePropertyUtils.implementsCloneable(type)) {
+            final MethodCallExpression clone = callX(value, "clone");
+            clone.setSafe(true); // leave a null value as null
+            return clone;
+        }
+        return value;
+    }
+
+    /**
+     * Reports whether the (wrapper) type participates in {@code old} snapshotting: the value-like JDK
+     * types ({@code java.lang} / {@code java.math} / {@code java.util} / {@code java.sql}), {@link String},
+     * {@link groovy.lang.GString} and the primitives.
+     */
+    private static boolean isOldSnapshotType(final ClassNode wrapperType) {
+        final String name = wrapperType.getName();
+        return name.startsWith("java.lang") // includes java.lang.String
+                || ClassHelper.isPrimitiveType(wrapperType)
+                || name.startsWith("java.math")
+                || name.startsWith("java.util")
+                || name.startsWith("java.sql")
+                || "groovy.lang.GString".equals(name);
+    }
+
+    /**
+     * Reports whether the (wrapper) type is {@link Cloneable} and exposes a {@code clone()} method, so its
+     * value can be defensively copied for the {@code old} map.
+     */
+    private static boolean isCloneable(final ClassNode wrapperType) {
+        return wrapperType.getMethod("clone", Parameter.EMPTY_ARRAY) != null
+                && wrapperType.implementsInterface(ClassHelper.make("java.lang.Cloneable"));
+    }
 }
diff --git a/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/generation/PostconditionGenerator.java b/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/generation/PostconditionGenerator.java
index 1863f85fca..a5d12be273 100644
--- a/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/generation/PostconditionGenerator.java
+++ b/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/generation/PostconditionGenerator.java
@@ -26,6 +26,7 @@ import org.codehaus.groovy.ast.ClassHelper;
 import org.codehaus.groovy.ast.ClassNode;
 import org.codehaus.groovy.ast.ConstructorNode;
 import org.codehaus.groovy.ast.MethodNode;
+import org.codehaus.groovy.ast.Parameter;
 import org.codehaus.groovy.ast.VariableScope;
 import org.codehaus.groovy.ast.expr.BooleanExpression;
 import org.codehaus.groovy.ast.expr.ConstantExpression;
@@ -35,17 +36,24 @@ import org.codehaus.groovy.ast.stmt.ReturnStatement;
 import org.codehaus.groovy.ast.stmt.Statement;
 import org.codehaus.groovy.control.io.ReaderSource;
 
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
+import static org.codehaus.groovy.ast.tools.GeneralUtils.args;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.assignS;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.block;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.boolX;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.callThisX;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.callX;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.constX;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.declS;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.ifS;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.localVarX;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.mapX;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.stmt;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.varX;
 import static org.codehaus.groovy.ast.ClassHelper.isPrimitiveVoid;
 
 /**
@@ -141,29 +149,64 @@ public class PostconditionGenerator extends BaseGenerator {
                     localPostconditionBlockStatement.getStatements().add(0, declS(result, returnStatement.getExpression()));
                     AssertStatementCreationUtility.injectResultVariableReturnStatementAndAssertionCallStatement(block, method.getReturnType().redirect(), returnStatement, localPostconditionBlockStatement);
                 }
-                setOldVariablesIfEnabled(block, contractsEnabled, method.isStatic());
+                setOldVariablesIfEnabled(block, contractsEnabled, method);
 
             } else if (method instanceof ConstructorNode) {
                 block.addStatements(postconditionBlockStatement.getStatements());
             } else {
-                setOldVariablesIfEnabled(block, contractsEnabled, method.isStatic());
+                setOldVariablesIfEnabled(block, contractsEnabled, method);
                 block.addStatements(postconditionBlockStatement.getStatements());
             }
             method.putNodeMetaData(METHOD_PROCESSED, true);
         }
     }
 
-    private void setOldVariablesIfEnabled(BlockStatement block, Expression contractsEnabled, boolean isStatic) {
+    private void setOldVariablesIfEnabled(BlockStatement block, Expression contractsEnabled, MethodNode method) {
         final Expression oldVariableExpression = localVarX("old", new ClassNode(Map.class));
-        if (isStatic) {
+        if (method.isStatic()) {
             // static methods have no instance state to snapshot; the synthetic old-variables method is an
-            // instance method, so 'old' is simply an empty map (references to it are rejected at compile time)
+            // instance method, so 'old' starts as an empty map. GROOVY-12078: it is still populated with
+            // the entry values of any parameters referenced via old.<name> (field references stay rejected)
             block.getStatements().add(0, declS(oldVariableExpression, mapX()));
+            List<Statement> parameterSnapshots = parameterSnapshotStatements(method);
+            if (!parameterSnapshots.isEmpty()) {
+                block.getStatements().add(1, ifS(boolX(contractsEnabled), block(new VariableScope(), parameterSnapshots)));
+            }
             return;
         }
-        // Assign the return statement expression to a local variable: Map old
-        Statement oldVariableStatement = assignS(oldVariableExpression, callThisX(OldVariableGenerationUtility.OLD_VARIABLES_METHOD));
+        // Snapshot instance field state into a local variable: Map old = $_gc_computeOldVariables()
+        final List<Statement> oldSetup = new ArrayList<>();
+        oldSetup.add(assignS(oldVariableExpression, callThisX(OldVariableGenerationUtility.OLD_VARIABLES_METHOD)));
+        // GROOVY-12078: also snapshot the entry values of parameters referenced via old.<name>, so a
+        // postcondition can compare against an argument that the method body reassigns
+        oldSetup.addAll(parameterSnapshotStatements(method));
+
         block.getStatements().add(0, declS(oldVariableExpression, ConstantExpression.NULL));
-        block.getStatements().add(1, ifS(boolX(contractsEnabled), block(oldVariableStatement)));
+        block.getStatements().add(1, ifS(boolX(contractsEnabled), block(new VariableScope(), oldSetup)));
+    }
+
+    /**
+     * Builds {@code old.put('name', name)} statements for each method parameter referenced via
+     * {@code old.<name>} in the postcondition. Parameters are snapshotted after the field state so a
+     * parameter shadowing a field name wins, mirroring lexical scoping. {@code final} (and {@code val})
+     * parameters are snapshotted too: {@code final} prevents reassignment but not in-place mutation, so
+     * {@code old.<name>} must still capture the entry state.
+     *
+     * @param method the method whose postcondition is being generated
+     * @return the list of snapshot statements (possibly empty)
+     */
+    private static List<Statement> parameterSnapshotStatements(final MethodNode method) {
+        final Set<String> oldReferences = method.getNodeMetaData(AnnotationClosureVisitor.OLD_REFERENCES_KEY);
+        if (oldReferences == null || oldReferences.isEmpty()) return List.of();
+
+        final List<Statement> statements = new ArrayList<>();
+        for (Parameter parameter : method.getParameters()) {
+            if (!oldReferences.contains(parameter.getName())) continue;
+            // old.put('name', name) - snapshotExpression defensively copies a mutable cloneable value,
+            // but stores an immutable (or otherwise uncopyable) value by reference
+            Expression snapshot = OldVariableGenerationUtility.snapshotExpression(parameter.getType(), varX(parameter));
+            statements.add(stmt(callX(varX("old"), "put", args(constX(parameter.getName()), snapshot))));
+        }
+        return statements;
     }
 }
diff --git a/subprojects/groovy-contracts/src/test/groovy/org/apache/groovy/contracts/tests/other/ClosureExpressionValidationTests.groovy b/subprojects/groovy-contracts/src/test/groovy/org/apache/groovy/contracts/tests/other/ClosureExpressionValidationTests.groovy
index cf9464d640..094446b199 100644
--- a/subprojects/groovy-contracts/src/test/groovy/org/apache/groovy/contracts/tests/other/ClosureExpressionValidationTests.groovy
+++ b/subprojects/groovy-contracts/src/test/groovy/org/apache/groovy/contracts/tests/other/ClosureExpressionValidationTests.groovy
@@ -264,8 +264,8 @@ class ClosureExpressionValidationTests extends GroovyShellTestCase {
         assertTrue msg.contains("State changing postfix & prefix operators are not supported.")
     }
 
-    // GROOVY-12052
-    void testOldNotSupportedInStaticPostcondition() {
+    // GROOVY-12052, GROOVY-12078: a static method has no instance state, so old may only reference a parameter
+    void testOldFieldNotSupportedInStaticPostcondition() {
 
         def msg = shouldFail CompilationFailedException, {
             evaluate """
@@ -273,7 +273,9 @@ class ClosureExpressionValidationTests extends GroovyShellTestCase {
 
                     class A {
 
-                        @Ensures({ old.value == result })
+                        private static int field = 1
+
+                        @Ensures({ old.field == result })
                         static int op(int value) { value }
                     }
 
@@ -281,6 +283,6 @@ class ClosureExpressionValidationTests extends GroovyShellTestCase {
                 """
         }
 
-        assertTrue msg.contains("'old' is not supported in postconditions of static methods")
+        assertTrue msg.contains("'old' in a postcondition of a static method may only reference a method parameter")
     }
 }
diff --git a/subprojects/groovy-contracts/src/test/groovy/org/apache/groovy/contracts/tests/post/ModifiesTests.groovy b/subprojects/groovy-contracts/src/test/groovy/org/apache/groovy/contracts/tests/post/ModifiesTests.groovy
index 5eed2df0db..142c933212 100644
--- a/subprojects/groovy-contracts/src/test/groovy/org/apache/groovy/contracts/tests/post/ModifiesTests.groovy
+++ b/subprojects/groovy-contracts/src/test/groovy/org/apache/groovy/contracts/tests/post/ModifiesTests.groovy
@@ -122,6 +122,27 @@ class ModifiesTests extends BaseTestClass {
         assert instance.count == 1
     }
 
+    @Test
+    void modifies_with_ensures_old_parameter_reference_is_exempt() {
+        // GROOVY-12078: @Modifies is a frame condition over fields, so old.<parameter> must not be
+        // required to appear in the @Modifies declaration
+        def instance = create_instance_of('''
+            import groovy.contracts.*
+
+            class A {
+                int count = 0
+
+                @Modifies({ this.count })
+                @Ensures({ count == old.count + amount && old.amount == amount })
+                void add(int amount) {
+                    count += amount
+                }
+            }
+        ''')
+        instance.add(5)
+        assert instance.count == 5
+    }
+
     @Test
     void modifies_with_ensures_invalid_old_reference_causes_compile_error() {
         shouldFail MultipleCompilationErrorsException, {
diff --git a/subprojects/groovy-contracts/src/test/groovy/org/apache/groovy/contracts/tests/post/OldParameterPostconditionTests.groovy b/subprojects/groovy-contracts/src/test/groovy/org/apache/groovy/contracts/tests/post/OldParameterPostconditionTests.groovy
new file mode 100644
index 0000000000..1acb5ea485
--- /dev/null
+++ b/subprojects/groovy-contracts/src/test/groovy/org/apache/groovy/contracts/tests/post/OldParameterPostconditionTests.groovy
@@ -0,0 +1,230 @@
+/*
+ *  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.apache.groovy.contracts.tests.post
+
+import org.apache.groovy.contracts.PostconditionViolation
+import org.apache.groovy.contracts.tests.basic.BaseTestClass
+import org.junit.jupiter.api.Test
+
+/**
+ * GROOVY-12078: {@code old.<name>} should snapshot the entry value of a (non-{@code final})
+ * method parameter, so a postcondition can compare against an argument that the body reassigns.
+ *
+ * @see groovy.contracts.Ensures
+ */
+final class OldParameterPostconditionTests extends BaseTestClass {
+
+    @Test
+    void swap_compares_result_against_old_parameter_values() {
+        def instance = create_instance_of '''
+            package tests
+
+            import groovy.contracts.*
+
+            class Swapper {
+                @Ensures({ result.a == old.b && result.b == old.a })
+                Map<String, Integer> swap(int a, int b) {
+                    (a, b) = [b, a]
+                    [a: a, b: b]
+                }
+            }
+        '''
+        assert instance.swap(1, 3) == [a: 3, b: 1]
+    }
+
+    @Test
+    void type_checked_swap() {
+        def instance = create_instance_of '''
+            package tests
+
+            import groovy.contracts.*
+
+            class Swapper {
+                @groovy.transform.TypeChecked
+                @Ensures({ result.a == old.b && result.b == old.a })
+                Map<String, Integer> swap(int a, int b) {
+                    (a, b) = [b, a]
+                    [a: a, b: b]
+                }
+            }
+        '''
+        assert instance.swap(1, 3) == [a: 3, b: 1]
+    }
+
+    @Test
+    void old_holds_the_entry_value_not_the_reassigned_value() {
+        def instance = create_instance_of '''
+            package tests
+
+            import groovy.contracts.*
+
+            class Swapper {
+                @Ensures({ old.a == 1 && old.b == 3 })
+                Map<String, Integer> swap(int a, int b) {
+                    (a, b) = [b, a]
+                    [a: a, b: b]
+                }
+            }
+        '''
+        assert instance.swap(1, 3) == [a: 3, b: 1]
+    }
+
+    @Test
+    void static_method_snapshots_old_parameter_value() {
+        def instance = create_instance_of '''
+            package tests
+
+            import groovy.contracts.*
+
+            class Swapper {
+                @Ensures({ result.a == old.b && result.b == old.a })
+                static Map<String, Integer> swap(int a, int b) {
+                    (a, b) = [b, a]
+                    [a: a, b: b]
+                }
+            }
+        '''
+        assert instance.swap(1, 3) == [a: 3, b: 1]
+    }
+
+    @Test
+    void old_parameter_is_typed_under_type_checking() {
+        // old.s.length() only resolves under @TypeChecked if old.s is typed as the parameter's type
+        def instance = create_instance_of '''
+            package tests
+
+            import groovy.contracts.*
+
+            class A {
+                @groovy.transform.TypeChecked
+                @Ensures({ old.s.length() == result })
+                int truncatedLength(String s) {
+                    s = s + '!'
+                    s.length() - 1
+                }
+            }
+        '''
+        assert instance.truncatedLength('abc') == 3
+    }
+
+    @Test
+    void mutable_cloneable_parameter_is_defensively_copied() {
+        def instance = create_instance_of '''
+            package tests
+
+            import groovy.contracts.*
+
+            class Mutator {
+                @Ensures({ old.list == [1, 2] && list == [1, 2, 3] })
+                ArrayList grow(ArrayList list) {
+                    list.add(3)
+                    list
+                }
+            }
+        '''
+        def list = [1, 2]
+        assert instance.grow(list) == [1, 2, 3]
+    }
+
+    @Test
+    void final_parameter_is_still_snapshotted() {
+        // 'final' prevents reassignment but not in-place mutation, so old.<name> must still capture
+        // the entry state (defensively copied for a cloneable type)
+        def instance = create_instance_of '''
+            package tests
+
+            import groovy.contracts.*
+
+            class Mutator {
+                @Ensures({ old.list == [1, 2] && list == [1, 2, 3] })
+                ArrayList grow(final ArrayList list) {
+                    list.add(3)
+                    list
+                }
+            }
+        '''
+        assert instance.grow([1, 2]) == [1, 2, 3]
+    }
+
+    @Test
+    void val_parameter_is_snapshotted() {
+        // 'val' is an alias of 'final' (both set ACC_FINAL), so it is snapshotted the same way
+        def instance = create_instance_of '''
+            package tests
+
+            import groovy.contracts.*
+
+            class A {
+                @Ensures({ old.x == 1 && result == 2 })
+                def inc(val x) {
+                    x + 1
+                }
+            }
+        '''
+        assert instance.inc(1) == 2
+    }
+
+    @Test
+    void user_defined_cloneable_parameter_is_defensively_copied() {
+        // a Cloneable type outside the JDK is detected via ImmutablePropertyUtils, so its snapshot is
+        // a real defensive copy even though its package is not one of the java.* value-like prefixes
+        def instance = create_instance_of '''
+            package tests
+
+            import groovy.contracts.*
+
+            class A {
+                Box makeBox(int v) { new Box(v) }
+
+                @Ensures({ old.box.v == 1 && box.v == 2 })
+                Box bump(Box box) {
+                    box.v = 2
+                    box
+                }
+            }
+
+            class Box implements Cloneable {
+                int v
+                Box(int v) { this.v = v }
+                @Override Object clone() { new Box(v) }
+            }
+        '''
+        instance.bump(instance.makeBox(1))
+    }
+
+    @Test
+    void violated_postcondition_reports_old_parameter_value() {
+        def instance = create_instance_of '''
+            package tests
+
+            import groovy.contracts.*
+
+            class Swapper {
+                @Ensures({ result.a == old.a })
+                Map<String, Integer> swap(int a, int b) {
+                    (a, b) = [b, a]
+                    [a: a, b: b]
+                }
+            }
+        '''
+        shouldFail(PostconditionViolation) {
+            instance.swap(1, 3)
+        }
+    }
+}