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

sunlan 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 9d63f1625b GROOVY-11878: Invariant loops spike (#2400)
9d63f1625b is described below

commit 9d63f1625b1a352c8e791e435004ba455e2b56c3
Author: Paul King <[email protected]>
AuthorDate: Sat Mar 28 07:20:20 2026 +1100

    GROOVY-11878: Invariant loops spike (#2400)
    
    * GROOVY-11878: Allow AST transforms to be applicable in more places, 
initially loop statements
    
    * GROOVY-11878: Allow AST transforms to be applicable in more places, 
initially loop statements (demo usage not in final form)
---
 src/antlr/GroovyParser.g4                          |   6 +-
 .../groovy/transform/ASTTestTransformation.groovy  |   2 +-
 src/main/java/groovy/transform/Parallel.java       |  41 ++++++
 .../apache/groovy/parser/antlr4/AstBuilder.java    |  12 +-
 .../groovy/parser/antlr4/SemanticPredicates.java   |  38 ++++-
 .../org/codehaus/groovy/ast/AnnotationNode.java    |   3 +
 .../groovy/ast/ClassCodeExpressionTransformer.java |   3 +
 .../groovy/ast/ClassCodeVisitorSupport.java        |  13 ++
 .../org/codehaus/groovy/ast/stmt/Statement.java    |  40 ++++++
 .../codehaus/groovy/control/ResolveVisitor.java    |  11 ++
 .../ASTTransformationCollectorCodeVisitor.java     |  12 ++
 .../groovy/transform/ASTTransformationVisitor.java |  15 ++
 .../transform/ParallelASTTransformation.java       | 109 ++++++++++++++
 src/test-resources/core/AnnotatedLoop_01.groovy    | 134 +++++++++++++++++
 src/test-resources/core/AnnotatedLoop_02x.groovy   | 117 +++++++++++++++
 .../groovy/parser/antlr4/GroovyParserTest.groovy   |   6 +
 .../src/main/java/groovy/contracts/Invariant.java  |  20 ++-
 .../groovy/contracts/LoopInvariantViolation.java   |  60 ++++++++
 .../ast/LoopInvariantASTTransformation.java        | 109 ++++++++++++++
 .../groovy/contracts/domain/LoopInvariant.java     |  44 ++++++
 .../src/spec/doc/contracts-userguide.adoc          |  47 +++++-
 .../src/spec/test/ContractsTest.groovy             |  46 ++++++
 .../contracts/tests/inv/LoopInvariantTests.groovy  | 159 +++++++++++++++++++++
 23 files changed, 1033 insertions(+), 14 deletions(-)

diff --git a/src/antlr/GroovyParser.g4 b/src/antlr/GroovyParser.g4
index 88e3b8b713..67da533f72 100644
--- a/src/antlr/GroovyParser.g4
+++ b/src/antlr/GroovyParser.g4
@@ -600,9 +600,9 @@ switchStatement
     ;
 
 loopStatement
-    :   FOR LPAREN forControl RPAREN nls statement                             
                               #forStmtAlt
-    |   WHILE expressionInPar nls statement                                    
                               #whileStmtAlt
-    |   DO nls statement nls WHILE expressionInPar                             
                               #doWhileStmtAlt
+    :   annotationsOpt FOR LPAREN forControl RPAREN nls statement              
                               #forStmtAlt
+    |   annotationsOpt WHILE expressionInPar nls statement                     
                               #whileStmtAlt
+    |   annotationsOpt DO nls statement nls WHILE expressionInPar              
                               #doWhileStmtAlt
     ;
 
 continueStatement
diff --git 
a/src/main/groovy/org/codehaus/groovy/transform/ASTTestTransformation.groovy 
b/src/main/groovy/org/codehaus/groovy/transform/ASTTestTransformation.groovy
index 897894bd17..e498a1b0d7 100644
--- a/src/main/groovy/org/codehaus/groovy/transform/ASTTestTransformation.groovy
+++ b/src/main/groovy/org/codehaus/groovy/transform/ASTTestTransformation.groovy
@@ -81,7 +81,7 @@ class ASTTestTransformation implements ASTTransformation, 
CompilationUnitAware {
         annotationNode.setNodeMetaData(ASTTestTransformation, member)
         annotationNode.setMember('value', new ClosureExpression(
             Parameter.EMPTY_ARRAY, EmptyStatement.INSTANCE))
-        member.variableScope.@parent = null
+        member.variableScope?.@parent = null
 
         ISourceUnitOperation astTester = new ASTTester(astNode: nodes[1], 
sourceUnit: source, testClosure: 
annotationNode.getNodeMetaData(ASTTestTransformation))
         for (int p = (phase ?: CompilePhase.SEMANTIC_ANALYSIS).phaseNumber, q 
= (phase ?: CompilePhase.FINALIZATION).phaseNumber; p <= q; p += 1) {
diff --git a/src/main/java/groovy/transform/Parallel.java 
b/src/main/java/groovy/transform/Parallel.java
new file mode 100644
index 0000000000..11c1fb0748
--- /dev/null
+++ b/src/main/java/groovy/transform/Parallel.java
@@ -0,0 +1,41 @@
+/*
+ *  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.transform;
+
+import org.codehaus.groovy.transform.GroovyASTTransformationClass;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * Runs each iteration of an annotated {@code for} loop on a separate thread.
+ * <p>
+ * This annotation is a lightweight demo transform and intentionally favors 
simplicity
+ * over production-grade parallel orchestration.
+ *
+ * @since 6.0.0
+ * @see org.codehaus.groovy.transform.ParallelASTTransformation
+ */
+@Documented
+@Retention(RetentionPolicy.SOURCE)
+@GroovyASTTransformationClass("org.codehaus.groovy.transform.ParallelASTTransformation")
+public @interface Parallel {
+}
+
diff --git a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java 
b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
index ff07bc7fb1..07331823f8 100644
--- a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
+++ b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
@@ -470,7 +470,9 @@ public class AstBuilder extends 
GroovyParserBaseVisitor<Object> {
 
         Statement loopBody = this.unpackStatement((Statement) 
this.visit(ctx.statement()));
 
-        return configureAST(maker.apply(loopBody), ctx);
+        ForStatement forStatement = configureAST(maker.apply(loopBody), ctx);
+        
visitAnnotationsOpt(ctx.annotationsOpt()).forEach(forStatement::addStatementAnnotation);
+        return forStatement;
     }
 
     @Override
@@ -571,14 +573,18 @@ public class AstBuilder extends 
GroovyParserBaseVisitor<Object> {
     public WhileStatement visitWhileStmtAlt(final WhileStmtAltContext ctx) {
         Tuple2<BooleanExpression, Statement> conditionAndBlock = 
createLoopConditionExpressionAndBlock(ctx.expressionInPar(), ctx.statement());
 
-        return configureAST(new WhileStatement(conditionAndBlock.getV1(), 
conditionAndBlock.getV2()), ctx);
+        WhileStatement whileStatement = configureAST(new 
WhileStatement(conditionAndBlock.getV1(), conditionAndBlock.getV2()), ctx);
+        
visitAnnotationsOpt(ctx.annotationsOpt()).forEach(whileStatement::addStatementAnnotation);
+        return whileStatement;
     }
 
     @Override
     public DoWhileStatement visitDoWhileStmtAlt(final DoWhileStmtAltContext 
ctx) {
         Tuple2<BooleanExpression, Statement> conditionAndBlock = 
createLoopConditionExpressionAndBlock(ctx.expressionInPar(), ctx.statement());
 
-        return configureAST(new DoWhileStatement(conditionAndBlock.getV1(), 
conditionAndBlock.getV2()), ctx);
+        DoWhileStatement doWhileStatement = configureAST(new 
DoWhileStatement(conditionAndBlock.getV1(), conditionAndBlock.getV2()), ctx);
+        
visitAnnotationsOpt(ctx.annotationsOpt()).forEach(doWhileStatement::addStatementAnnotation);
+        return doWhileStatement;
     }
 
     private Tuple2<BooleanExpression, Statement> 
createLoopConditionExpressionAndBlock(final ExpressionInParContext eipc, final 
StatementContext sc) {
diff --git 
a/src/main/java/org/apache/groovy/parser/antlr4/SemanticPredicates.java 
b/src/main/java/org/apache/groovy/parser/antlr4/SemanticPredicates.java
index addfd47d3c..e25d40f2b4 100644
--- a/src/main/java/org/apache/groovy/parser/antlr4/SemanticPredicates.java
+++ b/src/main/java/org/apache/groovy/parser/antlr4/SemanticPredicates.java
@@ -30,10 +30,13 @@ import java.util.List;
 import java.util.regex.Pattern;
 
 import static org.apache.groovy.parser.antlr4.GroovyParser.ASSIGN;
+import static org.apache.groovy.parser.antlr4.GroovyParser.AT;
 import static 
org.apache.groovy.parser.antlr4.GroovyParser.BuiltInPrimitiveType;
 import static 
org.apache.groovy.parser.antlr4.GroovyParser.CapitalizedIdentifier;
+import static org.apache.groovy.parser.antlr4.GroovyParser.DO;
 import static org.apache.groovy.parser.antlr4.GroovyParser.DOT;
 import static org.apache.groovy.parser.antlr4.GroovyParser.ExpressionContext;
+import static org.apache.groovy.parser.antlr4.GroovyParser.FOR;
 import static org.apache.groovy.parser.antlr4.GroovyParser.Identifier;
 import static org.apache.groovy.parser.antlr4.GroovyParser.LBRACK;
 import static org.apache.groovy.parser.antlr4.GroovyParser.LPAREN;
@@ -41,7 +44,9 @@ import static org.apache.groovy.parser.antlr4.GroovyParser.LT;
 import static 
org.apache.groovy.parser.antlr4.GroovyParser.PathExpressionContext;
 import static 
org.apache.groovy.parser.antlr4.GroovyParser.PostfixExprAltContext;
 import static 
org.apache.groovy.parser.antlr4.GroovyParser.PostfixExpressionContext;
+import static org.apache.groovy.parser.antlr4.GroovyParser.RPAREN;
 import static org.apache.groovy.parser.antlr4.GroovyParser.StringLiteral;
+import static org.apache.groovy.parser.antlr4.GroovyParser.WHILE;
 import static org.apache.groovy.parser.antlr4.GroovyParser.YIELD;
 import static org.apache.groovy.parser.antlr4.util.StringUtils.matches;
 
@@ -186,8 +191,39 @@ public class SemanticPredicates {
                 !(BuiltInPrimitiveType == tokenType || 
Arrays.binarySearch(MODIFIER_ARRAY, tokenType) >= 0)
                         && !Character.isUpperCase(nextCodePoint)
                         && nextCodePoint != '@'
-                        && !(ASSIGN == tokenType3 || (LT == tokenType2 || 
LBRACK == tokenType2));
+                        && !(ASSIGN == tokenType3 || (LT == tokenType2 || 
LBRACK == tokenType2))
+                || (nextCodePoint == '@' && isAnnotatedLoopStatement(ts));
 
     }
 
+    /**
+     * When the input starts with one or more annotations, scan past them and 
check whether
+     * the first non-annotation token is a loop keyword ({@code for}, {@code 
while}, {@code do}).
+     * If so, the construct is an annotated loop statement, NOT a local 
variable declaration.
+     *
+     * @param ts the token stream positioned at the first annotation {@code @} 
token
+     * @return {@code true} if annotations are followed by a loop keyword
+     */
+    static boolean isAnnotatedLoopStatement(TokenStream ts) {
+        int idx = 1; // ts.LT(1) is '@'
+        while (ts.LT(idx).getType() == AT) {
+            idx += 2; // skip AT and annotation name
+            // skip qualifier parts of a fully-qualified annotation name, e.g. 
@java.lang.Deprecated
+            while (ts.LT(idx).getType() == DOT) {
+                idx += 2; // skip DOT and next name element
+            }
+            // skip annotation arguments (parenthesised), handling nesting
+            if (ts.LT(idx).getType() == LPAREN) {
+                idx++;
+                int depth = 1;
+                while (depth > 0 && ts.LT(idx).getType() != 
org.antlr.v4.runtime.Token.EOF) {
+                    int t = ts.LT(idx++).getType();
+                    if (t == LPAREN) depth++;
+                    else if (t == RPAREN) depth--;
+                }
+            }
+        }
+        int afterAnnotations = ts.LT(idx).getType();
+        return afterAnnotations == FOR || afterAnnotations == WHILE || 
afterAnnotations == DO;
+    }
 }
diff --git a/src/main/java/org/codehaus/groovy/ast/AnnotationNode.java 
b/src/main/java/org/codehaus/groovy/ast/AnnotationNode.java
index 94b772f615..2ca4b0aab0 100644
--- a/src/main/java/org/codehaus/groovy/ast/AnnotationNode.java
+++ b/src/main/java/org/codehaus/groovy/ast/AnnotationNode.java
@@ -50,6 +50,8 @@ public class AnnotationNode extends ASTNode {
     public static final int TYPE_PARAMETER_TARGET   = 1 << 8;
     public static final int TYPE_USE_TARGET         = 1 << 9;
     public static final int RECORD_COMPONENT_TARGET = 1 << 10;
+    /** Groovy-only target for statement-level annotations (e.g. on {@code 
for}/{@code while} loops). */
+    public static final int STATEMENT_TARGET        = 1 << 11;
     public static final int TYPE_TARGET             = ANNOTATION_TARGET | 1; 
// GROOVY-7151
 
     private final ClassNode classNode;
@@ -257,6 +259,7 @@ public class AnnotationNode extends ASTNode {
             case TYPE_PARAMETER_TARGET   -> "TYPE_PARAMETER";
             case TYPE_USE_TARGET         -> "TYPE_USE";
             case RECORD_COMPONENT_TARGET -> "RECORD_COMPONENT";
+            case STATEMENT_TARGET        -> "STATEMENT";
             default -> "unknown target";
         };
     }
diff --git 
a/src/main/java/org/codehaus/groovy/ast/ClassCodeExpressionTransformer.java 
b/src/main/java/org/codehaus/groovy/ast/ClassCodeExpressionTransformer.java
index 860f962084..71f9865667 100644
--- a/src/main/java/org/codehaus/groovy/ast/ClassCodeExpressionTransformer.java
+++ b/src/main/java/org/codehaus/groovy/ast/ClassCodeExpressionTransformer.java
@@ -127,6 +127,7 @@ public abstract class ClassCodeExpressionTransformer 
extends ClassCodeVisitorSup
 
     @Override
     public void visitDoWhileLoop(final DoWhileStatement stmt) {
+        visitStatementAnnotations(stmt);
         stmt.getLoopBlock().visit(this);
         stmt.setBooleanExpression((BooleanExpression) 
transform(stmt.getBooleanExpression()));
     }
@@ -138,6 +139,7 @@ public abstract class ClassCodeExpressionTransformer 
extends ClassCodeVisitorSup
 
     @Override
     public void visitForLoop(final ForStatement stmt) {
+        visitStatementAnnotations(stmt);
         if (!(stmt.getCollectionExpression() instanceof 
ClosureListExpression)) {
             visitAnnotations(stmt.getValueVariable()); // "for(T x : y)" or 
"for(x in y)"
         }
@@ -179,6 +181,7 @@ public abstract class ClassCodeExpressionTransformer 
extends ClassCodeVisitorSup
 
     @Override
     public void visitWhileLoop(final WhileStatement stmt) {
+        visitStatementAnnotations(stmt);
         stmt.setBooleanExpression((BooleanExpression) 
transform(stmt.getBooleanExpression()));
         stmt.getLoopBlock().visit(this);
     }
diff --git a/src/main/java/org/codehaus/groovy/ast/ClassCodeVisitorSupport.java 
b/src/main/java/org/codehaus/groovy/ast/ClassCodeVisitorSupport.java
index 166c0b3295..a03bd15f83 100644
--- a/src/main/java/org/codehaus/groovy/ast/ClassCodeVisitorSupport.java
+++ b/src/main/java/org/codehaus/groovy/ast/ClassCodeVisitorSupport.java
@@ -200,6 +200,7 @@ public abstract class ClassCodeVisitorSupport extends 
CodeVisitorSupport impleme
     @Override
     public void visitDoWhileLoop(DoWhileStatement statement) {
         visitStatement(statement);
+        visitStatementAnnotations(statement);
         super.visitDoWhileLoop(statement);
     }
 
@@ -212,6 +213,7 @@ public abstract class ClassCodeVisitorSupport extends 
CodeVisitorSupport impleme
     @Override
     public void visitForLoop(ForStatement statement) {
         visitStatement(statement);
+        visitStatementAnnotations(statement);
         if (statement.getValueVariable() != null) {
             visitAnnotations(statement.getValueVariable());
         }
@@ -257,6 +259,7 @@ public abstract class ClassCodeVisitorSupport extends 
CodeVisitorSupport impleme
     @Override
     public void visitWhileLoop(WhileStatement statement) {
         visitStatement(statement);
+        visitStatementAnnotations(statement);
         super.visitWhileLoop(statement);
     }
 
@@ -265,6 +268,16 @@ public abstract class ClassCodeVisitorSupport extends 
CodeVisitorSupport impleme
     protected void visitStatement(Statement statement) {
     }
 
+    /**
+     * Called for each loop statement ({@code for}, {@code while}, {@code 
do-while}) that
+     * carries statement-level annotations. Subclasses may override to process 
those annotations.
+     *
+     * @param statement the loop statement that may have statement-level 
annotations
+     * @since 6.0.0
+     */
+    protected void visitStatementAnnotations(Statement statement) {
+    }
+
     protected abstract SourceUnit getSourceUnit();
 
     @Override
diff --git a/src/main/java/org/codehaus/groovy/ast/stmt/Statement.java 
b/src/main/java/org/codehaus/groovy/ast/stmt/Statement.java
index bc1d4b4c83..096143a08f 100644
--- a/src/main/java/org/codehaus/groovy/ast/stmt/Statement.java
+++ b/src/main/java/org/codehaus/groovy/ast/stmt/Statement.java
@@ -19,11 +19,14 @@
 package org.codehaus.groovy.ast.stmt;
 
 import org.codehaus.groovy.ast.ASTNode;
+import org.codehaus.groovy.ast.AnnotationNode;
 
+import java.util.ArrayList;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.Collections;
 
 /**
  * Base class for any statement.
@@ -58,6 +61,43 @@ public class Statement extends ASTNode {
         });
     }
 
+    
//--------------------------------------------------------------------------
+    // Statement-level annotation support (Groovy-only; stored in node 
metadata)
+
+    private static final Object STATEMENT_ANNOTATIONS_KEY = 
"_statementAnnotations_";
+
+    /**
+     * Returns the list of statement-level annotations attached to this 
statement.
+     * These are Groovy-only source-retention annotations that do not appear 
at the
+     * JVM level; they are processed by registered {@link 
org.codehaus.groovy.transform.ASTTransformation}s.
+     *
+     * @return an unmodifiable view of the annotations list, never {@code null}
+     * @since 6.0.0
+     */
+    @SuppressWarnings("unchecked")
+    public List<AnnotationNode> getStatementAnnotations() {
+        List<AnnotationNode> annotations = 
getNodeMetaData(STATEMENT_ANNOTATIONS_KEY);
+        return annotations != null ? Collections.unmodifiableList(annotations) 
: Collections.emptyList();
+    }
+
+    /**
+     * Attaches a statement-level annotation to this statement.
+     *
+     * @param annotation the annotation to attach
+     * @since 6.0.0
+     */
+    @SuppressWarnings("unchecked")
+    public void addStatementAnnotation(final AnnotationNode annotation) {
+        List<AnnotationNode> annotations = 
getNodeMetaData(STATEMENT_ANNOTATIONS_KEY);
+        if (annotations == null) {
+            annotations = new ArrayList<>();
+            setNodeMetaData(STATEMENT_ANNOTATIONS_KEY, annotations);
+        }
+        annotations.add(Objects.requireNonNull(annotation));
+    }
+
+    
//--------------------------------------------------------------------------
+
     public boolean isEmpty() {
         return false;
     }
diff --git a/src/main/java/org/codehaus/groovy/control/ResolveVisitor.java 
b/src/main/java/org/codehaus/groovy/control/ResolveVisitor.java
index 1251613f44..ec4335ceaf 100644
--- a/src/main/java/org/codehaus/groovy/control/ResolveVisitor.java
+++ b/src/main/java/org/codehaus/groovy/control/ResolveVisitor.java
@@ -1393,6 +1393,17 @@ public class ResolveVisitor extends 
ClassCodeExpressionTransformer {
         super.visitCatchStatement(cs);
     }
 
+    /**
+     * Resolves the class nodes of any annotations attached to a loop statement
+     * (stored in statement metadata rather than in {@link 
org.codehaus.groovy.ast.AnnotatedNode}).
+     */
+    @Override
+    protected void visitStatementAnnotations(final Statement statement) {
+        for (AnnotationNode annotation : statement.getStatementAnnotations()) {
+            visitAnnotation(annotation);
+        }
+    }
+
     @Override
     public void visitForLoop(final ForStatement forLoop) {
         if (forLoop.getValueVariable() != null) {
diff --git 
a/src/main/java/org/codehaus/groovy/transform/ASTTransformationCollectorCodeVisitor.java
 
b/src/main/java/org/codehaus/groovy/transform/ASTTransformationCollectorCodeVisitor.java
index 9df19d8e3b..47d0b90c98 100644
--- 
a/src/main/java/org/codehaus/groovy/transform/ASTTransformationCollectorCodeVisitor.java
+++ 
b/src/main/java/org/codehaus/groovy/transform/ASTTransformationCollectorCodeVisitor.java
@@ -26,6 +26,7 @@ import org.codehaus.groovy.ast.AnnotationNode;
 import org.codehaus.groovy.ast.ClassCodeVisitorSupport;
 import org.codehaus.groovy.ast.ClassNode;
 import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.stmt.Statement;
 import org.codehaus.groovy.control.CompilePhase;
 import org.codehaus.groovy.control.SourceUnit;
 import org.codehaus.groovy.control.messages.ExceptionMessage;
@@ -113,6 +114,17 @@ public class ASTTransformationCollectorCodeVisitor extends 
ClassCodeVisitorSuppo
         }
     }
 
+    /**
+     * Collects AST transforms from statement-level annotations (e.g. 
annotations placed
+     * directly on {@code for}/{@code while}/{@code do-while} loop statements).
+     */
+    @Override
+    protected void visitStatementAnnotations(final Statement statement) {
+        for (AnnotationNode annotation : statement.getStatementAnnotations()) {
+            addTransformsToClassNode(annotation);
+        }
+    }
+
     private static void mergeCollectedAnnotations(final 
AnnotationCollectorMode mode, final Map<Integer, List<AnnotationNode>> 
existing, final List<AnnotationNode> replacements) {
         switch (mode) {
             case PREFER_COLLECTOR:
diff --git 
a/src/main/java/org/codehaus/groovy/transform/ASTTransformationVisitor.java 
b/src/main/java/org/codehaus/groovy/transform/ASTTransformationVisitor.java
index 7687a87a20..1c37dd1695 100644
--- a/src/main/java/org/codehaus/groovy/transform/ASTTransformationVisitor.java
+++ b/src/main/java/org/codehaus/groovy/transform/ASTTransformationVisitor.java
@@ -31,6 +31,7 @@ import org.codehaus.groovy.ast.ClassNode;
 import org.codehaus.groovy.ast.GroovyClassVisitor;
 import org.codehaus.groovy.ast.PropertyNode;
 import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.stmt.Statement;
 import org.codehaus.groovy.classgen.GeneratorContext;
 import org.codehaus.groovy.control.ASTTransformationsContext;
 import org.codehaus.groovy.control.CompilationUnit;
@@ -173,6 +174,20 @@ public final class ASTTransformationVisitor extends 
ClassCodeVisitorSupport {
         }
     }
 
+    /**
+     * Adds annotated loop statements to the target list so that the registered
+     * AST transformation is invoked with {@code nodes[1]} being the loop 
statement.
+     */
+    @Override
+    protected void visitStatementAnnotations(final Statement statement) {
+        if (transforms == null) return;
+        for (AnnotationNode annotation : statement.getStatementAnnotations()) {
+            if (transforms.containsKey(annotation)) {
+                targetNodes.add(new ASTNode[]{annotation, statement});
+            }
+        }
+    }
+
     private static final Tuple3<String, String, String> 
COMPILEDYNAMIC_AND_COMPILESTATIC_AND_TYPECHECKED =
             Tuple.tuple("groovy.transform.CompileDynamic", 
"groovy.transform.CompileStatic", "groovy.transform.TypeChecked");
 
diff --git 
a/src/main/java/org/codehaus/groovy/transform/ParallelASTTransformation.java 
b/src/main/java/org/codehaus/groovy/transform/ParallelASTTransformation.java
new file mode 100644
index 0000000000..20e5a69f11
--- /dev/null
+++ b/src/main/java/org/codehaus/groovy/transform/ParallelASTTransformation.java
@@ -0,0 +1,109 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.codehaus.groovy.transform;
+
+import groovy.transform.Parallel;
+import org.codehaus.groovy.ast.ASTNode;
+import org.codehaus.groovy.ast.AnnotationNode;
+import org.codehaus.groovy.ast.ClassHelper;
+import org.codehaus.groovy.ast.Parameter;
+import org.codehaus.groovy.ast.VariableScope;
+import org.codehaus.groovy.ast.expr.ClosureExpression;
+import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
+import org.codehaus.groovy.ast.stmt.BlockStatement;
+import org.codehaus.groovy.ast.stmt.ForStatement;
+import org.codehaus.groovy.ast.stmt.Statement;
+import org.codehaus.groovy.control.CompilePhase;
+import org.codehaus.groovy.control.SourceUnit;
+import org.codehaus.groovy.control.messages.SyntaxErrorMessage;
+import org.codehaus.groovy.syntax.SyntaxException;
+
+import static org.codehaus.groovy.ast.tools.GeneralUtils.args;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.block;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.callX;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.castX;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.ctorX;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.stmt;
+
+/**
+ * Demo AST transform for {@link Parallel}: each {@code for-in} iteration body 
is
+ * wrapped in a new thread and started immediately.
+ */
+@GroovyASTTransformation(phase = CompilePhase.SEMANTIC_ANALYSIS)
+public class ParallelASTTransformation implements ASTTransformation {
+
+    @Override
+    public void visit(final ASTNode[] nodes, final SourceUnit source) {
+        if (nodes == null || nodes.length != 2) return;
+        if (!(nodes[0] instanceof AnnotationNode annotation)) return;
+        if 
(!Parallel.class.getName().equals(annotation.getClassNode().getName())) return;
+
+        if (!(nodes[1] instanceof ForStatement forStatement)) {
+            source.getErrorCollector().addError(new SyntaxErrorMessage(
+                    new SyntaxException("@Parallel may only be applied to a 
for loop statement",
+                            annotation.getLineNumber(), 
annotation.getColumnNumber()), source));
+            return;
+        }
+
+        if (forStatement.getValueVariable() == null) {
+            source.getErrorCollector().addError(new SyntaxErrorMessage(
+                    new SyntaxException("@Parallel currently supports only 
for-in loops",
+                            annotation.getLineNumber(), 
annotation.getColumnNumber()), source));
+            return;
+        }
+
+        injectParallelThreadStart(forStatement, annotation);
+    }
+
+    private static void injectParallelThreadStart(final ForStatement 
forStatement, final AnnotationNode annotation) {
+        Statement originalBody = forStatement.getLoopBlock();
+
+        // Use a closure parameter with the same loop variable name and 
curry(currentValue)
+        // so each launched thread receives its own iteration value.
+        Parameter loopParameter = forStatement.getValueVariable();
+        Parameter workerParameter = new 
Parameter(loopParameter.getOriginType(), loopParameter.getName());
+
+        BlockStatement workerCode = block();
+        if (originalBody instanceof BlockStatement bodyBlock) {
+            bodyBlock.getStatements().forEach(workerCode::addStatement);
+        } else {
+            workerCode.addStatement(originalBody);
+        }
+
+        ClosureExpression worker = new ClosureExpression(new 
Parameter[]{workerParameter}, workerCode);
+        worker.setVariableScope(new VariableScope());
+        worker.setSourcePosition(annotation);
+
+        Expression currentLoopValue = new VariableExpression(loopParameter);
+        Expression boundWorker = callX(worker, "curry", 
args(currentLoopValue));
+        Expression runnable = castX(ClassHelper.make(Runnable.class), 
boundWorker);
+
+        Statement startThread = stmt(callX(
+                ctorX(ClassHelper.make(Thread.class), args(runnable)),
+                "start"));
+        startThread.setSourcePosition(annotation);
+
+        BlockStatement loopBody = block(startThread);
+        loopBody.setSourcePosition(originalBody);
+        forStatement.setLoopBlock(loopBody);
+    }
+}
+
+
diff --git a/src/test-resources/core/AnnotatedLoop_01.groovy 
b/src/test-resources/core/AnnotatedLoop_01.groovy
new file mode 100644
index 0000000000..35b1dd9a8f
--- /dev/null
+++ b/src/test-resources/core/AnnotatedLoop_01.groovy
@@ -0,0 +1,134 @@
+/*
+ *  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.
+ */
+
+// annotated for-in loops
+@Test1 for (i in someList) {
+    break
+}
+
+@Test1 @Test2 for (i in someList) {
+    break
+}
+
+@Test1(value=1) for (i in someList) {
+    break
+}
+
+@Test1
+@Test2
+for (i in someList) {
+    break
+}
+
+@Test1(v1=1, v2=2)
+@Test2
+for (String i in someList) {
+    break
+}
+
+// annotated classic for loops
+@Test1 for (int i = 0; i < 10; i++) {
+    break
+}
+
+@Test1 @Test2 for (int i = 0; i < 10; i++) {
+    break
+}
+
+@Test1
+@Test2(value='x')
+for (int i = 0; i < 10; i++) {
+    break
+}
+
+// annotated while loops
+@Test1 while (true) {
+    break
+}
+
+@Test1 @Test2 while (true) {
+    break
+}
+
+@Test1(value=1) while (true) {
+    break
+}
+
+@Test1
+@Test2
+while (true) {
+    break
+}
+
+@Test1(v1=1, v2=2)
+@Test2
+while (true) {
+    break
+}
+
+// annotated do-while loops
+@Test1 do {
+    break
+} while (true)
+
+@Test1 @Test2 do {
+    break
+} while (true)
+
+@Test1(value=1) do {
+    break
+} while (true)
+
+@Test1
+@Test2
+do {
+    break
+} while (true)
+
+@Test1(v1=1, v2=2)
+@Test2
+do {
+    break
+} while (true)
+
+// fully-qualified annotation on loop
[email protected] for (i in someList) {
+    break
+}
+
[email protected] while (true) {
+    break
+}
+
[email protected] do {
+    break
+} while (true)
+
+// annotation with nested parentheses
+@Test1(value=(1 + 2)) for (i in someList) {
+    break
+}
+
+@Test1(value=(1 + 2)) while (true) {
+    break
+}
+
+@Test1(value=(1 + 2)) do {
+    break
+} while (true)
diff --git a/src/test-resources/core/AnnotatedLoop_02x.groovy 
b/src/test-resources/core/AnnotatedLoop_02x.groovy
new file mode 100644
index 0000000000..2f5c031b31
--- /dev/null
+++ b/src/test-resources/core/AnnotatedLoop_02x.groovy
@@ -0,0 +1,117 @@
+/*
+ *  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.
+ */
+import groovy.transform.ASTTest
+import org.codehaus.groovy.ast.stmt.ForStatement
+import org.codehaus.groovy.ast.stmt.WhileStatement
+import org.codehaus.groovy.ast.stmt.DoWhileStatement
+
+import static org.codehaus.groovy.control.CompilePhase.SEMANTIC_ANALYSIS
+
+// --- annotated for-in loop: verify annotation is attached ---
+@ASTTest(phase=SEMANTIC_ANALYSIS, value={
+    assert node instanceof ForStatement
+    def annos = node.statementAnnotations
+    assert annos.size() == 1
+    assert annos[0].classNode.name == 'groovy.transform.ASTTest'
+})
+for (int i in [1, 2, 3]) {
+    assert i > 0
+}
+
+// --- annotated classic for loop: verify annotation is attached ---
+int sum = 0
+@ASTTest(phase=SEMANTIC_ANALYSIS, value={
+    assert node instanceof ForStatement
+    def annos = node.statementAnnotations
+    assert annos.size() == 1
+    assert annos[0].classNode.name == 'groovy.transform.ASTTest'
+})
+for (int i = 0; i < 5; i++) {
+    sum += i
+}
+assert sum == 10
+
+// --- annotated while loop: verify annotation is attached ---
+int count = 0
+@ASTTest(phase=SEMANTIC_ANALYSIS, value={
+    assert node instanceof WhileStatement
+    def annos = node.statementAnnotations
+    assert annos.size() == 1
+    assert annos[0].classNode.name == 'groovy.transform.ASTTest'
+})
+while (count < 3) {
+    count++
+}
+assert count == 3
+
+// --- annotated do-while loop: verify annotation is attached ---
+int x = 0
+@ASTTest(phase=SEMANTIC_ANALYSIS, value={
+    assert node instanceof DoWhileStatement
+    def annos = node.statementAnnotations
+    assert annos.size() == 1
+    assert annos[0].classNode.name == 'groovy.transform.ASTTest'
+})
+do {
+    x++
+} while (x < 4)
+assert x == 4
+
+// --- multi-annotation on for loop ---
+@ASTTest(phase=SEMANTIC_ANALYSIS, value={
+    assert node instanceof ForStatement
+    def annos = node.statementAnnotations
+    assert annos.size() == 2
+    assert annos[0].classNode.name == 'groovy.transform.ASTTest'
+    assert annos[1].classNode.name == 'java.lang.SuppressWarnings'
+})
+@SuppressWarnings('unused')
+for (String s in ['a', 'b']) {
+    assert s.length() == 1
+}
+
+// --- multi-annotation on while loop (multi-line) ---
+int y = 0
+@ASTTest(phase=SEMANTIC_ANALYSIS, value={
+    assert node instanceof WhileStatement
+    def annos = node.statementAnnotations
+    assert annos.size() == 2
+    assert annos[0].classNode.name == 'groovy.transform.ASTTest'
+    assert annos[1].classNode.name == 'java.lang.SuppressWarnings'
+})
+@SuppressWarnings('unused')
+while (y < 2) {
+    y++
+}
+assert y == 2
+
+// --- multi-annotation on do-while loop ---
+int z = 0
+@ASTTest(phase=SEMANTIC_ANALYSIS, value={
+    assert node instanceof DoWhileStatement
+    def annos = node.statementAnnotations
+    assert annos.size() == 2
+    assert annos[0].classNode.name == 'groovy.transform.ASTTest'
+    assert annos[1].classNode.name == 'java.lang.SuppressWarnings'
+})
+@SuppressWarnings('unused')
+do {
+    z++
+} while (z < 3)
+assert z == 3
diff --git 
a/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy 
b/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy
index 8dd54cf55b..1abb9bd8cb 100644
--- a/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy
+++ b/src/test/groovy/org/apache/groovy/parser/antlr4/GroovyParserTest.groovy
@@ -285,6 +285,12 @@ final class GroovyParserTest {
         doRunAndTestAntlr4('core/DoWhile_04x.groovy')
     }
 
+    @Test
+    void 'groovy core - AnnotatedLoop'() {
+        doTest('core/AnnotatedLoop_01.groovy')
+        doRunAndTestAntlr4('core/AnnotatedLoop_02x.groovy')
+    }
+
     @Test
     void 'groovy core - TryCatch'() {
         doTest('core/TryCatch_01.groovy')
diff --git 
a/subprojects/groovy-contracts/src/main/java/groovy/contracts/Invariant.java 
b/subprojects/groovy-contracts/src/main/java/groovy/contracts/Invariant.java
index 8512cd49e2..053b89a77d 100644
--- a/subprojects/groovy-contracts/src/main/java/groovy/contracts/Invariant.java
+++ b/subprojects/groovy-contracts/src/main/java/groovy/contracts/Invariant.java
@@ -22,6 +22,7 @@ import 
org.apache.groovy.contracts.annotations.meta.AnnotationProcessorImplement
 import org.apache.groovy.contracts.annotations.meta.ClassInvariant;
 import 
org.apache.groovy.contracts.common.impl.ClassInvariantAnnotationProcessor;
 import org.apache.groovy.lang.annotation.Incubating;
+import org.codehaus.groovy.transform.GroovyASTTransformationClass;
 
 import java.lang.annotation.ElementType;
 import java.lang.annotation.Repeatable;
@@ -31,13 +32,10 @@ import java.lang.annotation.Target;
 
 /**
  * <p>
- * Represents a <b>class-invariant</b>.
- * </p>
- *
- * <p>
- * The class-invariant defines assertions holding during the entire objects 
life-time.
+ * Represents a <b>class-invariant</b> or a <b>loop invariant</b>.
  * </p>
  * <p>
+ * When applied to a class, defines assertions holding during the entire 
object's life-time.
  * Class-invariants are verified at runtime at the following pointcuts:
  * <ul>
  *  <li>after a constructor call</li>
@@ -46,6 +44,17 @@ import java.lang.annotation.Target;
  * </ul>
  * </p>
  * <p>
+ * When applied to a {@code for}, {@code while}, or {@code do-while} loop, 
defines a
+ * loop invariant that is asserted at the start of each iteration:
+ * <pre>
+ * int sum = 0
+ * {@code @Invariant}({ 0 &lt;= i &amp;&amp; i &lt;= 4 })
+ * for (int i in 0..4) {
+ *     sum += i
+ * }
+ * </pre>
+ * </p>
+ * <p>
  * Whenever a class has a parent which itself specifies a class-invariant, 
that class-invariant expression is combined
  * with the actual class's invariant (by using a logical AND).
  * </p>
@@ -56,6 +65,7 @@ import java.lang.annotation.Target;
 @ClassInvariant
 @Repeatable(Invariants.class)
 @AnnotationProcessorImplementation(ClassInvariantAnnotationProcessor.class)
+@GroovyASTTransformationClass("org.apache.groovy.contracts.ast.LoopInvariantASTTransformation")
 public @interface Invariant {
     Class value();
 }
diff --git 
a/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/LoopInvariantViolation.java
 
b/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/LoopInvariantViolation.java
new file mode 100644
index 0000000000..1543a7685f
--- /dev/null
+++ 
b/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/LoopInvariantViolation.java
@@ -0,0 +1,60 @@
+/*
+ *  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;
+
+/**
+ * <p>Thrown whenever a loop invariant violation occurs.</p>
+ *
+ * @see AssertionViolation
+ * @since 6.0.0
+ */
+public class LoopInvariantViolation extends AssertionViolation {
+
+    public LoopInvariantViolation() {
+    }
+
+    public LoopInvariantViolation(Object o) {
+        super(o);
+    }
+
+    public LoopInvariantViolation(boolean b) {
+        super(b);
+    }
+
+    public LoopInvariantViolation(char c) {
+        super(c);
+    }
+
+    public LoopInvariantViolation(int i) {
+        super(i);
+    }
+
+    public LoopInvariantViolation(long l) {
+        super(l);
+    }
+
+    public LoopInvariantViolation(float f) {
+        super(f);
+    }
+
+    public LoopInvariantViolation(double d) {
+        super(d);
+    }
+}
+
diff --git 
a/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/LoopInvariantASTTransformation.java
 
b/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/LoopInvariantASTTransformation.java
new file mode 100644
index 0000000000..0f06fbed3a
--- /dev/null
+++ 
b/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/ast/LoopInvariantASTTransformation.java
@@ -0,0 +1,109 @@
+/*
+ *  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.ast;
+
+import groovy.contracts.Invariant;
+import org.apache.groovy.contracts.LoopInvariantViolation;
+import org.apache.groovy.contracts.generation.AssertStatementCreationUtility;
+import org.apache.groovy.contracts.generation.TryCatchBlockGenerator;
+import org.apache.groovy.contracts.util.ExpressionUtils;
+import org.codehaus.groovy.ast.ASTNode;
+import org.codehaus.groovy.ast.AnnotationNode;
+import org.codehaus.groovy.ast.ClassHelper;
+import org.codehaus.groovy.ast.expr.BooleanExpression;
+import org.codehaus.groovy.ast.expr.ClosureExpression;
+import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.stmt.BlockStatement;
+import org.codehaus.groovy.ast.stmt.LoopingStatement;
+import org.codehaus.groovy.ast.stmt.Statement;
+import org.codehaus.groovy.control.CompilePhase;
+import org.codehaus.groovy.control.SourceUnit;
+import org.codehaus.groovy.transform.ASTTransformation;
+import org.codehaus.groovy.transform.GroovyASTTransformation;
+
+import java.util.List;
+
+/**
+ * Handles {@link Invariant} annotations placed on loop statements ({@code 
for},
+ * {@code while}, {@code do-while}). The invariant closure is evaluated as an
+ * assertion at the start of each loop iteration.
+ * <p>
+ * When {@code @Invariant} is placed on a class (its original usage), this
+ * transform returns immediately, letting the existing global contract pipeline
+ * handle it.
+ * <p>
+ * Example:
+ * <pre>
+ * int sum = 0
+ * {@code @Invariant}({ 0 &lt;= i &amp;&amp; i &lt;= 4 })
+ * for (int i in 0..4) {
+ *     sum += i
+ * }
+ * </pre>
+ *
+ * @since 6.0.0
+ * @see Invariant
+ * @see org.apache.groovy.contracts.LoopInvariantViolation
+ */
+@GroovyASTTransformation(phase = CompilePhase.SEMANTIC_ANALYSIS)
+public class LoopInvariantASTTransformation implements ASTTransformation {
+
+    @Override
+    public void visit(final ASTNode[] nodes, final SourceUnit source) {
+        if (nodes.length != 2) return;
+        if (!(nodes[0] instanceof AnnotationNode annotation)) return;
+
+        // Only handle loop statements; class-level @Invariant is handled by 
the
+        // existing GContractsASTTransformation global pipeline.
+        if (!(nodes[1] instanceof LoopingStatement loopStatement)) return;
+
+        Expression value = annotation.getMember("value");
+        if (!(value instanceof ClosureExpression closureExpression)) return;
+
+        List<BooleanExpression> booleanExpressions = 
ExpressionUtils.getBooleanExpression(closureExpression);
+        if (booleanExpressions == null || booleanExpressions.isEmpty()) return;
+
+        BlockStatement assertStatements = 
AssertStatementCreationUtility.getAssertionStatements(booleanExpressions);
+
+        // Wrap in a try-catch that converts PowerAssertionError into
+        // LoopInvariantViolation with a helpful message.
+        BlockStatement wrapped = 
TryCatchBlockGenerator.generateTryCatchBlockForInlineMode(
+                ClassHelper.makeWithoutCaching(LoopInvariantViolation.class),
+                "<" + Invariant.class.getName() + "> loop invariant \n\n",
+                assertStatements
+        );
+        wrapped.setSourcePosition(annotation);
+
+        injectAtLoopBodyStart(loopStatement, wrapped);
+    }
+
+    private static void injectAtLoopBodyStart(LoopingStatement loopStatement, 
Statement check) {
+        Statement loopBody = loopStatement.getLoopBlock();
+        if (loopBody instanceof BlockStatement block) {
+            block.getStatements().addAll(0, ((BlockStatement) 
check).getStatements());
+        } else {
+            BlockStatement newBody = new BlockStatement();
+            newBody.addStatements(((BlockStatement) check).getStatements());
+            newBody.addStatement(loopBody);
+            newBody.setSourcePosition(loopBody);
+            loopStatement.setLoopBlock(newBody);
+        }
+    }
+}
+
diff --git 
a/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/domain/LoopInvariant.java
 
b/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/domain/LoopInvariant.java
new file mode 100644
index 0000000000..4d3cdcc46c
--- /dev/null
+++ 
b/subprojects/groovy-contracts/src/main/java/org/apache/groovy/contracts/domain/LoopInvariant.java
@@ -0,0 +1,44 @@
+/*
+ *  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.domain;
+
+import org.codehaus.groovy.ast.expr.BooleanExpression;
+import org.codehaus.groovy.ast.stmt.BlockStatement;
+
+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.constX;
+
+/**
+ * <p>A loop-invariant assertion that must hold at the start of each 
iteration.</p>
+ *
+ * @since 6.0.0
+ */
+public class LoopInvariant extends Assertion<LoopInvariant> {
+
+    public static final LoopInvariant DEFAULT = new LoopInvariant(block(), 
boolX(constX(true)));
+
+    public LoopInvariant() {
+    }
+
+    public LoopInvariant(BlockStatement blockStatement, BooleanExpression 
booleanExpression) {
+        super(blockStatement, booleanExpression);
+    }
+}
+
diff --git a/subprojects/groovy-contracts/src/spec/doc/contracts-userguide.adoc 
b/subprojects/groovy-contracts/src/spec/doc/contracts-userguide.adoc
index 58e7f5697f..2fe98b130c 100644
--- a/subprojects/groovy-contracts/src/spec/doc/contracts-userguide.adoc
+++ b/subprojects/groovy-contracts/src/spec/doc/contracts-userguide.adoc
@@ -22,7 +22,8 @@
 = Groovy Contracts – design by contract support for Groovy
 
 This module provides contract annotations that support the specification of 
class-invariants,
-as well as pre- and post-conditions on Groovy classes and interfaces.
+pre- and post-conditions on Groovy classes and interfaces, and loop invariants 
on
+`for`, `while`, and `do-while` loops.
 Special support is provided so that post-conditions may refer to the old value 
of variables
 or to the result value associated with calling a method.
 
@@ -42,6 +43,7 @@ 
include::../test/ContractsTest.groovy[tags=basic_example,indent=0]
 Groovy contracts supports the following feature set:
 
 * definition of class invariants, pre- and post-conditions via @Invariant, 
@Requires and @Ensures
+* definition of loop invariants on `for`, `while`, and `do-while` loops via 
@Invariant
 * inheritance of class invariants, pre- and post-conditions of concrete 
predecessor classes
 * inheritance of class invariants, pre- and post-conditions in implemented 
interfaces
 * usage of old and result variable in post-condition assertions
@@ -95,3 +97,46 @@ 
include::../test/ContractsTest.groovy[tags=jep445_example,indent=0]
 
 You could even place an `@Invariant` annotation on the `main` method
 and it will be moved to the generated script class.
+
+== Loop Invariants
+
+In addition to class-level invariants, `@Invariant` can be placed directly on
+`for`, `while`, and `do-while` loops. A loop invariant is a condition that must
+hold at the start of each iteration. If the condition is violated, a
+`LoopInvariantViolation` (a subclass of `AssertionError`) is thrown.
+
+This is inspired by design-by-contract constructs found in languages like Dafny
+and Eiffel.
+
+=== For loop
+
+[source,groovy]
+----
+include::../test/ContractsTest.groovy[tags=loop_invariant_for_example,indent=0]
+----
+
+=== While loop
+
+[source,groovy]
+----
+include::../test/ContractsTest.groovy[tags=loop_invariant_while_example,indent=0]
+----
+
+=== Multiple invariants
+
+Multiple `@Invariant` annotations can be stacked on a single loop, and each
+condition is checked independently at the start of every iteration:
+
+[source,groovy]
+----
+include::../test/ContractsTest.groovy[tags=loop_invariant_multiple_example,indent=0]
+----
+
+=== Rules for loop invariant closures
+
+* The closure should be a boolean expression (or multiple expressions).
+* The closure may reference any variables visible in the enclosing scope,
+  including the loop variable.
+* Assignment operators and state-changing postfix/prefix operators are
+  not supported inside the closure.
+
diff --git a/subprojects/groovy-contracts/src/spec/test/ContractsTest.groovy 
b/subprojects/groovy-contracts/src/spec/test/ContractsTest.groovy
index 6f10a0371f..18aa43eeb0 100644
--- a/subprojects/groovy-contracts/src/spec/test/ContractsTest.groovy
+++ b/subprojects/groovy-contracts/src/spec/test/ContractsTest.groovy
@@ -110,6 +110,52 @@ class ContractsTest extends GroovyTestCase {
         '''
     }
 
+    void testLoopInvariantForIn() {
+        assertScript '''
+        // tag::loop_invariant_for_example[]
+        import groovy.contracts.Invariant
+
+        int sum = 0
+        @Invariant({ 0 <= i && i <= 4 })
+        for (int i in 0..4) {
+            sum += i
+        }
+        assert sum == 10
+        // end::loop_invariant_for_example[]
+        '''
+    }
+
+    void testLoopInvariantWhile() {
+        assertScript '''
+        // tag::loop_invariant_while_example[]
+        import groovy.contracts.Invariant
+
+        int n = 10
+        @Invariant({ n >= 0 })
+        while (n > 0) {
+            n--
+        }
+        assert n == 0
+        // end::loop_invariant_while_example[]
+        '''
+    }
+
+    void testLoopInvariantMultiple() {
+        assertScript '''
+        // tag::loop_invariant_multiple_example[]
+        import groovy.contracts.Invariant
+
+        int sum = 0
+        @Invariant({ sum >= 0 })
+        @Invariant({ sum <= 100 })
+        for (int i in 1..5) {
+            sum += i
+        }
+        assert sum == 15
+        // end::loop_invariant_multiple_example[]
+        '''
+    }
+
     void testJep445Script() {
         runScript '''
         // tag::jep445_example[]
diff --git 
a/subprojects/groovy-contracts/src/test/groovy/org/apache/groovy/contracts/tests/inv/LoopInvariantTests.groovy
 
b/subprojects/groovy-contracts/src/test/groovy/org/apache/groovy/contracts/tests/inv/LoopInvariantTests.groovy
new file mode 100644
index 0000000000..3ac1f77565
--- /dev/null
+++ 
b/subprojects/groovy-contracts/src/test/groovy/org/apache/groovy/contracts/tests/inv/LoopInvariantTests.groovy
@@ -0,0 +1,159 @@
+/*
+ *  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.inv
+
+import org.apache.groovy.contracts.tests.basic.BaseTestClass
+import org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.assertScript
+import static groovy.test.GroovyAssert.shouldFail
+
+/**
+ * Tests for {@code @Invariant} applied to loop statements.
+ */
+class LoopInvariantTests extends BaseTestClass {
+
+    @Test
+    void invariantOnForInLoop() {
+        assertScript '''
+            import groovy.contracts.Invariant
+
+            int sum = 0
+            @Invariant({ 0 <= i && i <= 4 })
+            for (int i in 0..4) {
+                sum += i
+            }
+            assert sum == 10
+        '''
+    }
+
+    @Test
+    void invariantOnClassicForLoop() {
+        assertScript '''
+            import groovy.contracts.Invariant
+
+            int product = 1
+            @Invariant({ product >= 1 })
+            for (int i = 1; i <= 5; i++) {
+                product *= i
+            }
+            assert product == 120
+        '''
+    }
+
+    @Test
+    void invariantOnWhileLoop() {
+        assertScript '''
+            import groovy.contracts.Invariant
+
+            int n = 10
+            @Invariant({ n >= 0 })
+            while (n > 0) {
+                n--
+            }
+            assert n == 0
+        '''
+    }
+
+    @Test
+    void invariantOnDoWhileLoop() {
+        assertScript '''
+            import groovy.contracts.Invariant
+
+            int count = 0
+            @Invariant({ count >= 0 })
+            do {
+                count++
+            } while (count < 3)
+            assert count == 3
+        '''
+    }
+
+    @Test
+    void multipleInvariantsOnLoop() {
+        assertScript '''
+            import groovy.contracts.Invariant
+
+            int sum = 0
+            @Invariant({ sum >= 0 })
+            @Invariant({ sum <= 100 })
+            for (int i in 1..5) {
+                sum += i
+            }
+            assert sum == 15
+        '''
+    }
+
+    @Test
+    void invariantViolationThrows() {
+        shouldFail AssertionError, '''
+            import groovy.contracts.Invariant
+
+            int n = 5
+            @Invariant({ n > 0 })
+            while (n >= 0) {
+                n--
+            }
+        '''
+    }
+
+    @Test
+    void invariantWithComplexExpression() {
+        assertScript '''
+            import groovy.contracts.Invariant
+
+            def items = []
+            @Invariant({ items.size() <= 5 })
+            for (int i in 1..5) {
+                items << i
+            }
+            assert items == [1, 2, 3, 4, 5]
+        '''
+    }
+
+    @Test
+    void invariantViolationOnFirstIteration() {
+        shouldFail AssertionError, '''
+            import groovy.contracts.Invariant
+
+            int x = -1
+            @Invariant({ x >= 0 })
+            for (int i in 0..2) {
+                x = i
+            }
+        '''
+    }
+
+    @Test
+    void classInvariantStillWorksWithLoopInvariantTransform() {
+        assertScript '''
+            import groovy.contracts.Invariant
+
+            @Invariant({ property != null })
+            class Foo {
+                def property
+                Foo(val) { property = val }
+            }
+
+            def f = new Foo('hello')
+            assert f.property == 'hello'
+        '''
+    }
+}
+

Reply via email to