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)
+ }
+ }
+}