borinquenkid commented on code in PR #16034:
URL: https://github.com/apache/grails-core/pull/16034#discussion_r3692289709


##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtils.java:
##########
@@ -47,7 +49,28 @@
  * @since 6.1
  */
 public class AstPropertyResolveUtils {
-    protected static Map<String, Map<String, ClassNode>> cachedClassProperties 
= new HashMap<>();
+
+    /**
+     * Cache of resolved properties per {@link ClassNode}.
+     * <p>
+     * Keyed by {@code ClassNode} identity rather than name. {@link 
ClassNode#equals(Object)} and
+     * {@link ClassNode#hashCode()} compare by {@link ClassNode#getText()} 
(essentially the class
+     * name), so a {@code Map} keyed by name - or even by {@code ClassNode} 
itself as the map key -
+     * treats any two distinct {@code ClassNode} instances that happen to 
share a name as the same
+     * cache entry. That collision is a real hazard for classes compiled 
without a package (common
+     * in tests and dynamically generated sources), and for the same source 
compiled more than once
+     * in separate {@code GroovyClassLoader}s: each compilation produces its 
own {@code ClassNode}
+     * instance that must never share cached property data with another 
compilation's instance of a
+     * same-named class. An {@link IdentityHashMap} avoids that collision 
entirely by comparing keys
+     * with {@code ==} instead of {@code equals()}.
+     * <p>
+     * Wrapped in {@link Collections#synchronizedMap(Map)} because AST 
transforms that populate and
+     * read this cache can run concurrently on multiple threads (e.g. parallel 
test execution within
+     * one JVM/fork); a plain, unsynchronized {@link HashMap} is not safe for 
concurrent structural
+     * modification and can corrupt its internal state under concurrent {@code 
put()} calls.
+     */
+    protected static final Map<ClassNode, Map<String, ClassNode>> 
cachedClassProperties =
+            Collections.synchronizedMap(new IdentityHashMap<>());

Review Comment:
   Agreed, and went with your suggested direction: the static map is gone 
entirely, and the cache is now stored as `ClassNode` metadata 
(`classNode.redirect().getNodeMetaData(key, fn)`), so a cache entry is only 
reachable through the node it describes and is collected with it. See the 
updated javadoc on `cachedClassProperties`'s replacement 
(`PROPERTIES_CACHE_KEY`) for the full writeup, including why keying on 
`redirect()` is safe here (`setRedirect()` throws for a *primary* node - i.e. 
every real caller of this utility, since they're all mid-compilation - so 
`redirect()` is just `this` for the node's whole life in practice) and why 
access ended up needing explicit per-node synchronization (some real callers 
can resolve to interned singletons like `ClassHelper.OBJECT_TYPE` for a plain 
`Object`/`def`-typed property, and those are shared across every compilation in 
the JVM, not scoped to one thread the way a normal node is). Landed in 
078ac9da53, tightened further in c3a4d9235b after a
 n adversarial self-review turned up the synchronization gap.



##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtils.java:
##########
@@ -47,7 +49,28 @@
  * @since 6.1
  */
 public class AstPropertyResolveUtils {
-    protected static Map<String, Map<String, ClassNode>> cachedClassProperties 
= new HashMap<>();
+
+    /**
+     * Cache of resolved properties per {@link ClassNode}.
+     * <p>
+     * Keyed by {@code ClassNode} identity rather than name. {@link 
ClassNode#equals(Object)} and
+     * {@link ClassNode#hashCode()} compare by {@link ClassNode#getText()} 
(essentially the class
+     * name), so a {@code Map} keyed by name - or even by {@code ClassNode} 
itself as the map key -
+     * treats any two distinct {@code ClassNode} instances that happen to 
share a name as the same
+     * cache entry. That collision is a real hazard for classes compiled 
without a package (common
+     * in tests and dynamically generated sources), and for the same source 
compiled more than once
+     * in separate {@code GroovyClassLoader}s: each compilation produces its 
own {@code ClassNode}
+     * instance that must never share cached property data with another 
compilation's instance of a
+     * same-named class. An {@link IdentityHashMap} avoids that collision 
entirely by comparing keys
+     * with {@code ==} instead of {@code equals()}.
+     * <p>
+     * Wrapped in {@link Collections#synchronizedMap(Map)} because AST 
transforms that populate and
+     * read this cache can run concurrently on multiple threads (e.g. parallel 
test execution within
+     * one JVM/fork); a plain, unsynchronized {@link HashMap} is not safe for 
concurrent structural
+     * modification and can corrupt its internal state under concurrent {@code 
put()} calls.
+     */
+    protected static final Map<ClassNode, Map<String, ClassNode>> 
cachedClassProperties =

Review Comment:
   Went further than private + `clearCache()` - the static map is gone entirely 
(078ac9da53), so there's no field left to expose or protect. That's still 
technically a breaking removal for anyone who referenced the old `protected` 
field directly, just a cleaner one than a silent type change. Given it's an 
internal implementation-detail field with no getter/documented extension use, 
I'm inclined to leave it out of the upgrade guide, but flagging it in case 
there's a reason to cover it there.



##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtils.java:
##########
@@ -47,7 +49,28 @@
  * @since 6.1
  */
 public class AstPropertyResolveUtils {
-    protected static Map<String, Map<String, ClassNode>> cachedClassProperties 
= new HashMap<>();
+
+    /**
+     * Cache of resolved properties per {@link ClassNode}.
+     * <p>
+     * Keyed by {@code ClassNode} identity rather than name. {@link 
ClassNode#equals(Object)} and
+     * {@link ClassNode#hashCode()} compare by {@link ClassNode#getText()} 
(essentially the class
+     * name), so a {@code Map} keyed by name - or even by {@code ClassNode} 
itself as the map key -
+     * treats any two distinct {@code ClassNode} instances that happen to 
share a name as the same
+     * cache entry. That collision is a real hazard for classes compiled 
without a package (common
+     * in tests and dynamically generated sources), and for the same source 
compiled more than once
+     * in separate {@code GroovyClassLoader}s: each compilation produces its 
own {@code ClassNode}
+     * instance that must never share cached property data with another 
compilation's instance of a
+     * same-named class. An {@link IdentityHashMap} avoids that collision 
entirely by comparing keys
+     * with {@code ==} instead of {@code equals()}.
+     * <p>
+     * Wrapped in {@link Collections#synchronizedMap(Map)} because AST 
transforms that populate and
+     * read this cache can run concurrently on multiple threads (e.g. parallel 
test execution within
+     * one JVM/fork); a plain, unsynchronized {@link HashMap} is not safe for 
concurrent structural
+     * modification and can corrupt its internal state under concurrent {@code 
put()} calls.

Review Comment:
   You're right, fixed (078ac9da53). The rationale no longer claims parallel 
test forks race on this - confirmed against `gradle/test-config.gradle` that 
`maxParallelForks` really does fork separate JVMs and this build never enables 
JUnit's in-JVM parallel execution, so that was never a real race. Rewrote the 
javadoc around the actual exposure instead: interned `ClassHelper` singletons 
(`OBJECT_TYPE`, `STRING_TYPE`, etc.) that any concurrently-running compilation 
in the same JVM could resolve to and touch through this cache - which is also 
why access is now synchronized per-node (c3a4d9235b) rather than resting on 
"only one thread ever touches a given node," which turned out not to be 
universally true.



##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtils.java:
##########
@@ -94,22 +117,25 @@ public static List<String> getPropertyNames(ClassNode 
classNode) {
     }
 
     private static Map<String, ClassNode> getPropertiesFromCache(ClassNode 
classNode) {
-        String className = classNode.getName();
-        Map<String, ClassNode> cachedProperties = 
cachedClassProperties.get(className);
+        Map<String, ClassNode> cachedProperties = 
cachedClassProperties.get(classNode);
         if (cachedProperties == null) {
-            cachedProperties = new HashMap<>();
+            Map<String, ClassNode> newProperties = new HashMap<>();
             boolean isDomainClass = AstUtils.isDomainClass(classNode);
             if (isDomainClass) {
-                cachedProperties.put(GormProperties.IDENTITY, new 
ClassNode(Long.class));
-                cachedProperties.put(GormProperties.VERSION, new 
ClassNode(Long.class));
+                newProperties.put(GormProperties.IDENTITY, new 
ClassNode(Long.class));
+                newProperties.put(GormProperties.VERSION, new 
ClassNode(Long.class));
             }
-            cachedClassProperties.put(className, cachedProperties);
             ClassNode currentNode = classNode;
             while (currentNode != null && 
!currentNode.equals(ClassHelper.OBJECT_TYPE)) {
-                populatePropertiesForClassNode(currentNode, cachedProperties, 
isDomainClass, !isDomainClass);
+                populatePropertiesForClassNode(currentNode, newProperties, 
isDomainClass, !isDomainClass);
                 currentNode = currentNode.getSuperClass();
             }
-        } return cachedProperties;
+            // Publish only once fully populated so a concurrent reader can 
never observe a
+            // partially-populated entry for this ClassNode.
+            cachedProperties = newProperties;
+            cachedClassProperties.put(classNode, cachedProperties);

Review Comment:
   `getNodeMetaData(key, fn)` uses `computeIfAbsent` internally (078ac9da53), 
and I additionally wrapped the call in `synchronized (cacheHolder)` 
(c3a4d9235b) after an adversarial self-review flagged that the backing 
`ListHashMap` is explicitly documented as not thread-safe, and that some real 
callers can land on interned singleton nodes (`ClassHelper.OBJECT_TYPE` etc.) 
that more than one concurrent compilation could reach - the "only one thread 
per node" assumption doesn't hold for those. See the javadoc for the precise 
scope of what the synchronization does and doesn't cover.



##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtils.java:
##########
@@ -94,22 +117,25 @@ public static List<String> getPropertyNames(ClassNode 
classNode) {
     }
 
     private static Map<String, ClassNode> getPropertiesFromCache(ClassNode 
classNode) {
-        String className = classNode.getName();
-        Map<String, ClassNode> cachedProperties = 
cachedClassProperties.get(className);
+        Map<String, ClassNode> cachedProperties = 
cachedClassProperties.get(classNode);
         if (cachedProperties == null) {
-            cachedProperties = new HashMap<>();
+            Map<String, ClassNode> newProperties = new HashMap<>();
             boolean isDomainClass = AstUtils.isDomainClass(classNode);
             if (isDomainClass) {
-                cachedProperties.put(GormProperties.IDENTITY, new 
ClassNode(Long.class));
-                cachedProperties.put(GormProperties.VERSION, new 
ClassNode(Long.class));
+                newProperties.put(GormProperties.IDENTITY, new 
ClassNode(Long.class));
+                newProperties.put(GormProperties.VERSION, new 
ClassNode(Long.class));
             }
-            cachedClassProperties.put(className, cachedProperties);
             ClassNode currentNode = classNode;
             while (currentNode != null && 
!currentNode.equals(ClassHelper.OBJECT_TYPE)) {
-                populatePropertiesForClassNode(currentNode, cachedProperties, 
isDomainClass, !isDomainClass);
+                populatePropertiesForClassNode(currentNode, newProperties, 
isDomainClass, !isDomainClass);
                 currentNode = currentNode.getSuperClass();
             }

Review Comment:
   Wanted to actually test this rather than argue about it. I reverted to the 
pre-fix cache and ran the exact "same source compiled twice" scenario 
`WhereQueryClosureCaptureSpec`'s second test exercises, 40 times sequentially 
in one JVM (simulating many test classes sharing one fork's static state) - it 
never diverged, even with the old buggy cache. So I can't point to a repro that 
pins the ~1% CI flake specifically to the name/identity-collision mechanism, 
and you may well be right that it isn't the actual trigger. I'm keeping the 
identity-safety fix regardless, since it's an independently real, provable bug 
on its own terms (the spec's second test shows two distinct, differently-shaped 
`ClassNode`s that happen to share a name getting their properties conflated 
under the old cache) - but I want to be upfront that I haven't confirmed it's 
*the* flake's cause, only that it's *a* real bug. I'll be watching the 
flaky-test dashboard (#16030) after this merges to see whether `WhereQueryCl
 osureCaptureSpec` actually clears.
   
   On the `isResolved()` snapshot mechanism specifically, I checked the Groovy 
5.0.7 source directly: `ClassNode.clazz` has no setter anywhere outside the 
`ClassNode(Class)` constructor, and `setRedirect()` throws a `GroovyBugError` 
if called on a *primary* node - which is what every real caller of this utility 
passes in, since they're all resolving a class mid-compilation. So for the 
nodes this cache actually serves, `redirect()` is simply the node itself for 
its entire life, and `isResolved()` can't flip after the node is first cached. 
I don't think the staleness you're describing can occur for this code path as 
it's actually used, though I agree the "cache once, forever" design would be 
fragile if it could - documented the reasoning (and the primary-node guarantee 
it leans on) in the class javadoc.



##########
grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtilsSpec.groovy:
##########
@@ -0,0 +1,101 @@
+/*
+ *  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
+ *
+ *    https://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.grails.datastore.gorm.transform
+
+import java.lang.reflect.Modifier
+
+import org.codehaus.groovy.ast.ClassHelper
+import org.codehaus.groovy.ast.ClassNode
+import spock.lang.Specification
+
+/**
+ * {@link AstPropertyResolveUtils} caches resolved property metadata in a 
static, process-wide
+ * map keyed by {@link ClassNode}. Two distinct compilations (e.g. the same 
source parsed in two
+ * different {@code GroovyClassLoader}s, as happens for dynamically-generated 
sources and in
+ * tests) produce distinct {@code ClassNode} instances that can legitimately 
share the exact same
+ * name - {@code ClassNode#equals(Object)} compares by name, so a naive name- 
or equals()-based
+ * cache key would conflate them, corrupting the resolved properties of one 
class with those of
+ * an unrelated class that happens to share its name. This spec proves the 
cache keys strictly by
+ * {@code ClassNode} identity, so same-named-but-distinct class nodes never 
contaminate each
+ * other's cached property data.
+ */
+class AstPropertyResolveUtilsSpec extends Specification {

Review Comment:
   Added both. Domain-class coverage now includes identity/version injection 
and `hasMany`/`belongsTo`/`hasOne` resolved two ways: via AST initial 
expressions (a hand-built `ClassNode` with a crafted `MapExpression`, matching 
what `static hasMany = [...]` compiles to) and via reflection on an 
already-resolved class (compiled through `GroovyClassLoader.parseClass` then 
re-wrapped with `ClassHelper.make()`, since that's the only way to get 
`isResolved() == true` for that branch).
   
   Also added two concurrency tests: many threads each resolving their own 
distinct, identically-named `ClassNode` (proves the identity-collision-freedom 
property holds under concurrent load, not just sequentially), and - after an 
adversarial self-review pointed out the first test can't exercise any real 
race, since nothing is shared between the threads - a second test where 32 
threads resolve the exact same shared `ClassNode` concurrently, which is what 
the `synchronized` fix mentioned above actually protects. Worth being honest 
about that second test's limits: I checked whether it fails without the 
`synchronized` guard, and it didn't, in 8 runs - the cached computation is 
deterministic and idempotent, so a black-box return-value test can't reliably 
force the underlying race into an observably wrong result. Said that directly 
in the test's comment rather than overclaiming what it proves; the 
synchronization is justified by `ListHashMap`'s own "not thread-safe" 
documentation, not by 
 this test having caught a live bug.



##########
grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtilsSpec.groovy:
##########
@@ -0,0 +1,101 @@
+/*
+ *  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
+ *
+ *    https://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.grails.datastore.gorm.transform
+
+import java.lang.reflect.Modifier
+
+import org.codehaus.groovy.ast.ClassHelper
+import org.codehaus.groovy.ast.ClassNode
+import spock.lang.Specification
+
+/**
+ * {@link AstPropertyResolveUtils} caches resolved property metadata in a 
static, process-wide
+ * map keyed by {@link ClassNode}. Two distinct compilations (e.g. the same 
source parsed in two
+ * different {@code GroovyClassLoader}s, as happens for dynamically-generated 
sources and in
+ * tests) produce distinct {@code ClassNode} instances that can legitimately 
share the exact same
+ * name - {@code ClassNode#equals(Object)} compares by name, so a naive name- 
or equals()-based
+ * cache key would conflate them, corrupting the resolved properties of one 
class with those of
+ * an unrelated class that happens to share its name. This spec proves the 
cache keys strictly by
+ * {@code ClassNode} identity, so same-named-but-distinct class nodes never 
contaminate each
+ * other's cached property data.
+ */
+class AstPropertyResolveUtilsSpec extends Specification {
+
+    void "property lookups for two same-named ClassNodes in different packages 
do not corrupt each other"() {
+        given: 'two distinct ClassNodes with the same simple name declared in 
different packages'
+        ClassNode first = new ClassNode('org.example.one.Widget', 
Modifier.PUBLIC, ClassHelper.OBJECT_TYPE)
+        first.addProperty('color', Modifier.PUBLIC, ClassHelper.STRING_TYPE, 
null, null, null)
+
+        ClassNode second = new ClassNode('org.example.two.Widget', 
Modifier.PUBLIC, ClassHelper.OBJECT_TYPE)
+        second.addProperty('weight', Modifier.PUBLIC, 
ClassHelper.Integer_TYPE, null, null, null)
+
+        when: 'the first class node is resolved, populating its cache entry'
+        List<String> firstProperties = 
AstPropertyResolveUtils.getPropertyNames(first)
+
+        then: 'only its own property is resolved'
+        firstProperties.contains('color')
+        !firstProperties.contains('weight')
+
+        when: 'the second, differently-packaged, same-simple-name class node 
is resolved'
+        List<String> secondProperties = 
AstPropertyResolveUtils.getPropertyNames(second)
+
+        then: 'its own property is resolved, not leaked from the first class 
node'
+        secondProperties.contains('weight')
+        !secondProperties.contains('color')
+
+        and: 'the first class node cache entry remains unaffected by resolving 
the second'
+        List<String> firstPropertiesAfter = 
AstPropertyResolveUtils.getPropertyNames(first)
+        firstPropertiesAfter.contains('color')
+        !firstPropertiesAfter.contains('weight')
+    }
+
+    void "property lookups for two distinct ClassNode instances with the exact 
same unqualified name do not corrupt each other"() {
+        given: 'two distinct ClassNode instances - as produced by two separate 
compilations - sharing an identical unqualified name'
+        ClassNode first = new ClassNode('Widget', Modifier.PUBLIC, 
ClassHelper.OBJECT_TYPE)
+        first.addProperty('color', Modifier.PUBLIC, ClassHelper.STRING_TYPE, 
null, null, null)
+
+        ClassNode second = new ClassNode('Widget', Modifier.PUBLIC, 
ClassHelper.OBJECT_TYPE)
+        second.addProperty('weight', Modifier.PUBLIC, 
ClassHelper.Integer_TYPE, null, null, null)
+
+        expect: 'the two ClassNode instances compare equal by name - the exact 
condition that would collide in a name-keyed or equals()-keyed cache'
+        first == second
+        first.hashCode() == second.hashCode()
+        !first.is(second)
+
+        when: 'both class nodes are resolved'
+        List<String> firstProperties = 
AstPropertyResolveUtils.getPropertyNames(first)
+        List<String> secondProperties = 
AstPropertyResolveUtils.getPropertyNames(second)
+
+        then: 'each keeps its own, independently-resolved properties despite 
comparing equal'
+        firstProperties.contains('color')
+        !firstProperties.contains('weight')
+        secondProperties.contains('weight')
+        !secondProperties.contains('color')
+    }
+
+    void "getPropertyType resolves and caches the type of a declared 
property"() {
+        given: 'a class node with a declared property'
+        ClassNode classNode = new ClassNode('org.example.PropertyTypeWidget', 
Modifier.PUBLIC, ClassHelper.OBJECT_TYPE)
+        classNode.addProperty('label', Modifier.PUBLIC, 
ClassHelper.STRING_TYPE, null, null, null)
+
+        expect: 'the resolved property type matches the declared type, both on 
first (cache-populating) and second (cache-hit) lookup'
+        AstPropertyResolveUtils.getPropertyType(classNode, 'label') == 
ClassHelper.STRING_TYPE
+        AstPropertyResolveUtils.getPropertyType(classNode, 'label') == 
ClassHelper.STRING_TYPE

Review Comment:
   Replaced it. The new test adds a second property to the `ClassNode` after 
the first `getPropertyNames` lookup and asserts the second lookup doesn't see 
it - `getPropertyNames` has no live-fallback path (unlike `getPropertyType`, 
which falls through to a direct `classNode.getProperty()` lookup when the cache 
doesn't contain the key), so this actually proves the result was cached rather 
than recomputed. It also asserts, via a direct `classNode.getProperty('extra') 
!= null` check, that the property really was added to the underlying node - so 
the test demonstrates staleness specifically, not just a lookup that happens to 
return nothing.



##########
grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/transform/AstPropertyResolveUtilsSpec.groovy:
##########
@@ -0,0 +1,101 @@
+/*
+ *  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
+ *
+ *    https://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.grails.datastore.gorm.transform
+
+import java.lang.reflect.Modifier
+
+import org.codehaus.groovy.ast.ClassHelper
+import org.codehaus.groovy.ast.ClassNode
+import spock.lang.Specification
+
+/**
+ * {@link AstPropertyResolveUtils} caches resolved property metadata in a 
static, process-wide
+ * map keyed by {@link ClassNode}. Two distinct compilations (e.g. the same 
source parsed in two
+ * different {@code GroovyClassLoader}s, as happens for dynamically-generated 
sources and in
+ * tests) produce distinct {@code ClassNode} instances that can legitimately 
share the exact same
+ * name - {@code ClassNode#equals(Object)} compares by name, so a naive name- 
or equals()-based
+ * cache key would conflate them, corrupting the resolved properties of one 
class with those of
+ * an unrelated class that happens to share its name. This spec proves the 
cache keys strictly by
+ * {@code ClassNode} identity, so same-named-but-distinct class nodes never 
contaminate each
+ * other's cached property data.
+ */
+class AstPropertyResolveUtilsSpec extends Specification {
+
+    void "property lookups for two same-named ClassNodes in different packages 
do not corrupt each other"() {
+        given: 'two distinct ClassNodes with the same simple name declared in 
different packages'
+        ClassNode first = new ClassNode('org.example.one.Widget', 
Modifier.PUBLIC, ClassHelper.OBJECT_TYPE)
+        first.addProperty('color', Modifier.PUBLIC, ClassHelper.STRING_TYPE, 
null, null, null)
+
+        ClassNode second = new ClassNode('org.example.two.Widget', 
Modifier.PUBLIC, ClassHelper.OBJECT_TYPE)
+        second.addProperty('weight', Modifier.PUBLIC, 
ClassHelper.Integer_TYPE, null, null, null)
+
+        when: 'the first class node is resolved, populating its cache entry'
+        List<String> firstProperties = 
AstPropertyResolveUtils.getPropertyNames(first)
+
+        then: 'only its own property is resolved'
+        firstProperties.contains('color')
+        !firstProperties.contains('weight')
+
+        when: 'the second, differently-packaged, same-simple-name class node 
is resolved'
+        List<String> secondProperties = 
AstPropertyResolveUtils.getPropertyNames(second)
+
+        then: 'its own property is resolved, not leaked from the first class 
node'
+        secondProperties.contains('weight')
+        !secondProperties.contains('color')
+
+        and: 'the first class node cache entry remains unaffected by resolving 
the second'
+        List<String> firstPropertiesAfter = 
AstPropertyResolveUtils.getPropertyNames(first)
+        firstPropertiesAfter.contains('color')
+        !firstPropertiesAfter.contains('weight')
+    }
+
+    void "property lookups for two distinct ClassNode instances with the exact 
same unqualified name do not corrupt each other"() {
+        given: 'two distinct ClassNode instances - as produced by two separate 
compilations - sharing an identical unqualified name'
+        ClassNode first = new ClassNode('Widget', Modifier.PUBLIC, 
ClassHelper.OBJECT_TYPE)
+        first.addProperty('color', Modifier.PUBLIC, ClassHelper.STRING_TYPE, 
null, null, null)
+
+        ClassNode second = new ClassNode('Widget', Modifier.PUBLIC, 
ClassHelper.OBJECT_TYPE)
+        second.addProperty('weight', Modifier.PUBLIC, 
ClassHelper.Integer_TYPE, null, null, null)
+
+        expect: 'the two ClassNode instances compare equal by name - the exact 
condition that would collide in a name-keyed or equals()-keyed cache'
+        first == second
+        first.hashCode() == second.hashCode()

Review Comment:
   Done - trimmed to just `!first.is(second)`, with a comment explaining why 
the `equals()`/`hashCode()` equality matters (it's the exact collision 
condition a name- or `equals()`-keyed cache would hit) rather than asserting on 
Groovy's own `ClassNode` equality contract.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to