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

Reply via email to