This is an automated email from the ASF dual-hosted git repository.
paulk 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 6f717acaea GROOVY-11910: Add ModifiesChecker type checking extension
to verify @Modifies frame conditions
6f717acaea is described below
commit 6f717acaea37d9838210c3b40f4cab377e0bf3d8
Author: Paul King <[email protected]>
AuthorDate: Tue Apr 7 11:38:21 2026 +1000
GROOVY-11910: Add ModifiesChecker type checking extension to verify
@Modifies frame conditions
---
.../traitx/TraitASTTransformationTest.groovy | 19 ++
.../contracts/ast/ModifiesASTTransformation.java | 5 +
.../src/spec/doc/contracts-userguide.adoc | 15 ++
subprojects/groovy-typecheckers/build.gradle | 1 +
.../groovy/typecheckers/ModifiesChecker.groovy | 242 ++++++++++++++++++
.../src/spec/doc/typecheckers.adoc | 107 ++++++++
.../groovy/typecheckers/ModifiesCheckerTest.groovy | 270 +++++++++++++++++++++
7 files changed, 659 insertions(+)
diff --git
a/src/test/groovy/org/codehaus/groovy/transform/traitx/TraitASTTransformationTest.groovy
b/src/test/groovy/org/codehaus/groovy/transform/traitx/TraitASTTransformationTest.groovy
index bcb96bb858..8715b32fec 100644
---
a/src/test/groovy/org/codehaus/groovy/transform/traitx/TraitASTTransformationTest.groovy
+++
b/src/test/groovy/org/codehaus/groovy/transform/traitx/TraitASTTransformationTest.groovy
@@ -4084,4 +4084,23 @@ final class TraitASTTransformationTest {
new C().test()
'''
}
+
+ // GROOVY-11XXX
+ @Test
+ void testTraitWithStaticFieldAndSpockSpecification() {
+ assertScript shell, '''
+ @Grab('org.spockframework:spock-core:2.4-groovy-5.0')
+ @GrabExclude('org.apache.groovy:*')
+ import spock.lang.Specification
+
+ @CompileStatic
+ trait MyTrait {
+ static String myStaticField
+ }
+
+ @CompileStatic
+ abstract class MySpec extends Specification implements MyTrait {
+ }
+ '''
+ }
}
diff --git
a/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/ModifiesASTTransformation.java
b/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/ModifiesASTTransformation.java
index 45b1f1984c..3734e9be3f 100644
---
a/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/ModifiesASTTransformation.java
+++
b/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/ModifiesASTTransformation.java
@@ -20,6 +20,7 @@ package org.apache.groovy.contracts.ast;
import org.codehaus.groovy.ast.ASTNode;
import org.codehaus.groovy.ast.AnnotationNode;
+import org.codehaus.groovy.ast.ClassHelper;
import org.codehaus.groovy.ast.ClassNode;
import org.codehaus.groovy.ast.MethodNode;
import org.codehaus.groovy.ast.Parameter;
@@ -75,6 +76,10 @@ public class ModifiesASTTransformation implements
ASTTransformation {
extractFromAnnotation(annotation, methodNode, modifiesSet, source);
+ // Replace the closure with a class reference so the type checker
+ // doesn't try to type-check the specification closure
+ annotation.setMember("value", new
org.codehaus.groovy.ast.expr.ClassExpression(ClassHelper.OBJECT_TYPE));
+
if (!modifiesSet.isEmpty()) {
methodNode.putNodeMetaData(MODIFIES_FIELDS_KEY, modifiesSet);
}
diff --git a/subprojects/groovy-contracts/src/spec/doc/contracts-userguide.adoc
b/subprojects/groovy-contracts/src/spec/doc/contracts-userguide.adoc
index e69431171e..dee4c8934e 100644
--- a/subprojects/groovy-contracts/src/spec/doc/contracts-userguide.adoc
+++ b/subprojects/groovy-contracts/src/spec/doc/contracts-userguide.adoc
@@ -231,3 +231,18 @@ The `@Modifies` annotation validates at compile time that:
* Parameter references use the bare parameter name.
* The closure is not executed at runtime — it is only analyzed at compile time.
+=== Verifying method bodies with ModifiesChecker
+
+While `@Modifies` alone is a compile-time-checked specification (validating
field/parameter
+existence and `@Ensures` consistency), it does not verify the method body
itself. For that,
+the `groovy-typecheckers` module provides the `ModifiesChecker` type checking
extension:
+
+[source,groovy]
+----
+@TypeChecked(extensions = 'groovy.typecheckers.ModifiesChecker')
+----
+
+When enabled, the checker verifies that method bodies only modify fields
declared in
+`@Modifies`, and that method calls on non-modifiable receivers use only
non-mutating
+operations. See the _Type Checkers_ documentation for details.
+
diff --git a/subprojects/groovy-typecheckers/build.gradle
b/subprojects/groovy-typecheckers/build.gradle
index 051d9f49b6..68ca487d6d 100644
--- a/subprojects/groovy-typecheckers/build.gradle
+++ b/subprojects/groovy-typecheckers/build.gradle
@@ -24,6 +24,7 @@ dependencies {
implementation rootProject
api projects.groovyMacro
testImplementation projects.groovyTest
+ testImplementation projects.groovyContracts
testImplementation 'org.jspecify:jspecify:1.0.0'
}
diff --git
a/subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/ModifiesChecker.groovy
b/subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/ModifiesChecker.groovy
new file mode 100644
index 0000000000..9e62b5f7c9
--- /dev/null
+++
b/subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/ModifiesChecker.groovy
@@ -0,0 +1,242 @@
+/*
+ * 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 groovy.typecheckers
+
+import org.apache.groovy.ast.tools.ImmutablePropertyUtils
+import org.apache.groovy.lang.annotation.Incubating
+import org.apache.groovy.typecheckers.CheckingVisitor
+import org.codehaus.groovy.ast.ClassNode
+import org.codehaus.groovy.ast.FieldNode
+import org.codehaus.groovy.ast.MethodNode
+import org.codehaus.groovy.ast.Variable
+import org.codehaus.groovy.ast.expr.BinaryExpression
+import org.codehaus.groovy.ast.expr.DeclarationExpression
+import org.codehaus.groovy.ast.expr.Expression
+import org.codehaus.groovy.ast.expr.MethodCallExpression
+import org.codehaus.groovy.ast.expr.PostfixExpression
+import org.codehaus.groovy.ast.expr.PrefixExpression
+import org.codehaus.groovy.ast.expr.PropertyExpression
+import org.codehaus.groovy.ast.expr.StaticMethodCallExpression
+import org.codehaus.groovy.ast.expr.VariableExpression
+import org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport
+import org.codehaus.groovy.transform.stc.StaticTypesMarker
+
+import static org.codehaus.groovy.syntax.Types.isAssignment
+
+/**
+ * A compile-time checker that verifies method bodies comply with their
+ * {@code @Modifies} frame condition declarations. Checks that:
+ * <ul>
+ * <li>Direct field assignments only target fields listed in {@code
@Modifies}</li>
+ * <li>Calls to methods on {@code this} are compatible with the declared
frame</li>
+ * <li>Calls on parameters/variables not in {@code @Modifies} use only
non-mutating methods</li>
+ * </ul>
+ * <p>
+ * Non-mutating calls are determined by:
+ * <ol>
+ * <li>Receiver type is immutable (String, Integer, etc.)</li>
+ * <li>Method is annotated {@code @Pure}</li>
+ * <li>Method name is in a known-safe whitelist (toString, size, get,
etc.)</li>
+ * </ol>
+ * <p>
+ * This checker is opt-in:
+ * <pre>
+ * {@code @TypeChecked(extensions = 'groovy.typecheckers.ModifiesChecker')}
+ * </pre>
+ *
+ * @since 6.0.0
+ * @see groovy.contracts.Modifies
+ */
+@Incubating
+class ModifiesChecker extends
GroovyTypeCheckingExtensionSupport.TypeCheckingDSL {
+
+ // Methods known to be non-mutating on common mutable types
+ private static final Set<String> SAFE_METHOD_NAMES = Set.of(
+ // Object fundamentals
+ 'toString', 'hashCode', 'equals', 'compareTo', 'getClass',
+ // Collection/Map queries
+ 'size', 'length', 'isEmpty', 'contains', 'containsKey',
'containsValue',
+ 'get', 'getAt', 'getOrDefault', 'indexOf', 'lastIndexOf',
+ 'iterator', 'listIterator', 'spliterator',
+ 'stream', 'parallelStream',
+ 'toArray', 'toList', 'toSet', 'toSorted', 'toUnique',
+ 'subList', 'headSet', 'tailSet', 'subSet',
+ 'keySet', 'values', 'entrySet',
+ 'first', 'last', 'head', 'tail', 'init',
+ 'find', 'findAll', 'collect', 'collectEntries',
+ 'any', 'every', 'count', 'sum', 'min', 'max',
+ 'join', 'inject', 'groupBy', 'countBy',
+ 'each', 'eachWithIndex', 'reverseEach',
+ // String queries
+ 'charAt', 'substring', 'trim', 'strip', 'toLowerCase',
'toUpperCase',
+ 'startsWith', 'endsWith', 'matches', 'split',
+ // Array queries
+ 'clone'
+ )
+
+ private static final Set<String> PURE_ANNOS = Set.of('Pure')
+
+ @Override
+ Object run() {
+ afterVisitMethod { MethodNode mn ->
+ // Key matches ModifiesASTTransformation.MODIFIES_FIELDS_KEY — no
hard dependency on groovy-contracts
+ Set<String> modifiesSet =
mn.getNodeMetaData('groovy.contracts.modifiesFields') as Set<String>
+ if (modifiesSet == null) return // no @Modifies on this method —
nothing to check
+
+ mn.code?.visit(makeVisitor(modifiesSet, mn))
+ }
+ }
+
+ private CheckingVisitor makeVisitor(Set<String> modifiesSet, MethodNode
methodNode) {
+ Set<String> paramNames = methodNode.parameters*.name as Set<String>
+
+ new CheckingVisitor() {
+
+ @Override
+ void visitDeclarationExpression(DeclarationExpression decl) {
+ super.visitDeclarationExpression(decl)
+ // Local variable declarations are always fine
+ }
+
+ @Override
+ void visitBinaryExpression(BinaryExpression expression) {
+ super.visitBinaryExpression(expression)
+ if (isAssignment(expression.operation.type)) {
+ checkWriteTarget(expression.leftExpression, expression)
+ }
+ }
+
+ @Override
+ void visitPostfixExpression(PostfixExpression expression) {
+ super.visitPostfixExpression(expression)
+ checkWriteTarget(expression.expression, expression)
+ }
+
+ @Override
+ void visitPrefixExpression(PrefixExpression expression) {
+ super.visitPrefixExpression(expression)
+ checkWriteTarget(expression.expression, expression)
+ }
+
+ @Override
+ void visitMethodCallExpression(MethodCallExpression call) {
+ super.visitMethodCallExpression(call)
+ checkMethodCall(call.objectExpression, call)
+ }
+
+ // ---- helpers ----
+
+ private void checkWriteTarget(Expression target, Expression
context) {
+ if (target instanceof PropertyExpression) {
+ def objExpr = target.objectExpression
+ if (objExpr instanceof VariableExpression &&
objExpr.isThisExpression()) {
+ String fieldName = target.propertyAsString
+ if (fieldName && !modifiesSet.contains(fieldName)) {
+ addStaticTypeError("@Modifies violation:
assignment to 'this.${fieldName}' but '${fieldName}' is not declared in
@Modifies", context)
+ }
+ }
+ } else if (target instanceof VariableExpression) {
+ // Check if this is actually a field (implicit this)
+ Variable accessedVar = findTargetVariable(target)
+ if (accessedVar instanceof FieldNode) {
+ String fieldName = accessedVar.name
+ if (!modifiesSet.contains(fieldName)) {
+ addStaticTypeError("@Modifies violation:
assignment to '${fieldName}' but '${fieldName}' is not declared in @Modifies",
context)
+ }
+ }
+ // Local variables and parameters being reassigned are fine
+ }
+ }
+
+ private void checkMethodCall(Expression receiver,
MethodCallExpression call) {
+ if (receiver instanceof VariableExpression &&
receiver.isThisExpression()) {
+ checkCallOnThis(call)
+ } else if (receiver instanceof VariableExpression || receiver
instanceof PropertyExpression) {
+ checkCallOnVariable(receiver, call)
+ }
+ }
+
+ private void checkCallOnThis(MethodCallExpression call) {
+ // Check if the callee method has @Modifies
+ def targetMethod =
call.getNodeMetaData(StaticTypesMarker.DIRECT_METHOD_CALL_TARGET)
+ if (!(targetMethod instanceof MethodNode)) return
+
+ Set<String> calleeModifies =
targetMethod.getNodeMetaData('groovy.contracts.modifiesFields') as Set<String>
+
+ if (calleeModifies != null) {
+ // Callee has @Modifies — check it's a subset of our frame
+ for (String field : calleeModifies) {
+ if (!modifiesSet.contains(field)) {
+ addStaticTypeError("@Modifies violation: call to
'${targetMethod.name}()' modifies '${field}' which is not in this method's
@Modifies", call)
+ }
+ }
+ } else if (hasPureAnno(targetMethod)) {
+ // @Pure methods are safe
+ } else if (SAFE_METHOD_NAMES.contains(call.methodAsString)) {
+ // Known-safe method name
+ } else {
+ // Unknown effects — warn
+ addStaticTypeError("@Modifies warning: call to
'this.${call.methodAsString}()' has unknown effects (consider adding @Modifies
or @Pure)", call)
+ }
+ }
+
+ private void checkCallOnVariable(Expression receiver,
MethodCallExpression call) {
+ String receiverName = resolveReceiverName(receiver)
+
+ // If receiver is in the modifies set, any call is allowed
+ if (receiverName && modifiesSet.contains(receiverName)) return
+
+ // Check if the method is known to be safe
+ def targetMethod =
call.getNodeMetaData(StaticTypesMarker.DIRECT_METHOD_CALL_TARGET)
+
+ // Immutable receiver type — all calls safe
+ ClassNode receiverType = getType(receiver)
+ if (receiverType &&
ImmutablePropertyUtils.isBuiltinImmutable(receiverType.name)) return
+
+ // @Pure method
+ if (targetMethod instanceof MethodNode &&
hasPureAnno(targetMethod)) return
+
+ // Known-safe method name
+ if (SAFE_METHOD_NAMES.contains(call.methodAsString)) return
+
+ // Unknown — warn
+ if (receiverName) {
+ addStaticTypeError("@Modifies warning: call to
'${receiverName}.${call.methodAsString}()' may modify '${receiverName}' which
is not in @Modifies", call)
+ }
+ }
+
+ private String resolveReceiverName(Expression receiver) {
+ if (receiver instanceof VariableExpression) {
+ return receiver.name
+ }
+ if (receiver instanceof PropertyExpression) {
+ def obj = receiver.objectExpression
+ if (obj instanceof VariableExpression &&
obj.isThisExpression()) {
+ return receiver.propertyAsString
+ }
+ }
+ null
+ }
+
+ private static boolean hasPureAnno(MethodNode method) {
+ method.annotations?.any { it.classNode?.nameWithoutPackage in
PURE_ANNOS } ?: false
+ }
+ }
+ }
+}
diff --git a/subprojects/groovy-typecheckers/src/spec/doc/typecheckers.adoc
b/subprojects/groovy-typecheckers/src/spec/doc/typecheckers.adoc
index 30791d7b2e..c508af2bb7 100644
--- a/subprojects/groovy-typecheckers/src/spec/doc/typecheckers.adoc
+++ b/subprojects/groovy-typecheckers/src/spec/doc/typecheckers.adoc
@@ -666,3 +666,110 @@ The complete list of errors detected include:
NOTE: For complementary null-related checks such as detecting broken
null-check logic,
unnecessary null guards before `instanceof`, or `Boolean` methods returning
`null`,
consider using https://codenarc.org/[CodeNarc]'s null-related rules alongside
these type checkers.
+
+== Checking @Modifies Frame Conditions (Incubating)
+
+The `ModifiesChecker` verifies that method bodies comply with their
`@Modifies` frame
+condition declarations from the `groovy-contracts` module. It checks that
methods only
+modify the fields and parameters they declare, helping both humans and AI
reason about
+what a method changes -- and crucially, what it does not.
+
+This checker is opt-in and only activates for methods annotated with
`@Modifies`:
+
+[source,groovy]
+----
+@TypeChecked(extensions = 'groovy.typecheckers.ModifiesChecker')
+class InventoryItem {
+ int quantity = 0
+ int reserved = 0
+
+ @Modifies({ [this.quantity, this.reserved] })
+ void restock(int amount) {
+ quantity += amount // OK: quantity is declared
+ reserved = 0 // OK: reserved is declared
+ }
+}
+----
+
+=== What the checker verifies
+
+==== Direct field assignments
+
+Assignments to fields (via `this.field = ...`, `field = ...`, `field++`,
`field += ...`)
+must target fields listed in `@Modifies`. Local variable assignments are
always allowed.
+
+[source,groovy]
+----
+@Modifies({ this.count })
+void increment() {
+ count++ // OK: count is declared
+ def temp = count // OK: local variable
+}
+
+@Modifies({ this.count })
+void broken() {
+ other = 5 // ERROR: 'other' not declared in @Modifies
+}
+----
+
+==== Method calls on `this`
+
+When a method calls another method on `this`, the checker verifies
compatibility:
+
+* If the callee has `@Modifies`, its frame must be a subset of the caller's
frame.
+* If the callee is annotated `@Pure`, the call is always safe.
+* If the callee's name is in a known-safe list (e.g., `toString`, `hashCode`),
the call is allowed.
+* Otherwise, a warning is produced.
+
+[source,groovy]
+----
+@Modifies({ [this.items, this.count] })
+void addItem(String item) {
+ doAdd(item) // OK if doAdd has compatible @Modifies
+ def n = currentCount() // OK if currentCount() is @Pure
+}
+----
+
+==== Method calls on parameters and variables
+
+When a method calls a method on a variable or parameter:
+
+* If the receiver is in the `@Modifies` set, any call is allowed (the contract
declares
+ the caller may modify it).
+* If the receiver is *not* in the `@Modifies` set, the checker determines
whether the
+ call is non-mutating using a layered approach:
+ 1. *Immutable receiver type*: calls on `String`, `Integer`, `BigDecimal`,
`LocalDate`, etc.
+ are always safe.
+ 2. *`@Pure` method*: if the called method is annotated `@Pure`, it is safe.
+ 3. *Known-safe method name*: a curated whitelist of non-mutating method
names including
+ `size`, `get`, `contains`, `isEmpty`, `toString`, `iterator`, `stream`,
`toList`, etc.
+ 4. *Unknown*: a warning is produced suggesting the method may modify the
receiver.
+
+[source,groovy]
+----
+@Modifies({ this.count })
+void countItems(List items) {
+ count = items.size() // OK: size() is known-safe
+ count = items.toString().length() // OK: String is immutable
+}
+
+@Modifies({ list1 })
+void process(List list1, List list2) {
+ list1.add('x') // OK: list1 is in @Modifies
+ list2.size() // OK: size() is known-safe
+ list2.clear() // WARNING: clear() may modify list2
+}
+----
+
+=== Interaction with other contract annotations
+
+The `ModifiesChecker` works alongside the other design-by-contract annotations
to provide
+a complete specification for modular reasoning:
+
+* `@Requires` declares what must be true before a call
+* `@Ensures` declares what is guaranteed after a call
+* `@Modifies` declares what may change (and implicitly, what does not)
+* `@Pure` declares a method has no side effects
+
+Together, these annotations allow analysis of method call sequences without
reading
+method bodies -- each method becomes a self-contained specification.
diff --git
a/subprojects/groovy-typecheckers/src/test/groovy/groovy/typecheckers/ModifiesCheckerTest.groovy
b/subprojects/groovy-typecheckers/src/test/groovy/groovy/typecheckers/ModifiesCheckerTest.groovy
new file mode 100644
index 0000000000..749bd84ba8
--- /dev/null
+++
b/subprojects/groovy-typecheckers/src/test/groovy/groovy/typecheckers/ModifiesCheckerTest.groovy
@@ -0,0 +1,270 @@
+/*
+ * 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 groovy.typecheckers
+
+import org.codehaus.groovy.control.CompilerConfiguration
+import org.codehaus.groovy.control.customizers.ASTTransformationCustomizer
+import org.junit.jupiter.api.BeforeAll
+import org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.assertScript
+import static groovy.test.GroovyAssert.shouldFail
+
+final class ModifiesCheckerTest {
+
+ private static GroovyShell shell
+
+ @BeforeAll
+ static void setUp() {
+ shell = new GroovyShell(new CompilerConfiguration().tap {
+ def customizer = new
ASTTransformationCustomizer(groovy.transform.TypeChecked)
+ customizer.annotationParameters = [extensions:
'groovy.typecheckers.ModifiesChecker']
+ addCompilationCustomizers(customizer)
+ })
+ }
+
+ // === Direct field assignments ===
+
+ @Test
+ void assignment_to_declared_field_passes() {
+ assertScript shell, '''
+ import groovy.contracts.*
+
+ class A {
+ int count = 0
+
+ @Modifies({ this.count })
+ void increment() {
+ count++
+ }
+ }
+ def a = new A()
+ a.increment()
+ assert a.count == 1
+ '''
+ }
+
+ @Test
+ void assignment_to_undeclared_field_fails() {
+ def err = shouldFail shell, '''
+ import groovy.contracts.*
+
+ class A {
+ int count = 0
+ int other = 0
+
+ @Modifies({ this.count })
+ void broken() {
+ other = 5
+ }
+ }
+ '''
+ assert err.message.contains('@Modifies violation')
+ assert err.message.contains('other')
+ }
+
+ @Test
+ void explicit_this_assignment_to_undeclared_field_fails() {
+ def err = shouldFail shell, '''
+ import groovy.contracts.*
+
+ class A {
+ int count = 0
+ int other = 0
+
+ @Modifies({ this.count })
+ void broken() {
+ this.other = 5
+ }
+ }
+ '''
+ assert err.message.contains('@Modifies violation')
+ assert err.message.contains('other')
+ }
+
+ @Test
+ void local_variable_assignment_always_ok() {
+ assertScript shell, '''
+ import groovy.contracts.*
+
+ class A {
+ int count = 0
+
+ @Modifies({ this.count })
+ void increment() {
+ def temp = count + 1
+ count = temp
+ }
+ }
+ def a = new A()
+ a.increment()
+ assert a.count == 1
+ '''
+ }
+
+ // === Method calls on parameters ===
+
+ @Test
+ void mutating_call_on_modifiable_param_passes() {
+ assertScript shell, '''
+ import groovy.contracts.*
+
+ class A {
+ @Modifies({ items })
+ void addItem(List items, String item) {
+ items.add(item)
+ }
+ }
+ def list = []
+ new A().addItem(list, 'hello')
+ assert list == ['hello']
+ '''
+ }
+
+ @Test
+ void non_mutating_call_on_non_modifiable_param_passes() {
+ assertScript shell, '''
+ import groovy.contracts.*
+
+ class A {
+ int count = 0
+
+ @Modifies({ this.count })
+ void countSize(List items) {
+ count = items.size()
+ }
+ }
+ def a = new A()
+ a.countSize([1, 2, 3])
+ assert a.count == 3
+ '''
+ }
+
+ @Test
+ void immutable_receiver_always_safe() {
+ assertScript shell, '''
+ import groovy.contracts.*
+
+ class A {
+ String result
+
+ @Modifies({ this.result })
+ void process(String input) {
+ result = input.toUpperCase()
+ }
+ }
+ def a = new A()
+ a.process('hello')
+ assert a.result == 'HELLO'
+ '''
+ }
+
+ @Test
+ void safe_method_name_on_non_modifiable_passes() {
+ assertScript shell, '''
+ import groovy.contracts.*
+
+ class A {
+ int count = 0
+
+ @Modifies({ this.count })
+ void countItems(List items) {
+ count = items.size()
+ def s = items.toString()
+ def has = items.contains('x')
+ def empty = items.isEmpty()
+ }
+ }
+ new A().countItems([])
+ '''
+ }
+
+ // === Calls on this ===
+
+ @Test
+ void call_to_pure_method_on_this_passes() {
+ assertScript shell, '''
+ import groovy.contracts.*
+ import groovy.transform.Pure
+
+ class A {
+ int count = 0
+
+ @Modifies({ this.count })
+ void increment() {
+ count = currentCount() + 1
+ }
+
+ @Pure
+ int currentCount() {
+ return count
+ }
+ }
+ def a = new A()
+ a.increment()
+ assert a.count == 1
+ '''
+ }
+
+ @Test
+ void call_to_method_with_compatible_modifies_passes() {
+ assertScript shell, '''
+ import groovy.contracts.*
+
+ class A {
+ List items = []
+ int count = 0
+
+ @Modifies({ [this.items, this.count] })
+ void addItem(String item) {
+ doAdd(item)
+ }
+
+ @Modifies({ [this.items, this.count] })
+ private void doAdd(String item) {
+ items.add(item)
+ count++
+ }
+ }
+ def a = new A()
+ a.addItem('hello')
+ assert a.count == 1
+ '''
+ }
+
+ // === No @Modifies — checker is silent ===
+
+ @Test
+ void no_modifies_annotation_no_checking() {
+ assertScript shell, '''
+ class A {
+ int count = 0
+ int other = 0
+
+ void doAnything() {
+ count = 1
+ other = 2
+ }
+ }
+ def a = new A()
+ a.doAnything()
+ assert a.count == 1
+ '''
+ }
+}