You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2026/08/23 21:28:30 UTC

(groovy) branch master updated: GROOVY-12290: Private field access from another class compiles under @TypeChecked but fails under @CompileStatic

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

paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git


The following commit(s) were added to refs/heads/master by this push:
     new 0f217d25d3 GROOVY-12290: Private field access from another class compiles under @TypeChecked but fails under @CompileStatic
0f217d25d3 is described below

commit 0f217d25d3ae3caeadd88b404699ac384e0c37e1
Author: Paul King <pa...@asert.com.au>
AuthorDate: Sun Aug 23 16:39:56 2026 +1000

    GROOVY-12290: Private field access from another class compiles under @TypeChecked but fails under @CompileStatic
---
 .../transform/stc/StaticTypeCheckingVisitor.java   | 60 +++++++++++++++++++---
 src/test/groovy/bugs/Groovy7165.groovy             |  3 +-
 .../transform/stc/ConstructorsSTCTest.groovy       |  9 ++--
 .../stc/FieldsAndPropertiesSTCTest.groovy          | 48 +++++++++++++++++
 .../asm/sc/StaticCompileConstructorsTest.groovy    | 12 ++---
 5 files changed, 113 insertions(+), 19 deletions(-)

diff --git a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java
index f942a365f2..5f2e7861e8 100644
--- a/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java
+++ b/src/main/java/org/codehaus/groovy/transform/stc/StaticTypeCheckingVisitor.java
@@ -49,6 +49,7 @@ import org.codehaus.groovy.ast.MethodNode;
 import org.codehaus.groovy.ast.MultipleAssignmentMetadata;
 import org.codehaus.groovy.ast.Parameter;
 import org.codehaus.groovy.ast.PropertyNode;
+import org.codehaus.groovy.ast.RecordComponentNode;
 import org.codehaus.groovy.ast.Variable;
 import org.codehaus.groovy.ast.expr.AnnotationConstantExpression;
 import org.codehaus.groovy.ast.expr.ArgumentListExpression;
@@ -653,6 +654,20 @@ public class StaticTypeCheckingVisitor extends ClassCodeVisitorSupport {
         }
     }
 
+    /**
+     * Determines if the two classes belong to the same nest, so a private-access
+     * bridge can be generated between them. Identity comparison is deliberate:
+     * nest-mates only arise within a single source unit, where the outer-class
+     * chain shares {@code ClassNode} instances; name-based {@code equals} could
+     * conflate same-named classes from different origins. Accessibility decisions
+     * ({@link #isFieldAccessible}) must use the same predicate as bridge marking
+     * ({@code checkOrMarkPrivateAccess}), or an access could be admitted for
+     * which no bridge is generated.
+     */
+    private static boolean inSameNest(final ClassNode a, final ClassNode b) {
+        return getNestHost(a) == getNestHost(b);
+    }
+
     /**
      * Checks for private field access from closure or nestmate.
      */
@@ -660,7 +675,7 @@ public class StaticTypeCheckingVisitor extends ClassCodeVisitorSupport {
         if (fn != null && fn.isPrivate() && !fn.isSynthetic()) {
             ClassNode declaringClass = fn.getDeclaringClass();
             ClassNode enclosingClass = typeCheckingContext.getEnclosingClassNode();
-            if (declaringClass == enclosingClass ? typeCheckingContext.getEnclosingClosure() != null : getNestHost(declaringClass) == getNestHost(enclosingClass)) {
+            if (declaringClass == enclosingClass ? typeCheckingContext.getEnclosingClosure() != null : inSameNest(declaringClass, enclosingClass)) {
                 source.putNodeMetaData(lhsOfAssignment ? PV_FIELDS_MUTATION : PV_FIELDS_ACCESS, fn);
             }
         }
@@ -673,7 +688,7 @@ public class StaticTypeCheckingVisitor extends ClassCodeVisitorSupport {
         ClassNode declaringClass = mn.getDeclaringClass();
         ClassNode enclosingClass = typeCheckingContext.getEnclosingClassNode();
         if (declaringClass != enclosingClass || typeCheckingContext.getEnclosingClosure() != null) {
-            if (mn.isPrivate() && getNestHost(declaringClass) == getNestHost(enclosingClass)) {
+            if (mn.isPrivate() && inSameNest(declaringClass, enclosingClass)) {
                 if (source instanceof MethodReferenceExpression // GROOVY-11301: access bridge for lambda
                         && Float.parseFloat(getSourceUnit().getConfiguration().getTargetBytecode()) < 15)
                     declaringClass.getNodeMetaData(PV_METHODS_ACCESS, k -> new LinkedHashSet<MethodNode>()).add(mn);
@@ -2241,6 +2256,16 @@ out:    if ((samParameterTypes.length == 1 && isOrImplements(samParameterTypes[0
                     getter = getGetterMethod(current, getterName, checkUp);
                     getter = allowStaticAccessToMember(getter, staticOnly);
                 }
+                if (getter == null && current.isRecord()) {
+                    // GROOVY-12225: a precompiled record's component accessor is named for the
+                    // component (e.g. "x()"), so there is no get/is getter and no PropertyNode
+                    for (RecordComponentNode rcn : current.getRecordComponents()) {
+                        if (rcn.getName().equals(propertyName)) {
+                            getter = allowStaticAccessToMember(current.getDeclaredMethod(propertyName, Parameter.EMPTY_ARRAY), staticOnly);
+                            break;
+                        }
+                    }
+                }
                 if (getter != null && ((publicOnly && (!getter.isPublic() || propertyName.equals("class") || propertyName.equals("empty")))
                         // GROOVY-11319:
                         || !hasAccessToMember(typeCheckingContext.getEnclosingClassNode(), getter.getDeclaringClass(), getter.getModifiers()))) {
@@ -2284,7 +2309,8 @@ out:    if ((samParameterTypes.length == 1 && isOrImplements(samParameterTypes[0
                             }
                             pexp.removeNodeMetaData(READONLY_PROPERTY);
                             return true;
-                        } else if (getter != null && (field == null || field.isFinal()) && setters.isEmpty()) {
+                        } else if (getter != null && setters.isEmpty() && (field == null || field.isFinal()
+                                || !isFieldAccessible(field, receiverType, pexp, receiver.getData()))) { // GROOVY-12290: inaccessible field cannot make the property writable
                             pexp.putNodeMetaData(READONLY_PROPERTY, Boolean.TRUE); // GROOVY-9127
                         }
                     }
@@ -2526,10 +2552,32 @@ out:    if ((samParameterTypes.length == 1 && isOrImplements(samParameterTypes[0
         return method instanceof ExtensionMethodNode ? ((ExtensionMethodNode) method).isStaticExtension() : method.isStatic();
     }
 
-    private boolean storeField(final FieldNode field, final PropertyExpression expressionToStoreOn, final ClassNode receiver, final ClassCodeVisitorSupport visitor, final String delegationData, final boolean lhsOfAssignment) {
-        boolean superField = isSuperExpression(expressionToStoreOn.getObjectExpression());
-        boolean accessible = (!superField && receiver.equals(field.getDeclaringClass()) && !field.getDeclaringClass().isAbstract()) // GROOVY-7300, GROOVY-11358
+    /**
+     * Determines if the field can back the given property (or attribute)
+     * expression: accessible by Java rules, or admitted by the receiver-type
+     * leniency (GROOVY-7300, GROOVY-11358) that models dynamic Groovy's
+     * permissive private access. Since GROOVY-12290 that leniency no longer
+     * admits plain property syntax to a private field of a foreign nest — a
+     * direct field access that static compilation cannot honour (no access
+     * bridge exists). The deliberate dynamic escape hatches remain: attribute
+     * access (.@), and closure bodies (GROOVY-9195) — including delegate-resolved
+     * access — whose property dispatch stays dynamic-capable under static compilation.
+     */
+    private boolean isFieldAccessible(final FieldNode field, final ClassNode receiver, final PropertyExpression expression, final String delegationData) {
+        boolean superField = isSuperExpression(expression.getObjectExpression());
+        boolean exactReceiver = (!superField && receiver.equals(field.getDeclaringClass()) && !field.getDeclaringClass().isAbstract()); // GROOVY-7300, GROOVY-11358
+        if (exactReceiver && field.isPrivate() && delegationData == null
+                && typeCheckingContext.getEnclosingClosure() == null
+                && !(expression instanceof AttributeExpression)
+                && !inSameNest(field.getDeclaringClass(), typeCheckingContext.getEnclosingClassNode())) {
+            exactReceiver = false; // GROOVY-12290
+        }
+        return exactReceiver
                 || hasAccessToMember(typeCheckingContext.getEnclosingClassNode(), field.getDeclaringClass(), field.getModifiers());
+    }
+
+    private boolean storeField(final FieldNode field, final PropertyExpression expressionToStoreOn, final ClassNode receiver, final ClassCodeVisitorSupport visitor, final String delegationData, final boolean lhsOfAssignment) {
+        boolean accessible = isFieldAccessible(field, receiver, expressionToStoreOn, delegationData);
         if (!accessible) {
             if (expressionToStoreOn instanceof AttributeExpression) {
                 addStaticTypeError("Cannot access field: " + field.getName() + " of class: " + prettyPrintTypeName(field.getDeclaringClass()), expressionToStoreOn.getProperty());
diff --git a/src/test/groovy/bugs/Groovy7165.groovy b/src/test/groovy/bugs/Groovy7165.groovy
index 2b50224f35..5a49b46a4a 100644
--- a/src/test/groovy/bugs/Groovy7165.groovy
+++ b/src/test/groovy/bugs/Groovy7165.groovy
@@ -90,6 +90,7 @@ final class Groovy7165 {
             }
             assert false : 'compilation should fail'
         '''
-        assert err.message =~ /Access to A#CONST is forbidden/
+        // GROOVY-12290: reported by the type checker, no longer late by the static writer
+        assert err.message =~ /No such property: CONST for Class or static property for class: A/
     }
 }
diff --git a/src/test/groovy/groovy/transform/stc/ConstructorsSTCTest.groovy b/src/test/groovy/groovy/transform/stc/ConstructorsSTCTest.groovy
index 1cbef1626c..06a6747b66 100644
--- a/src/test/groovy/groovy/transform/stc/ConstructorsSTCTest.groovy
+++ b/src/test/groovy/groovy/transform/stc/ConstructorsSTCTest.groovy
@@ -568,12 +568,15 @@ import static groovy.test.GroovyAssert.shouldFail
         'Cannot set read-only property: foo','@ line 5, column 19'
     }
 
-    // GROOVY-11956
+    // GROOVY-11956, GROOVY-12290: the protected setter is out of scope and the
+    // private field is in a foreign nest, so the property is read-only in both
+    // type-checked and statically-compiled code
     @Test
     void testMapStyleConstructorWithInaccessibleSetter() {
-        assertScript """import ${Pojo11956.canonicalName}
+        shouldFailWithMessages """import ${Pojo11956.canonicalName}
             new Pojo11956(foo: 'bar')
-        """
+        """,
+        'Cannot set read-only property: foo'
     }
 
     // GROOVY-10787
diff --git a/src/test/groovy/groovy/transform/stc/FieldsAndPropertiesSTCTest.groovy b/src/test/groovy/groovy/transform/stc/FieldsAndPropertiesSTCTest.groovy
index 753cc335f5..26dcbf13e6 100644
--- a/src/test/groovy/groovy/transform/stc/FieldsAndPropertiesSTCTest.groovy
+++ b/src/test/groovy/groovy/transform/stc/FieldsAndPropertiesSTCTest.groovy
@@ -1887,6 +1887,54 @@ class FieldsAndPropertiesSTCTest extends StaticTypeCheckingTestCase {
         'Cannot access field: f of class: A', 'No such property: f for class: A'
     }
 
+    // GROOVY-12290
+    @Test
+    void testPrivateFieldOfForeignNestViaPropertySyntax() {
+        shouldFailWithMessages '''
+            class P { private static final Map M = [a: 1] }
+            def m() { P.M }
+        ''',
+        'No such property: M for Class or static property for class: P'
+
+        shouldFailWithMessages '''
+            class P { private final Map f = [a: 1] }
+            def m() { new P().f }
+        ''',
+        'No such property: f for class: P'
+
+        shouldFailWithMessages '''
+            class P { private static Map W = [:] }
+            def m() { P.W = [b: 2] }
+        ''',
+        'No such property: W for Class or static property for class: P'
+    }
+
+    // GROOVY-12290: the deliberate escape hatches into dynamic private access
+    // keep working: attribute syntax and delegate-resolved access in closures
+    @Test
+    void testPrivateFieldOfForeignNestViaAttributeOrDelegate() {
+        assertScript '''
+            class P { private final Map f = [a: 1] }
+            assert new P().@f == [a: 1]
+        '''
+        assertScript '''
+            class P { private final Map f = [a: 1] }
+            assert new P().with { f } == [a: 1]
+        '''
+    }
+
+    // GROOVY-12290: nest-mates go through access bridges, not the receiver-type leniency
+    @Test
+    void testPrivateFieldOfNestMateViaPropertySyntax() {
+        assertScript '''
+            class Outer {
+                private static final Map M = [a: 1]
+                static class Inner { def m() { M } }
+            }
+            assert new Outer.Inner().m() == [a: 1]
+        '''
+    }
+
     // GROOVY-5737
     @Test
     void testGeneratedFieldAccessInClosure() {
diff --git a/src/test/groovy/org/codehaus/groovy/classgen/asm/sc/StaticCompileConstructorsTest.groovy b/src/test/groovy/org/codehaus/groovy/classgen/asm/sc/StaticCompileConstructorsTest.groovy
index 02756215ee..b937f9e7cc 100644
--- a/src/test/groovy/org/codehaus/groovy/classgen/asm/sc/StaticCompileConstructorsTest.groovy
+++ b/src/test/groovy/org/codehaus/groovy/classgen/asm/sc/StaticCompileConstructorsTest.groovy
@@ -19,7 +19,6 @@
 package org.codehaus.groovy.classgen.asm.sc
 
 import groovy.transform.stc.ConstructorsSTCTest
-import groovy.transform.stc.Pojo11956
 import org.junit.jupiter.api.Test
 
 /**
@@ -128,12 +127,7 @@ final class StaticCompileConstructorsTest extends ConstructorsSTCTest implements
         assert !asserts.contains('INVOKESTATIC org/codehaus/groovy/runtime/ScriptBytecodeAdapter.setGroovyObjectProperty')
     }
 
-    // GROOVY-11956
-    @Override @Test
-    void testMapStyleConstructorWithInaccessibleSetter() {
-        shouldFailWithMessages """import ${Pojo11956.canonicalName}
-            new Pojo11956(foo: 'bar')
-        """,
-        "Cannot access method: setFoo(java.lang.String) of class: ${Pojo11956.canonicalName} @ line 2, column 27"
-    }
+    // GROOVY-11956, GROOVY-12290: testMapStyleConstructorWithInaccessibleSetter is
+    // inherited: the read-only property error is now reported by the type checker,
+    // identically for @TypeChecked and @CompileStatic
 }