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 24a65ab8de GROOVY-11914: Add PurityChecker type checking extension
24a65ab8de is described below

commit 24a65ab8dec4b0e5e60854ed88823d8f6c61ecb7
Author: Paul King <[email protected]>
AuthorDate: Thu Apr 9 20:54:07 2026 +1000

    GROOVY-11914: Add PurityChecker type checking extension
---
 .../groovy/typecheckers/PurityChecker.groovy       | 445 ++++++++++++++++
 .../src/spec/doc/typecheckers.adoc                 | 216 ++++++++
 .../groovy/typecheckers/PurityCheckerTest.groovy   | 592 +++++++++++++++++++++
 3 files changed, 1253 insertions(+)

diff --git 
a/subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/PurityChecker.groovy
 
b/subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/PurityChecker.groovy
new file mode 100644
index 0000000000..198ca35d26
--- /dev/null
+++ 
b/subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/PurityChecker.groovy
@@ -0,0 +1,445 @@
+/*
+ *  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.ClassExpression
+import org.codehaus.groovy.ast.expr.ConstructorCallExpression
+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 {@code @Pure} methods have no side 
effects.
+ * <p>
+ * By default, strict purity is enforced: no field mutations, no I/O, no 
logging,
+ * no non-deterministic calls. The {@code allows} option declares which effect
+ * categories are tolerated:
+ * <pre>
+ * // Strict: no side effects at all
+ * {@code @TypeChecked(extensions = 'groovy.typecheckers.PurityChecker')}
+ *
+ * // Tolerate logging and metrics
+ * {@code @TypeChecked(extensions = 'groovy.typecheckers.PurityChecker(allows: 
"LOGGING|METRICS")')}
+ * </pre>
+ * <p>
+ * Effect categories:
+ * <ul>
+ *   <li>{@code LOGGING} — calls to logging frameworks (SLF4J, JUL, etc.) and 
{@code println}</li>
+ *   <li>{@code METRICS} — calls to metrics instruments (Micrometer, 
OpenTelemetry, etc.)</li>
+ *   <li>{@code IO} — file, network, database, and console I/O</li>
+ *   <li>{@code NONDETERMINISM} — time-dependent, random, and 
environment-dependent calls</li>
+ * </ul>
+ * <p>
+ * Also recognises:
+ * <ul>
+ *   <li>{@code @SideEffectFree} (Checker Framework) — treated as {@code 
@Pure} with implicit NONDETERMINISM allowed</li>
+ *   <li>{@code @Contract(pure = true)} (JetBrains) — treated as {@code 
@Pure}</li>
+ *   <li>{@code @Memoized} — treated as effectively pure</li>
+ * </ul>
+ *
+ * @since 6.0.0
+ * @see groovy.transform.Pure
+ */
+@Incubating
+class PurityChecker extends GroovyTypeCheckingExtensionSupport.TypeCheckingDSL 
{
+
+    private static final Set<String> PURE_ANNOS = Set.of('Pure')
+    private static final Set<String> SIDE_EFFECT_FREE_ANNOS = 
Set.of('SideEffectFree')
+    private static final Set<String> CONTRACT_ANNOS = Set.of('Contract')
+    private static final Set<String> MEMOIZED_ANNOS = Set.of('Memoized')
+
+    // Methods on mutable types known to be pure (no mutation, no effects, no 
closures)
+    private static final Set<String> KNOWN_PURE_METHODS = 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', 'subList', 'keySet', 'values', 'entrySet',
+            'first', 'last', 'head', 'tail', 'init',
+            'asBoolean', 'is', 'isCase',
+            // Type info
+            'getMetaClass', 'respondsTo', 'hasProperty',
+    )
+
+    // Known non-deterministic static methods (class.method)
+    private static final Map<String, Set<String>> 
NONDETERMINISTIC_STATIC_METHODS = [
+            'java.lang.System'        : Set.of('nanoTime', 
'currentTimeMillis', 'getProperty', 'getenv'),
+            'java.lang.Math'          : Set.of('random'),
+            'java.util.UUID'          : Set.of('randomUUID'),
+            'java.time.Instant'       : Set.of('now'),
+            'java.time.LocalDateTime' : Set.of('now'),
+            'java.time.LocalDate'     : Set.of('now'),
+            'java.time.LocalTime'     : Set.of('now'),
+            'java.time.ZonedDateTime' : Set.of('now'),
+            'java.time.OffsetDateTime': Set.of('now'),
+            'java.time.OffsetTime'    : Set.of('now'),
+            'java.time.Year'          : Set.of('now'),
+            'java.time.YearMonth'     : Set.of('now'),
+            'java.time.MonthDay'      : Set.of('now'),
+    ]
+
+    // Non-deterministic no-arg constructors
+    private static final Set<String> NONDETERMINISTIC_CONSTRUCTORS = Set.of(
+            'java.util.Date',
+            'java.util.Random',
+    )
+
+    // Instance method that is non-deterministic
+    private static final Map<String, Set<String>> 
NONDETERMINISTIC_INSTANCE_METHODS = [
+            'java.util.concurrent.ThreadLocalRandom': Set.of('current'),
+    ]
+
+    // Logging receiver type prefixes
+    private static final List<String> LOGGING_TYPE_PREFIXES = [
+            'org.slf4j.Logger',
+            'java.util.logging.Logger',
+            'org.apache.commons.logging.Log',
+            'org.apache.log4j.Logger',
+            'org.apache.logging.log4j.Logger',
+            'java.lang.System.Logger',
+    ]
+
+    // Logging method names (on implicit this or any receiver)
+    private static final Set<String> LOGGING_METHOD_NAMES = Set.of(
+            'println', 'print', 'printf',
+    )
+
+    // Metrics receiver type prefixes
+    private static final List<String> METRICS_TYPE_PREFIXES = [
+            'io.micrometer.core.instrument',
+            'io.opentelemetry.api.metrics',
+            'com.codahale.metrics',
+            'org.eclipse.microprofile.metrics',
+    ]
+
+    // I/O type prefixes
+    private static final List<String> IO_TYPE_PREFIXES = [
+            'java.io.',
+            'java.nio.',
+            'java.net.',
+            'java.sql.',
+            'javax.sql.',
+            'groovy.io.',
+    ]
+
+    // I/O class names for constructor detection
+    private static final List<String> IO_CONSTRUCTOR_PREFIXES = [
+            'java.io.',
+            'java.nio.',
+            'java.net.',
+            'java.sql.',
+    ]
+
+    @Override
+    Object run() {
+        Set<String> baseAllows = parseAllows(options?.allows as String)
+
+        afterVisitMethod { MethodNode mn ->
+            Set<String> allows = baseAllows
+
+            if (hasPureAnno(mn) || hasMemoizedAnno(mn) || 
hasContractPureAnno(mn)) {
+                // strict purity (or whatever baseAllows says)
+            } else if (hasSideEffectFreeAnno(mn)) {
+                // @SideEffectFree implies NONDETERMINISM is allowed
+                allows = new HashSet<>(baseAllows)
+                allows.add('NONDETERMINISM')
+            } else {
+                return // no purity annotation — nothing to check
+            }
+
+            mn.code?.visit(makeVisitor(allows, mn))
+        }
+    }
+
+    private static Set<String> parseAllows(String allowsStr) {
+        if (!allowsStr) return Collections.emptySet()
+        allowsStr.split('\\|')*.trim()*.toUpperCase() as Set<String>
+    }
+
+    private static boolean hasPureAnno(MethodNode method) {
+        method.annotations?.any { it.classNode?.nameWithoutPackage in 
PURE_ANNOS } ?: false
+    }
+
+    private static boolean hasSideEffectFreeAnno(MethodNode method) {
+        method.annotations?.any { it.classNode?.nameWithoutPackage in 
SIDE_EFFECT_FREE_ANNOS } ?: false
+    }
+
+    /**
+     * Checks for {@code @Contract(pure = true)} (JetBrains annotations).
+     * Works with CLASS retention since annotation nodes are available during 
type checking.
+     */
+    private static boolean hasContractPureAnno(MethodNode method) {
+        method.annotations?.any { anno ->
+            anno.classNode?.nameWithoutPackage in CONTRACT_ANNOS &&
+                    anno.getMember('pure')?.text == 'true'
+        } ?: false
+    }
+
+    private static boolean hasMemoizedAnno(MethodNode method) {
+        method.annotations?.any { it.classNode?.nameWithoutPackage in 
MEMOIZED_ANNOS } ?: false
+    }
+
+    private CheckingVisitor makeVisitor(Set<String> allows, MethodNode 
methodNode) {
+        boolean allowLogging = 'LOGGING' in allows
+        boolean allowMetrics = 'METRICS' in allows
+        boolean allowIO = 'IO' in allows
+        boolean allowNondeterminism = 'NONDETERMINISM' in allows
+
+        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)) {
+                    checkFieldWrite(expression.leftExpression, expression)
+                }
+            }
+
+            @Override
+            void visitPostfixExpression(PostfixExpression expression) {
+                super.visitPostfixExpression(expression)
+                checkFieldWrite(expression.expression, expression)
+            }
+
+            @Override
+            void visitPrefixExpression(PrefixExpression expression) {
+                super.visitPrefixExpression(expression)
+                checkFieldWrite(expression.expression, expression)
+            }
+
+            @Override
+            void visitMethodCallExpression(MethodCallExpression call) {
+                super.visitMethodCallExpression(call)
+                checkInstanceCall(call)
+            }
+
+            @Override
+            void visitStaticMethodCallExpression(StaticMethodCallExpression 
call) {
+                super.visitStaticMethodCallExpression(call)
+                checkStaticCall(call)
+            }
+
+            @Override
+            void visitConstructorCallExpression(ConstructorCallExpression 
call) {
+                super.visitConstructorCallExpression(call)
+                checkConstructorCall(call)
+            }
+
+            // ---- field mutation check ----
+
+            private void checkFieldWrite(Expression target, Expression 
context) {
+                if (target instanceof PropertyExpression) {
+                    def objExpr = target.objectExpression
+                    if (objExpr instanceof VariableExpression && 
objExpr.isThisExpression()) {
+                        addStaticTypeError("@Pure violation: field assignment 
to 'this.${target.propertyAsString}'", context)
+                    } else if (objExpr instanceof VariableExpression && 
!objExpr.isThisExpression()) {
+                        // Writing to a property on a parameter or local 
variable (e.g., param.x = 1)
+                        addStaticTypeError("@Pure violation: property 
assignment to '${objExpr.name}.${target.propertyAsString}'", context)
+                    }
+                } else if (target instanceof VariableExpression) {
+                    Variable accessedVar = findTargetVariable(target)
+                    if (accessedVar instanceof FieldNode) {
+                        addStaticTypeError("@Pure violation: field assignment 
to '${accessedVar.name}'", context)
+                    }
+                }
+            }
+
+            // ---- instance method call check ----
+
+            private void checkInstanceCall(MethodCallExpression call) {
+                String methodName = call.methodAsString
+                Expression receiver = call.objectExpression
+
+                // Static call via ClassExpression (e.g., System.nanoTime())
+                if (receiver instanceof ClassExpression) {
+                    checkStaticCallOnClass(receiver.type.name, methodName, 
call)
+                    return
+                }
+
+                // Check logging by method name (println, print, printf — 
including implicit this)
+                if (methodName in LOGGING_METHOD_NAMES) {
+                    if (!allowLogging) {
+                        addStaticTypeError("@Pure violation: '${methodName}()' 
is a logging/output call (allow with LOGGING)", call)
+                    }
+                    return
+                }
+
+                // Check receiver type for logging/metrics/IO
+                ClassNode receiverType = getType(receiver)
+                if (receiverType) {
+                    String typeName = receiverType.name
+
+                    // Check logging by receiver type
+                    if (LOGGING_TYPE_PREFIXES.any { typeName.startsWith(it) }) 
{
+                        if (!allowLogging) {
+                            addStaticTypeError("@Pure violation: call to 
'${methodName}()' on logging type (allow with LOGGING)", call)
+                        }
+                        return
+                    }
+
+                    // Check metrics by receiver type
+                    if (METRICS_TYPE_PREFIXES.any { typeName.startsWith(it) }) 
{
+                        if (!allowMetrics) {
+                            addStaticTypeError("@Pure violation: call to 
'${methodName}()' on metrics type (allow with METRICS)", call)
+                        }
+                        return
+                    }
+
+                    // Check I/O by receiver type (but not PrintStream used 
for logging)
+                    if (IO_TYPE_PREFIXES.any { typeName.startsWith(it) }) {
+                        if (!allowIO) {
+                            addStaticTypeError("@Pure violation: call to 
'${methodName}()' is an I/O operation (allow with IO)", call)
+                        }
+                        return
+                    }
+
+                    // Check non-deterministic instance methods
+                    Set<String> nonDetMethods = 
NONDETERMINISTIC_INSTANCE_METHODS[typeName]
+                    if (nonDetMethods && methodName in nonDetMethods) {
+                        if (!allowNondeterminism) {
+                            addStaticTypeError("@Pure violation: 
'${typeName}.${methodName}()' is non-deterministic (allow with 
NONDETERMINISM)", call)
+                        }
+                        return
+                    }
+
+                    // Immutable receiver — all calls are pure
+                    if (ImmutablePropertyUtils.isBuiltinImmutable(typeName)) 
return
+                }
+
+                // Check callee annotations
+                def targetMethod = 
call.getNodeMetaData(StaticTypesMarker.DIRECT_METHOD_CALL_TARGET)
+                if (targetMethod instanceof MethodNode) {
+                    if (hasPureAnno(targetMethod) || 
hasSideEffectFreeAnno(targetMethod) || hasContractPureAnno(targetMethod) || 
hasMemoizedAnno(targetMethod)) return
+                }
+
+                // Known pure method name
+                if (methodName in KNOWN_PURE_METHODS) return
+
+                // Unknown — warn
+                String receiverName = resolveReceiverName(receiver)
+                if (receiverName) {
+                    addStaticTypeError("@Pure warning: call to 
'${receiverName}.${methodName}()' — purity cannot be verified (consider adding 
@Pure)", call)
+                } else if (!(receiver instanceof VariableExpression && 
receiver.isThisExpression())) {
+                    addStaticTypeError("@Pure warning: call to 
'${methodName}()' — purity cannot be verified", call)
+                } else {
+                    // Call on this — check callee
+                    if (targetMethod instanceof MethodNode) {
+                        addStaticTypeError("@Pure warning: call to 
'this.${methodName}()' — purity cannot be verified (consider adding @Pure)", 
call)
+                    }
+                }
+            }
+
+            // ---- static method call check ----
+
+            private void checkStaticCall(StaticMethodCallExpression call) {
+                checkStaticCallOnClass(call.ownerType.name, 
call.methodAsString, call)
+            }
+
+            private void checkStaticCallOnClass(String ownerName, String 
methodName, Expression call) {
+                // Check non-deterministic static methods
+                Set<String> nonDetMethods = 
NONDETERMINISTIC_STATIC_METHODS[ownerName]
+                if (nonDetMethods && methodName in nonDetMethods) {
+                    if (!allowNondeterminism) {
+                        addStaticTypeError("@Pure violation: 
'${ownerName}.${methodName}()' is non-deterministic (allow with 
NONDETERMINISM)", call)
+                    }
+                    return
+                }
+
+                // I/O static methods
+                if (IO_TYPE_PREFIXES.any { ownerName.startsWith(it) }) {
+                    if (!allowIO) {
+                        addStaticTypeError("@Pure violation: 
'${ownerName}.${methodName}()' is an I/O operation (allow with IO)", call)
+                    }
+                    return
+                }
+
+                // Immutable type — all static methods are pure
+                if (ImmutablePropertyUtils.isBuiltinImmutable(ownerName)) 
return
+
+                // Check callee annotations
+                def targetMethod = 
call.getNodeMetaData(StaticTypesMarker.DIRECT_METHOD_CALL_TARGET)
+                if (targetMethod instanceof MethodNode) {
+                    if (hasPureAnno(targetMethod) || 
hasSideEffectFreeAnno(targetMethod) || hasContractPureAnno(targetMethod) || 
hasMemoizedAnno(targetMethod)) return
+                }
+
+                // Known pure method name on known type
+                if (methodName in KNOWN_PURE_METHODS) return
+            }
+
+            // ---- constructor call check ----
+
+            private void checkConstructorCall(ConstructorCallExpression call) {
+                String typeName = call.type.name
+
+                // Non-deterministic constructors (new Date(), new Random())
+                if (typeName in NONDETERMINISTIC_CONSTRUCTORS) {
+                    if (!allowNondeterminism) {
+                        addStaticTypeError("@Pure violation: 'new 
${call.type.nameWithoutPackage}()' is non-deterministic (allow with 
NONDETERMINISM)", call)
+                    }
+                    return
+                }
+
+                // I/O constructors (new File(...), new Socket(...), etc.)
+                if (IO_CONSTRUCTOR_PREFIXES.any { typeName.startsWith(it) }) {
+                    if (!allowIO) {
+                        addStaticTypeError("@Pure violation: 'new 
${call.type.nameWithoutPackage}(...)' is an I/O operation (allow with IO)", 
call)
+                    }
+                }
+            }
+
+            // ---- helpers ----
+
+            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
+            }
+        }
+    }
+}
diff --git a/subprojects/groovy-typecheckers/src/spec/doc/typecheckers.adoc 
b/subprojects/groovy-typecheckers/src/spec/doc/typecheckers.adoc
index b47a271e1c..cd6a55bac8 100644
--- a/subprojects/groovy-typecheckers/src/spec/doc/typecheckers.adoc
+++ b/subprojects/groovy-typecheckers/src/spec/doc/typecheckers.adoc
@@ -824,3 +824,219 @@ a complete specification for modular reasoning:
 
 Together, these annotations allow analysis of method call sequences without 
reading
 method bodies -- each method becomes a self-contained specification.
+
+== Checking @Pure Purity (Incubating)
+
+The `PurityChecker` verifies that `@Pure` methods have no side effects. By 
default, strict
+purity is enforced. The `allows` option declares which effect categories are 
tolerated.
+
+=== Basic usage
+
+[source,groovy]
+----
+// Strict: no side effects at all
+@TypeChecked(extensions = 'groovy.typecheckers.PurityChecker')
+class Calculator {
+    @Pure
+    int add(int a, int b) { a + b }              // OK: no effects
+
+    @Pure
+    int broken() { count++; return count }         // ERROR: field mutation
+}
+----
+
+=== Effect categories
+
+The `allows` option accepts a `|`-separated list of effect categories:
+
+[source,groovy]
+----
+@TypeChecked(extensions = 'groovy.typecheckers.PurityChecker(allows: 
"LOGGING|METRICS")')
+----
+
+[cols="1,3,3",options="header"]
+|===
+| Category | What it allows | Examples
+
+| `LOGGING`
+| Calls to logging frameworks and `println`/`print`
+| `log.debug(...)`, `println(...)`, `System.out.println(...)`
+
+| `METRICS`
+| Calls to metrics instruments
+| Micrometer counters/timers, OpenTelemetry metrics
+
+| `IO`
+| File, network, database, and console I/O
+| `new File(...)`, `Files.readAllLines(...)`, JDBC calls
+
+| `NONDETERMINISM`
+| Time-dependent, random, and environment-dependent calls
+| `System.nanoTime()`, `Math.random()`, `new Date()`, `UUID.randomUUID()`
+|===
+
+=== Supported annotations
+
+The checker recognises purity annotations from multiple libraries by simple 
name:
+
+[cols="2,2,3",options="header"]
+|===
+| Annotation | Library | Semantics
+
+| `@Pure`
+| Groovy, Checker Framework, Xtext
+| Strict purity (side-effect-free + deterministic)
+
+| `@SideEffectFree`
+| Checker Framework
+| No mutations, but non-determinism is implicitly allowed
+
+| `@Contract(pure = true)`
+| JetBrains Annotations
+| Strict purity (works with CLASS retention)
+
+| `@Memoized`
+| Groovy
+| Effectively pure (checked for side effects)
+|===
+
+Annotations are matched by simple name, so `@Pure` from any package is 
recognised. The
+`@Contract` annotation is checked for its `pure` attribute being `true`.
+
+=== How calls are classified
+
+The checker uses a layered approach to determine if a call is pure:
+
+1. *Callee has `@Pure`, `@SideEffectFree`, or `@Contract(pure = true)`* -- the 
call is pure
+2. *Callee has `@Memoized`* -- the call is effectively pure
+3. *Receiver type is immutable* (String, Integer, BigDecimal, etc.) -- all 
calls are pure
+4. *Method is in the known-pure list* (`size`, `get`, `contains`, `isEmpty`, 
`toString`, etc.) -- pure
+5. *Call matches an effect category* -- error unless that category is in 
`allows`
+6. *None of the above* -- warning: purity cannot be verified
+
+=== Reasoning guarantees
+
+The `allows` declaration is machine-readable, enabling both humans and AI to 
derive
+precise guarantees about `@Pure` methods:
+
+[cols="3,1,1,1,1,1",options="header"]
+|===
+| Property | strict | +LOGGING | +METRICS | +IO | +NONDETERMINISM
+
+| No field mutations
+| Yes | Yes | Yes | Yes | Yes
+
+| Can carry forward state across call
+| Yes | Yes | Yes | Yes | Yes
+
+| Deterministic (same inputs -> same output)
+| Yes | Yes | Yes | Yes | *No*
+
+| Can cache/memoize result
+| Yes | Yes | Yes | Yes | *No*
+
+| Can reorder call freely
+| Yes | Yes | Yes | *No* | Yes
+
+| Can eliminate dead call
+| Yes | *No* | *No* | *No* | Yes
+
+| No observable output
+| Yes | *No* | *No* | *No* | Yes
+|===
+
+Each column represents a different `allows` configuration. For example, a 
`@Pure` method
+under `PurityChecker(allows: "LOGGING")` is guaranteed to have no field 
mutations, be
+deterministic, and be safely reorderable -- but may produce log output, so 
dead call
+elimination would lose that output.
+
+=== Examples
+
+*Strict purity (default):*
+
+[source,groovy]
+----
+@TypeChecked(extensions = 'groovy.typecheckers.PurityChecker')
+class MathUtils {
+    @Pure
+    int square(int x) { x * x }                             // OK
+
+    @Pure
+    long timestamp() { System.nanoTime() }                   // ERROR: 
non-deterministic
+
+    @Pure
+    int logged(int x) { println("x=$x"); x * 2 }            // ERROR: logging
+}
+----
+
+*Allowing logging:*
+
+[source,groovy]
+----
+@TypeChecked(extensions = 'groovy.typecheckers.PurityChecker(allows: 
"LOGGING")')
+class Service {
+    @Pure
+    int compute(int x) {
+        println("computing $x")          // OK: logging allowed
+        return x * x
+    }
+
+    @Pure
+    long getTime() { System.nanoTime() } // ERROR: non-deterministic (not 
allowed)
+}
+----
+
+*Allowing logging and non-determinism:*
+
+[source,groovy]
+----
+@TypeChecked(extensions = 'groovy.typecheckers.PurityChecker(allows: 
"LOGGING|NONDETERMINISM")')
+class Diagnostics {
+    @Pure
+    String snapshot(Map state) {
+        println("snapshot at ${System.nanoTime()}")   // OK: both allowed
+        return state.toString()
+    }
+}
+----
+
+=== What the checker detects
+
+The checker flags:
+
+* Direct field assignments on `this` (`this.x = ...`, `x++` where `x` is a 
field)
+* Property assignments on parameters and local variables (`param.x = 1`)
+* Calls to methods not known to be pure
+* Calls matching effect categories (logging, metrics, I/O, non-determinism) 
unless allowed
+* Construction of non-deterministic or I/O types
+
+The checker does *not* currently detect:
+
+* Mutation through aliases (`def ref = this.list; ref.clear()`)
+* Deep heap mutation (`this.items.get(0).setName("x")`)
+* Mutation via chained property access (`a.b.c = 1`)
+* Side effects within closures passed to higher-order methods
+
+These limitations are acceptable for an opt-in checker -- the common cases are 
caught,
+and unverifiable calls produce warnings.
+
+=== Relationship to @Modifies and ModifiesChecker
+
+`@Pure` implies `@Modifies({})` -- a method with no field mutations. The 
`ModifiesChecker`
+recognises `@Pure` and verifies the no-mutation guarantee. The `PurityChecker` 
goes further,
+verifying additional purity properties (no impure calls, no I/O, etc.).
+
+Both checkers can be used independently or together:
+
+[source,groovy]
+----
+// Just frame conditions
+@TypeChecked(extensions = 'groovy.typecheckers.ModifiesChecker')
+
+// Just purity
+@TypeChecked(extensions = 'groovy.typecheckers.PurityChecker')
+
+// Both -- ModifiesChecker handles @Modifies, PurityChecker handles @Pure
+@TypeChecked(extensions = ['groovy.typecheckers.ModifiesChecker',
+                           'groovy.typecheckers.PurityChecker'])
+----
diff --git 
a/subprojects/groovy-typecheckers/src/test/groovy/groovy/typecheckers/PurityCheckerTest.groovy
 
b/subprojects/groovy-typecheckers/src/test/groovy/groovy/typecheckers/PurityCheckerTest.groovy
new file mode 100644
index 0000000000..102a094400
--- /dev/null
+++ 
b/subprojects/groovy-typecheckers/src/test/groovy/groovy/typecheckers/PurityCheckerTest.groovy
@@ -0,0 +1,592 @@
+/*
+ *  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 PurityCheckerTest {
+
+    private static GroovyShell strictShell
+    private static GroovyShell loggingShell
+    private static GroovyShell nondetShell
+    private static GroovyShell ioShell
+
+    @BeforeAll
+    static void setUp() {
+        strictShell = makeShell('')
+        loggingShell = makeShell('LOGGING')
+        nondetShell = makeShell('NONDETERMINISM')
+        ioShell = makeShell('IO')
+    }
+
+    private static GroovyShell makeShell(String allows) {
+        String ext = allows ? "groovy.typecheckers.PurityChecker(allows: 
'${allows}')" : 'groovy.typecheckers.PurityChecker'
+        new GroovyShell(new CompilerConfiguration().tap {
+            def customizer = new 
ASTTransformationCustomizer(groovy.transform.TypeChecked)
+            customizer.annotationParameters = [extensions: ext]
+            addCompilationCustomizers(customizer)
+        })
+    }
+
+    // === Field mutation checks ===
+
+    @Test
+    void pure_method_with_no_side_effects_passes() {
+        assertScript strictShell, '''
+            import groovy.transform.Pure
+
+            class A {
+                int value = 42
+
+                @Pure
+                int doubled() {
+                    return value * 2
+                }
+            }
+            assert new A().doubled() == 84
+        '''
+    }
+
+    @Test
+    void pure_method_with_field_write_fails() {
+        def err = shouldFail strictShell, '''
+            import groovy.transform.Pure
+
+            class A {
+                int count = 0
+
+                @Pure
+                int increment() {
+                    count++
+                    return count
+                }
+            }
+        '''
+        assert err.message.contains('@Pure violation')
+        assert err.message.contains('field assignment')
+    }
+
+    @Test
+    void pure_method_writing_to_parameter_property_fails() {
+        def err = shouldFail strictShell, '''
+            import groovy.transform.Pure
+
+            class Item { int value }
+
+            class A {
+                @Pure
+                void broken(Item item) {
+                    item.value = 42
+                }
+            }
+        '''
+        assert err.message.contains('@Pure violation')
+        assert err.message.contains('property assignment')
+    }
+
+    // === Calling other pure methods ===
+
+    @Test
+    void pure_method_calling_another_pure_method_passes() {
+        assertScript strictShell, '''
+            import groovy.transform.Pure
+
+            class A {
+                int value = 42
+
+                @Pure
+                int doubled() { return value * 2 }
+
+                @Pure
+                int quadrupled() { return doubled() * 2 }
+            }
+            assert new A().quadrupled() == 168
+        '''
+    }
+
+    @Test
+    void pure_method_calling_memoized_method_passes() {
+        assertScript strictShell, '''
+            import groovy.transform.Pure
+            import groovy.transform.Memoized
+
+            class A {
+                @Memoized
+                int expensiveCompute(int x) { return x * x }
+
+                @Pure
+                int useComputed(int x) { return expensiveCompute(x) + 1 }
+            }
+            assert new A().useComputed(5) == 26
+        '''
+    }
+
+    // === Immutable receiver types ===
+
+    @Test
+    void calls_on_immutable_types_always_pass() {
+        assertScript strictShell, '''
+            import groovy.transform.Pure
+
+            class A {
+                @Pure
+                String process(String input) {
+                    return input.toUpperCase().trim().substring(1)
+                }
+            }
+            assert new A().process(' HELLO') == 'ELLO'
+        '''
+    }
+
+    @Test
+    void calls_on_bigdecimal_pass() {
+        assertScript strictShell, '''
+            import groovy.transform.Pure
+
+            class A {
+                @Pure
+                BigDecimal calculate(BigDecimal price, BigDecimal tax) {
+                    return price.multiply(tax)
+                }
+            }
+            assert new A().calculate(100.0, 0.1) == 10.0
+        '''
+    }
+
+    // === Known pure methods on mutable types ===
+
+    @Test
+    void known_pure_methods_on_collections_pass() {
+        assertScript strictShell, '''
+            import groovy.transform.Pure
+
+            class A {
+                @Pure
+                int countItems(List items) {
+                    return items.size()
+                }
+
+                @Pure
+                boolean hasItem(List items, Object item) {
+                    return items.contains(item)
+                }
+            }
+            assert new A().countItems([1,2,3]) == 3
+            assert new A().hasItem([1,2,3], 2)
+        '''
+    }
+
+    // === NONDETERMINISM ===
+
+    @Test
+    void system_nanotime_fails_in_strict_mode() {
+        def err = shouldFail strictShell, '''
+            import groovy.transform.Pure
+
+            class A {
+                @Pure
+                long getTimestamp() {
+                    return System.nanoTime()
+                }
+            }
+        '''
+        assert err.message.contains('@Pure violation')
+        assert err.message.contains('non-deterministic')
+    }
+
+    @Test
+    void system_nanotime_passes_when_nondeterminism_allowed() {
+        assertScript nondetShell, '''
+            import groovy.transform.Pure
+
+            class A {
+                @Pure
+                long getTimestamp() {
+                    return System.nanoTime()
+                }
+            }
+            new A().getTimestamp()
+        '''
+    }
+
+    @Test
+    void math_random_fails_in_strict_mode() {
+        def err = shouldFail strictShell, '''
+            import groovy.transform.Pure
+
+            class A {
+                @Pure
+                double randomValue() {
+                    return Math.random()
+                }
+            }
+        '''
+        assert err.message.contains('@Pure violation')
+        assert err.message.contains('non-deterministic')
+    }
+
+    @Test
+    void new_date_fails_in_strict_mode() {
+        def err = shouldFail strictShell, '''
+            import groovy.transform.Pure
+
+            class A {
+                @Pure
+                Date now() {
+                    return new Date()
+                }
+            }
+        '''
+        assert err.message.contains('@Pure violation')
+        assert err.message.contains('non-deterministic')
+    }
+
+    @Test
+    void new_date_passes_when_nondeterminism_allowed() {
+        assertScript nondetShell, '''
+            import groovy.transform.Pure
+
+            class A {
+                @Pure
+                Date now() {
+                    return new Date()
+                }
+            }
+            new A().now()
+        '''
+    }
+
+    // === LOGGING ===
+
+    @Test
+    void println_fails_in_strict_mode() {
+        def err = shouldFail strictShell, '''
+            import groovy.transform.Pure
+
+            class A {
+                @Pure
+                int compute(int x) {
+                    println("computing $x")
+                    return x * 2
+                }
+            }
+        '''
+        assert err.message.contains('@Pure violation')
+        assert err.message.contains('logging')
+    }
+
+    @Test
+    void println_passes_when_logging_allowed() {
+        assertScript loggingShell, '''
+            import groovy.transform.Pure
+
+            class A {
+                @Pure
+                int compute(int x) {
+                    println("computing $x")
+                    return x * 2
+                }
+            }
+            assert new A().compute(5) == 10
+        '''
+    }
+
+    // === IO ===
+
+    @Test
+    void file_constructor_fails_in_strict_mode() {
+        def err = shouldFail strictShell, '''
+            import groovy.transform.Pure
+
+            class A {
+                @Pure
+                boolean fileExists(String path) {
+                    return new File(path).exists()
+                }
+            }
+        '''
+        assert err.message.contains('@Pure violation')
+        assert err.message.contains('I/O')
+    }
+
+    @Test
+    void file_constructor_passes_when_io_allowed() {
+        assertScript ioShell, '''
+            import groovy.transform.Pure
+
+            class A {
+                @Pure
+                boolean fileExists(String path) {
+                    return new File(path).exists()
+                }
+            }
+            new A().fileExists('/nonexistent')
+        '''
+    }
+
+    // === Combined allows ===
+
+    @Test
+    void combined_allows_logging_and_nondeterminism() {
+        def shell = makeShell('LOGGING|NONDETERMINISM')
+        assertScript shell, '''
+            import groovy.transform.Pure
+
+            class A {
+                @Pure
+                long computeWithLogging(int x) {
+                    println("computing at ${System.nanoTime()}")
+                    return x * 2L
+                }
+            }
+            new A().computeWithLogging(5)
+        '''
+    }
+
+    @Test
+    void combined_allows_still_flags_disallowed_categories() {
+        def shell = makeShell('LOGGING')
+        def err = shouldFail shell, '''
+            import groovy.transform.Pure
+
+            class A {
+                @Pure
+                long computeWithTime() {
+                    println("starting")
+                    return System.nanoTime()
+                }
+            }
+        '''
+        assert err.message.contains('non-deterministic')
+    }
+
+    // === No annotation — checker is silent ===
+
+    @Test
+    void no_pure_annotation_no_checking() {
+        assertScript strictShell, '''
+            class A {
+                int count = 0
+
+                void doAnything() {
+                    count++
+                    println("modified")
+                    System.nanoTime()
+                }
+            }
+            new A().doAnything()
+        '''
+    }
+
+    // === @Memoized methods are checked too ===
+
+    @Test
+    void memoized_method_with_field_write_fails() {
+        def err = shouldFail strictShell, '''
+            import groovy.transform.Memoized
+
+            class A {
+                int callCount = 0
+
+                @Memoized
+                int compute(int x) {
+                    callCount++
+                    return x * x
+                }
+            }
+        '''
+        assert err.message.contains('@Pure violation')
+    }
+
+    // === @SideEffectFree ===
+
+    @Test
+    void side_effect_free_method_allows_nondeterminism_implicitly() {
+        assertScript strictShell, '''
+            import java.lang.annotation.*
+
+            @Retention(RetentionPolicy.RUNTIME)
+            @Target(ElementType.METHOD)
+            @interface SideEffectFree {}
+
+            class A {
+                @SideEffectFree
+                long timestamped(int x) {
+                    return x + System.nanoTime()
+                }
+            }
+            new A().timestamped(1)
+        '''
+    }
+
+    @Test
+    void side_effect_free_method_still_rejects_field_writes() {
+        def err = shouldFail strictShell, '''
+            import java.lang.annotation.*
+
+            @Retention(RetentionPolicy.RUNTIME)
+            @Target(ElementType.METHOD)
+            @interface SideEffectFree {}
+
+            class A {
+                int count = 0
+
+                @SideEffectFree
+                int broken() {
+                    count++
+                    return count
+                }
+            }
+        '''
+        assert err.message.contains('@Pure violation')
+    }
+
+    // === @Contract(pure = true) ===
+
+    @Test
+    void contract_pure_method_is_checked() {
+        assertScript strictShell, '''
+            import java.lang.annotation.*
+
+            @Retention(RetentionPolicy.CLASS)
+            @Target(ElementType.METHOD)
+            @interface Contract {
+                String value() default ""
+                boolean pure() default false
+                String mutates() default ""
+            }
+
+            class A {
+                int value = 42
+
+                @Contract(pure = true)
+                int doubled() {
+                    return value * 2
+                }
+            }
+            assert new A().doubled() == 84
+        '''
+    }
+
+    @Test
+    void contract_pure_method_rejects_field_writes() {
+        def err = shouldFail strictShell, '''
+            import java.lang.annotation.*
+
+            @Retention(RetentionPolicy.CLASS)
+            @Target(ElementType.METHOD)
+            @interface Contract {
+                String value() default ""
+                boolean pure() default false
+                String mutates() default ""
+            }
+
+            class A {
+                int count = 0
+
+                @Contract(pure = true)
+                int broken() {
+                    count++
+                    return count
+                }
+            }
+        '''
+        assert err.message.contains('@Pure violation')
+    }
+
+    @Test
+    void contract_without_pure_is_not_checked() {
+        assertScript strictShell, '''
+            import java.lang.annotation.*
+
+            @Retention(RetentionPolicy.CLASS)
+            @Target(ElementType.METHOD)
+            @interface Contract {
+                String value() default ""
+                boolean pure() default false
+                String mutates() default ""
+            }
+
+            class A {
+                int count = 0
+
+                @Contract(value = "_ -> !null")
+                int notPure() {
+                    count++
+                    return count
+                }
+            }
+            assert new A().notPure() == 1
+        '''
+    }
+
+    // === Callee recognition ===
+
+    @Test
+    void pure_method_can_call_side_effect_free_callee() {
+        assertScript strictShell, '''
+            import groovy.transform.Pure
+            import java.lang.annotation.*
+
+            @Retention(RetentionPolicy.RUNTIME)
+            @Target(ElementType.METHOD)
+            @interface SideEffectFree {}
+
+            class A {
+                @SideEffectFree
+                int helper(int x) { return x * 2 }
+
+                @Pure
+                int compute(int x) { return helper(x) + 1 }
+            }
+            assert new A().compute(5) == 11
+        '''
+    }
+
+    @Test
+    void pure_method_can_call_contract_pure_callee() {
+        assertScript strictShell, '''
+            import groovy.transform.Pure
+            import java.lang.annotation.*
+
+            @Retention(RetentionPolicy.CLASS)
+            @Target(ElementType.METHOD)
+            @interface Contract {
+                String value() default ""
+                boolean pure() default false
+                String mutates() default ""
+            }
+
+            class A {
+                @Contract(pure = true)
+                int helper(int x) { return x * 2 }
+
+                @Pure
+                int compute(int x) { return helper(x) + 1 }
+            }
+            assert new A().compute(5) == 11
+        '''
+    }
+}


Reply via email to