From 013cb13343ded14a367750ded51604e827e91e13 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sun, 5 Apr 2026 15:12:51 -0400 Subject: [PATCH 01/26] canary: test Groovy 6.0.0-SNAPSHOT Bumps groovy.version to 6.0.0-SNAPSHOT (from 5.0.3) to see what breaks. Snapshot resolves from https://repository.apache.org/content/groups/snapshots which was already configured in build-logic/GrailsRepoSettingsPlugin.groovy for the org.apache.groovy.* group. Changes needed on top of the Groovy 5.0.3 canary: - gradle/test-config.gradle: apply '-Dspock.iKnowWhatImDoing.disableGroovyVersionCheck=true' to every GroovyCompile task, not just compileGroovy/compileTestGroovy. Spock 2.4-groovy-5.0 is the latest available and refuses to run against Groovy 6 without this flag; since SpockTransform is registered via META-INF/services, the Groovy compiler loads it for every source set (including main) and main compiles fail without the flag being set globally. - DefaultHalViewHelper.groovy: reorder the (association instanceof ToMany && !(association instanceof Basic)) / else if (association instanceof ToOne) cascade to check ToOne first. Groovy 6's flow typing narrows 'association' in the else branch in a way that conflicts with the later 'instanceof ToOne' check (Incompatible instanceof types: Basic and ToOne). The reordered form is equivalent because ToOne and ToMany are sibling Association subtypes. - AbstractHibernateGormInstanceApi.groovy: fix a pre-existing operator-precedence bug caught by Groovy 6's stricter instanceof type checking. before: if (association instanceof ToOne && !association instanceof Embedded) { after: if (association instanceof ToOne && !(association instanceof Embedded)) { Without the parentheses '!association' is evaluated first (to a boolean) and then 'instanceof Embedded' is checked against a boolean, which is always false - the whole left side of the && had been dead code. Groovy 6 now reports this as 'Incompatible instanceof types: boolean and Embedded'. Known still-failing: grails-geb:compileTestFixturesGroovy still triggers the ASM Frame.putAbstractType bug that was the reason we pinned to Groovy 5.0.3. Same bytecode-generation issue carries forward to 6.0.0-SNAPSHOT. --- dependencies.gradle | 2 +- gradle/test-config.gradle | 11 ++++++----- .../hibernate/AbstractHibernateGormInstanceApi.groovy | 2 +- .../view/api/internal/DefaultHalViewHelper.groovy | 4 ++-- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/dependencies.gradle b/dependencies.gradle index e8553c7c6b0..0986f6bf1a4 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -74,7 +74,7 @@ ext { 'commons-codec.version' : '1.18.0', 'commons-lang3.version' : '3.20.0', 'geb-spock.version' : '8.0.1', - 'groovy.version' : '5.0.3', + 'groovy.version' : '6.0.0-SNAPSHOT', 'jackson.version' : '2.21.2', 'jquery.version' : '3.7.1', 'liquibase-hibernate5.version': '4.27.0', diff --git a/gradle/test-config.gradle b/gradle/test-config.gradle index b6de1a6517e..2926f89e91b 100644 --- a/gradle/test-config.gradle +++ b/gradle/test-config.gradle @@ -35,11 +35,12 @@ dependencies { add('testRuntimeOnly', 'org.objenesis:objenesis') } -tasks.named('compileTestGroovy') { - options.forkOptions.jvmArgs += ['-Dspock.iKnowWhatImDoing.disableGroovyVersionCheck=true'] -} - -tasks.named('compileGroovy') { +// Disable Spock's compile-time Groovy version check on ALL Groovy compile +// tasks. Spock's SpockTransform is registered via META-INF services and +// the Groovy compiler loads every AST transform on the classpath, so even +// main source sets trip the version check when Groovy is newer than the +// Spock artifact's groovy variant. +tasks.withType(GroovyCompile).configureEach { options.forkOptions.jvmArgs += ['-Dspock.iKnowWhatImDoing.disableGroovyVersionCheck=true'] } diff --git a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/AbstractHibernateGormInstanceApi.groovy b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/AbstractHibernateGormInstanceApi.groovy index 5b7c18d66bc..3f3abea76b3 100644 --- a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/AbstractHibernateGormInstanceApi.groovy +++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/AbstractHibernateGormInstanceApi.groovy @@ -398,7 +398,7 @@ abstract class AbstractHibernateGormInstanceApi extends GormInstanceApi { setObjectToReadOnly(target) if (entity) { for (Association association in entity.associations) { - if (association instanceof ToOne && !association instanceof Embedded) { + if (association instanceof ToOne && !(association instanceof Embedded)) { if (proxyHandler.isInitialized(target, association.name)) { def bean = new BeanWrapperImpl(target) def propertyValue = bean.getPropertyValue(association.name) diff --git a/grails-views-gson/src/main/groovy/grails/plugin/json/view/api/internal/DefaultHalViewHelper.groovy b/grails-views-gson/src/main/groovy/grails/plugin/json/view/api/internal/DefaultHalViewHelper.groovy index 093784f1842..8d46a8ec4b0 100644 --- a/grails-views-gson/src/main/groovy/grails/plugin/json/view/api/internal/DefaultHalViewHelper.groovy +++ b/grails-views-gson/src/main/groovy/grails/plugin/json/view/api/internal/DefaultHalViewHelper.groovy @@ -320,13 +320,13 @@ class DefaultHalViewHelper extends DefaultJsonViewHelper implements HalViewHelpe def value = entityReflector.getProperty(object, propertyName) if (value != null) { - if (association instanceof ToMany && !(association instanceof Basic)) { + if (association instanceof ToOne) { if (deep || expandProperties.contains(propertyName) || proxyHandler == null || proxyHandler.isInitialized(value)) { embeddedValues.put((Association) association, value) } excs.add(propertyName) } - else if (association instanceof ToOne) { + else if (association instanceof ToMany && !(association instanceof Basic)) { if (deep || expandProperties.contains(propertyName) || proxyHandler == null || proxyHandler.isInitialized(value)) { embeddedValues.put((Association) association, value) } From 44634943564173bdf52086a62dd029c78b819aab Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sun, 5 Apr 2026 15:23:06 -0400 Subject: [PATCH 02/26] fix: Groovy 6 VerifyError in DefaultConstraintFactory default parameter Groovy 6.0.0-SNAPSHOT generates invalid bytecode for constructors that use a default-valued List parameter inside @CompileStatic classes. Decompiled stack frames show Object where ArrayList is expected: Type 'java/lang/Object' (current frame, stack[4]) is not assignable to 'java/util/ArrayList' at DefaultConstraintFactory.(Class, MessageSource):V This breaks every validateable. At runtime VerifyError is raised the first time the default-parameter overload is constructed, which cascades into Validateable.validate(), grails-datastore-core bean wiring, and any test that exercises constraints. Workaround: replace the default-parameter signature with two explicit constructors (the 2-arg one delegates to the 3-arg one with [Object.class] as List). This is compilation-compatible - users were already allowed to construct with or without the targetTypes arg. --- .../constraints/factory/DefaultConstraintFactory.groovy | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/factory/DefaultConstraintFactory.groovy b/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/factory/DefaultConstraintFactory.groovy index 001acf048f4..88e4178820e 100644 --- a/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/factory/DefaultConstraintFactory.groovy +++ b/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/factory/DefaultConstraintFactory.groovy @@ -47,7 +47,11 @@ class DefaultConstraintFactory implements ConstraintFactory { protected final Constructor constraintConstructor - DefaultConstraintFactory(Class constraintClass, MessageSource messageSource, List targetTypes = [Object]) { + DefaultConstraintFactory(Class constraintClass, MessageSource messageSource) { + this(constraintClass, messageSource, [Object.class] as List) + } + + DefaultConstraintFactory(Class constraintClass, MessageSource messageSource, List targetTypes) { this.type = constraintClass this.name = Introspector.decapitalize(constraintClass.simpleName) - 'Constraint' this.messageSource = messageSource From c19037d0ddfce7a7cb9a58c1309fba77f6949750 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Mon, 6 Apr 2026 09:36:12 -0400 Subject: [PATCH 03/26] fix: Groovy 6 compile fixes - Spock version check and CycloneDX license Add spock.iKnowWhatImDoing.disableGroovyVersionCheck to all shared test configs (hibernate5, mongodb, mongodb-forked, functional) via tasks.withType(GroovyCompile).configureEach. The flag was only in test-config.gradle, so modules using other configs failed with IncompatibleGroovyVersionException on Groovy 6. In functional-test-config.gradle, replace the per-task-name flags with the configureEach pattern to also cover compileIntegrationTestGroovy and other custom source sets. Add CycloneDX license override for org.jline/jansi@4.0.7 (BSD-3-Clause) which is pulled in by Groovy 6.0.0-SNAPSHOT's jline dependency upgrade. Assisted-by: Claude Code --- .../groovy/org/apache/grails/buildsrc/SbomPlugin.groovy | 1 + gradle/functional-test-config.gradle | 6 +----- gradle/hibernate5-test-config.gradle | 4 ++++ gradle/mongodb-forked-test-config.gradle | 4 ++++ gradle/mongodb-test-config.gradle | 4 ++++ 5 files changed, 14 insertions(+), 5 deletions(-) diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy index 7f97736a782..d6589011bf1 100644 --- a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy @@ -94,6 +94,7 @@ class SbomPlugin implements Plugin { 'pkg:maven/opensymphony/sitemesh@2.6.0?type=jar' : 'OpenSymphony', // custom license approved by legal LEGAL-707 'pkg:maven/org.antlr/antlr4-runtime@4.7.2?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 'pkg:maven/org.jline/jansi@3.30.6?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 + 'pkg:maven/org.jline/jansi@4.0.7?type=jar' : 'BSD-3-Clause', // Groovy 6 pulls jansi 4.0.7; same mapping issue 'pkg:maven/org.jline/jline@3.23.0?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 'pkg:maven/org.jline/jline-builtins@3.30.6?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 'pkg:maven/org.jline/jline-console@3.30.6?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 diff --git a/gradle/functional-test-config.gradle b/gradle/functional-test-config.gradle index fb0dda9d28e..e34d790dafb 100644 --- a/gradle/functional-test-config.gradle +++ b/gradle/functional-test-config.gradle @@ -54,11 +54,7 @@ configurations.configureEach { } } -tasks.named('compileTestGroovy') { - options.forkOptions.jvmArgs += ['-Dspock.iKnowWhatImDoing.disableGroovyVersionCheck=true'] -} - -tasks.named('compileGroovy') { +tasks.withType(GroovyCompile).configureEach { options.forkOptions.jvmArgs += ['-Dspock.iKnowWhatImDoing.disableGroovyVersionCheck=true'] } diff --git a/gradle/hibernate5-test-config.gradle b/gradle/hibernate5-test-config.gradle index 2ca987dc3a1..5babe1492cb 100644 --- a/gradle/hibernate5-test-config.gradle +++ b/gradle/hibernate5-test-config.gradle @@ -26,6 +26,10 @@ dependencies { add('testRuntimeOnly', 'org.objenesis:objenesis') } +tasks.withType(GroovyCompile).configureEach { + options.forkOptions.jvmArgs += ['-Dspock.iKnowWhatImDoing.disableGroovyVersionCheck=true'] +} + tasks.withType(Test).configureEach { onlyIf { ![ diff --git a/gradle/mongodb-forked-test-config.gradle b/gradle/mongodb-forked-test-config.gradle index 61ebcd5a4f9..c07859f207f 100644 --- a/gradle/mongodb-forked-test-config.gradle +++ b/gradle/mongodb-forked-test-config.gradle @@ -26,6 +26,10 @@ dependencies { add('testRuntimeOnly', 'org.objenesis:objenesis') } +tasks.withType(GroovyCompile).configureEach { + options.forkOptions.jvmArgs += ['-Dspock.iKnowWhatImDoing.disableGroovyVersionCheck=true'] +} + tasks.named('compileTestGroovy', GroovyCompile) { groovyOptions.forkOptions.jvmArgs = ['-Xmx768m'] } diff --git a/gradle/mongodb-test-config.gradle b/gradle/mongodb-test-config.gradle index dd4fa9851fe..e97994a0e4d 100644 --- a/gradle/mongodb-test-config.gradle +++ b/gradle/mongodb-test-config.gradle @@ -26,6 +26,10 @@ dependencies { add('testRuntimeOnly', 'org.objenesis:objenesis') } +tasks.withType(GroovyCompile).configureEach { + options.forkOptions.jvmArgs += ['-Dspock.iKnowWhatImDoing.disableGroovyVersionCheck=true'] +} + tasks.named('compileTestGroovy', GroovyCompile) { groovyOptions.forkOptions.jvmArgs = ['-Xmx768m'] } From 0dcb65294ca9f011358145e8766450235b1a2165 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Mon, 6 Apr 2026 10:04:05 -0400 Subject: [PATCH 04/26] fix: Groovy 6 genericGetMethod regression breaks property access on GORM entities Groovy 6 registers GormEntity.get(Serializable) as the genericGetMethod in MetaClassImpl, causing dynamic property access like Entity.name to call get("name") instead of Class.getName(). This breaks all property access on @Entity classes that goes through Groovy's dynamic dispatch. Root cause: Groovy 6 relaxed MetaClassImpl.isGenericGetMethod from requiring get(String) to accepting get(Serializable), which matches GormEntity's static get(Serializable) method. Confirmed by runtime metaclass inspection showing genericGetMethod set to get(Serializable). Fix: add a get(String) overload to GormEntity that intercepts the genericGetMethod calls. When the argument matches a java.lang.Class bean property (name, simpleName, etc.), it delegates to Class.class metaclass. Otherwise it delegates to the GORM static API as before. Also guard staticPropertyMissing with the same Class property check for belt-and-suspenders coverage of the Groovy 6 property resolution change. Assisted-by: Claude Code --- .../grails/datastore/gorm/GormEntity.groovy | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy index e360b55b021..d4f42ce4cd4 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy @@ -40,6 +40,7 @@ import org.grails.datastore.mapping.model.types.ToOne import org.grails.datastore.mapping.query.api.BuildableCriteria import org.grails.datastore.mapping.query.api.Criteria import org.grails.datastore.mapping.reflect.EntityReflector +import org.codehaus.groovy.runtime.InvokerHelper /** * @@ -592,12 +593,27 @@ trait GormEntity implements GormValidateable, DirtyCheckable, GormEntityApi implements GormValidateable, DirtyCheckable, GormEntityApi Date: Mon, 6 Apr 2026 10:13:44 -0400 Subject: [PATCH 05/26] fix: GormEntity.get(String) throws MissingPropertyException when GORM is not initialized When Groovy 6 calls get(String) as a genericGetMethod for property resolution and GORM is not initialized, throw MissingPropertyException instead of IllegalStateException. This matches the existing staticPropertyMissing behavior and passes the GormEntityTransformSpec test for unknown static properties. Assisted-by: Claude Code --- .../main/groovy/org/grails/datastore/gorm/GormEntity.groovy | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy index d4f42ce4cd4..f76b7f47b49 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy @@ -611,7 +611,11 @@ trait GormEntity implements GormValidateable, DirtyCheckable, GormEntityApi Date: Mon, 6 Apr 2026 10:34:56 -0400 Subject: [PATCH 06/26] fix: centralize Spock version check and add jline 4.0.7 CycloneDX overrides Move spock.iKnowWhatImDoing.disableGroovyVersionCheck into the build-logic CompilePlugin, which is applied to ALL modules. This replaces the per-test-config additions and covers modules like grails-datamapping-tck and grails-test-suite-base that don't apply any shared test config. Add CycloneDX BSD-3-Clause license overrides for all jline 4.0.7 artifacts pulled by Groovy 6 (builtins, console, console-ui, native, reader, shell, style, terminal, terminal-jni). Assisted-by: Claude Code --- .../org/apache/grails/buildsrc/CompilePlugin.groovy | 1 + .../groovy/org/apache/grails/buildsrc/SbomPlugin.groovy | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/CompilePlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/CompilePlugin.groovy index a8116836a0c..f59c1578767 100644 --- a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/CompilePlugin.groovy +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/CompilePlugin.groovy @@ -110,6 +110,7 @@ class CompilePlugin implements Plugin { it.options.encoding = StandardCharsets.UTF_8.name() it.options.fork = true it.options.forkOptions.jvmArgs = ['-Xms128M', '-Xmx2G'] + it.options.forkOptions.jvmArgs += ['-Dspock.iKnowWhatImDoing.disableGroovyVersionCheck=true'] if (System.getenv('SUPPRESS_DEPRECATION_WARNINGS') == 'true') { it.options.compilerArgs += ['-Xlint:-removal'] } diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy index 4e166499376..9242048229f 100644 --- a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy @@ -97,14 +97,23 @@ class SbomPlugin implements Plugin { 'pkg:maven/org.jline/jansi@4.0.7?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 'pkg:maven/org.jline/jline@3.23.0?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 'pkg:maven/org.jline/jline-builtins@3.30.9?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 + 'pkg:maven/org.jline/jline-builtins@4.0.7?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 'pkg:maven/org.jline/jline-console@3.30.9?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 + 'pkg:maven/org.jline/jline-console@4.0.7?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 + 'pkg:maven/org.jline/jline-console-ui@4.0.7?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 'pkg:maven/org.jline/jline-native@3.30.9?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 + 'pkg:maven/org.jline/jline-native@4.0.7?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 'pkg:maven/org.jline/jline-reader@3.30.9?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 + 'pkg:maven/org.jline/jline-reader@4.0.7?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 + 'pkg:maven/org.jline/jline-shell@4.0.7?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 'pkg:maven/org.jline/jline-style@3.30.9?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 + 'pkg:maven/org.jline/jline-style@4.0.7?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 'pkg:maven/org.jline/jline-terminal@3.30.9?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 + 'pkg:maven/org.jline/jline-terminal@4.0.7?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 'pkg:maven/org.jline/jline-terminal-jansi@3.30.9?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 'pkg:maven/org.jline/jline-terminal-jna@3.30.9?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 'pkg:maven/org.jline/jline-terminal-jni@3.30.9?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 + 'pkg:maven/org.jline/jline-terminal-jni@4.0.7?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 'pkg:maven/org.jruby/jzlib@1.1.5?type=jar' : 'BSD-3-Clause', // https://web.archive.org/web/20240822213507/http://www.jcraft.com/jzlib/LICENSE.txt shows it's a 3 clause 'pkg:maven/org.liquibase.ext/liquibase-hibernate5@4.27.0?type=jar': 'Apache-2.0', // maps incorrectly because of https://github.com/liquibase/liquibase/issues/2445 & the base pom does not define a license ] From cfd7605a2a5bdaab9e48b4ecff7a4998b32e9907 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Mon, 6 Apr 2026 12:18:17 -0400 Subject: [PATCH 07/26] fix: Groovy 6 remaining test fixes Change outputTagResult from private to protected in AbstractGrailsTagTests - Groovy 6 restricts private method access from nested closures. Set spock.iKnowWhatImDoing.disableGroovyVersionCheck on Test tasks (not just GroovyCompile) so runtime Groovy compilation inside tests (e.g., BeanBuilder.loadBeans) doesn't trigger Spock's version check. Restore try-catch in GormEntity.get(String) to convert IllegalStateException to MissingPropertyException when GORM is not initialized, matching staticPropertyMissing behavior. Assisted-by: Claude Code --- .../groovy/org/apache/grails/buildsrc/CompilePlugin.groovy | 4 ++++ .../org/grails/web/taglib/AbstractGrailsTagTests.groovy | 2 +- .../grails/views/gsp/layout/AbstractGrailsTagTests.groovy | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/CompilePlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/CompilePlugin.groovy index f59c1578767..fb0ac407f0e 100644 --- a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/CompilePlugin.groovy +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/CompilePlugin.groovy @@ -33,6 +33,7 @@ import org.gradle.api.tasks.bundling.Jar import org.gradle.api.tasks.compile.GroovyCompile import org.gradle.api.tasks.compile.JavaCompile import org.gradle.api.tasks.javadoc.Javadoc +import org.gradle.api.tasks.testing.Test import org.gradle.external.javadoc.StandardJavadocDocletOptions import static org.apache.grails.buildsrc.GradleUtils.lookupPropertyByType @@ -115,6 +116,9 @@ class CompilePlugin implements Plugin { it.options.compilerArgs += ['-Xlint:-removal'] } } + project.tasks.withType(Test).configureEach { + it.jvmArgs('-Dspock.iKnowWhatImDoing.disableGroovyVersionCheck=true') + } } } diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/AbstractGrailsTagTests.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/AbstractGrailsTagTests.groovy index 1763a8c00f3..7d4c3c7fb12 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/AbstractGrailsTagTests.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/AbstractGrailsTagTests.groovy @@ -229,7 +229,7 @@ abstract class AbstractGrailsTagTests { return result } - private void outputTagResult(Writer taglibWriter, boolean returnsObject, Object tagresult) { + protected void outputTagResult(Writer taglibWriter, boolean returnsObject, Object tagresult) { if (returnsObject && tagresult != null && !(tagresult instanceof Writer)) { taglibWriter.print(tagresult) } diff --git a/grails-test-examples/gsp-layout/src/test/groovy/org/apache/grails/views/gsp/layout/AbstractGrailsTagTests.groovy b/grails-test-examples/gsp-layout/src/test/groovy/org/apache/grails/views/gsp/layout/AbstractGrailsTagTests.groovy index f39c34a2a4e..77868cd9700 100644 --- a/grails-test-examples/gsp-layout/src/test/groovy/org/apache/grails/views/gsp/layout/AbstractGrailsTagTests.groovy +++ b/grails-test-examples/gsp-layout/src/test/groovy/org/apache/grails/views/gsp/layout/AbstractGrailsTagTests.groovy @@ -233,7 +233,7 @@ abstract class AbstractGrailsTagTests { return result } - private void outputTagResult(Writer taglibWriter, boolean returnsObject, Object tagresult) { + protected void outputTagResult(Writer taglibWriter, boolean returnsObject, Object tagresult) { if (returnsObject && tagresult != null && !(tagresult instanceof Writer)) { taglibWriter.print(tagresult) } From b2bd21343ac529b4103912f306450d105cba11c5 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Mon, 6 Apr 2026 13:49:56 -0400 Subject: [PATCH 08/26] fix: resolve all remaining test failures on Groovy 6 DataBindingTests: replace old-style Author.metaClass.static.get mock with Spock GroovySpy. Groovy 6 changed MetaClass dispatch precedence for trait-provided static methods, so dynamically-added MetaClass closures no longer intercept calls to compiled trait methods. grails-views-gson StreamingJsonBuilder ClassCastException: the Groovy parent's call(Closure) creates groovy.json.StreamingJsonDelegate via private cloneDelegateAndGetContent, but compiled .gson templates cast the delegate to grails.plugin.json.builder.StreamingJsonDelegate. Fix: override call(Closure) in the Grails StreamingJsonBuilder to use the Grails delegate subclass, and fix JsonViewWritableScript.json() to create Grails delegates directly instead of the Groovy parent type. Assisted-by: Claude Code --- .../web/binding/DataBindingTests.groovy | 18 +++--- .../json/builder/StreamingJsonBuilder.java | 64 ++++++++++--------- .../json/view/JsonViewWritableScript.groovy | 4 +- 3 files changed, 44 insertions(+), 42 deletions(-) diff --git a/grails-test-suite-web/src/test/groovy/org/grails/web/binding/DataBindingTests.groovy b/grails-test-suite-web/src/test/groovy/org/grails/web/binding/DataBindingTests.groovy index 4a97878f11d..14a12328f49 100644 --- a/grails-test-suite-web/src/test/groovy/org/grails/web/binding/DataBindingTests.groovy +++ b/grails-test-suite-web/src/test/groovy/org/grails/web/binding/DataBindingTests.groovy @@ -404,16 +404,10 @@ class DataBindingTests extends Specification implements ControllerUnitTest - def result = new Author() - result.id = id as long - result.name = "Mocked ${id}" - result - } + given: + GroovySpy(Author, global: true) + when: request.addParameter("title", "The Stand") request.addParameter("author.id", "5") @@ -422,6 +416,12 @@ class DataBindingTests extends Specification implements ControllerUnitTest> { args -> + def result = new Author() + result.id = args[0] as long + result.name = "Mocked ${args[0]}" + result + } "The Stand" == b.title b.author != null 5 == b.author.id diff --git a/grails-views-gson/src/main/groovy/grails/plugin/json/builder/StreamingJsonBuilder.java b/grails-views-gson/src/main/groovy/grails/plugin/json/builder/StreamingJsonBuilder.java index f720afe684e..ea4cfe064a6 100644 --- a/grails-views-gson/src/main/groovy/grails/plugin/json/builder/StreamingJsonBuilder.java +++ b/grails-views-gson/src/main/groovy/grails/plugin/json/builder/StreamingJsonBuilder.java @@ -21,6 +21,8 @@ import java.io.IOException; import java.io.Writer; +import groovy.lang.Closure; + /** * Temporary fork of {@link groovy.json.StreamingJsonBuilder} until Groovy 2.4.5 is out. * @@ -34,60 +36,52 @@ @Deprecated(since = "7.1", forRemoval = true) public class StreamingJsonBuilder extends groovy.json.StreamingJsonBuilder { - /** - * Instantiates a JSON builder. - * - * @param writer A writer to which Json will be written - */ + private final Writer grailsWriter; + private final groovy.json.JsonGenerator grailsGenerator; + public StreamingJsonBuilder(Writer writer) { super(writer); + this.grailsWriter = writer; + this.grailsGenerator = new groovy.json.JsonGenerator.Options().build(); } - /** - * Instantiates a JSON builder with the given generator. - * - * @param writer A writer to which Json will be written - * @param generator used to generate the output - * @since 2.5 - */ @Deprecated(since = "7.1", forRemoval = true) public StreamingJsonBuilder(Writer writer, grails.plugin.json.builder.JsonGenerator generator) { super(writer, generator); + this.grailsWriter = writer; + this.grailsGenerator = generator; } - /** - * Instantiates a JSON builder, possibly with some existing data structure. - * - * @param writer A writer to which Json will be written - * @param content a pre-existing data structure, default to null - * @throws IOException - * If an I/O error occurs - */ public StreamingJsonBuilder(Writer writer, Object content) throws IOException { super(writer, content); + this.grailsWriter = writer; + this.grailsGenerator = new groovy.json.JsonGenerator.Options().build(); } - /** - * Instantiates a JSON builder, possibly with some existing data structure and - * the given generator. - * - * @param writer A writer to which Json will be written - * @param content a pre-existing data structure, default to null - * @param generator used to generate the output - * @throws IOException - * If an I/O error occurs - * @since 2.5 - */ public StreamingJsonBuilder(Writer writer, Object content, grails.plugin.json.builder.JsonGenerator generator) throws IOException { super(writer, content, generator); + this.grailsWriter = writer; + this.grailsGenerator = generator; } public StreamingJsonBuilder(Writer writer, groovy.json.JsonGenerator generator) { super(writer, generator); + this.grailsWriter = writer; + this.grailsGenerator = generator; } public StreamingJsonBuilder(Writer writer, Object content, groovy.json.JsonGenerator generator) throws IOException { super(writer, content, generator); + this.grailsWriter = writer; + this.grailsGenerator = generator; + } + + @Override + public Object call(@groovy.lang.DelegatesTo(value = StreamingJsonDelegate.class, strategy = Closure.DELEGATE_FIRST) Closure c) throws IOException { + grailsWriter.write(grails.plugin.json.builder.JsonOutput.OPEN_BRACE); + StreamingJsonDelegate.cloneDelegateAndGetContent(grailsWriter, c, true, grailsGenerator); + grailsWriter.write(grails.plugin.json.builder.JsonOutput.CLOSE_BRACE); + return null; } @Deprecated(since = "7.1", forRemoval = true) @@ -105,5 +99,13 @@ public StreamingJsonDelegate(Writer w, boolean first, grails.plugin.json.builder public StreamingJsonDelegate(Writer w, boolean first, groovy.json.JsonGenerator generator) { super(w, first, generator); } + + public static void cloneDelegateAndGetContent(Writer w, Closure c, boolean first, groovy.json.JsonGenerator generator) { + StreamingJsonDelegate delegate = new StreamingJsonDelegate(w, first, generator); + Closure cloned = (Closure) c.clone(); + cloned.setDelegate(delegate); + cloned.setResolveStrategy(Closure.DELEGATE_FIRST); + cloned.call(); + } } } diff --git a/grails-views-gson/src/main/groovy/grails/plugin/json/view/JsonViewWritableScript.groovy b/grails-views-gson/src/main/groovy/grails/plugin/json/view/JsonViewWritableScript.groovy index 2b502922348..506cd9e9441 100644 --- a/grails-views-gson/src/main/groovy/grails/plugin/json/view/JsonViewWritableScript.groovy +++ b/grails-views-gson/src/main/groovy/grails/plugin/json/view/JsonViewWritableScript.groovy @@ -78,7 +78,7 @@ abstract class JsonViewWritableScript extends AbstractWritableScript implements out.write(JsonOutput.COMMA) } } - def jsonDelegate = new StreamingJsonBuilder.StreamingJsonDelegate(out, false, generator) + def jsonDelegate = new grails.plugin.json.builder.StreamingJsonBuilder.StreamingJsonDelegate(out, false, generator) callable.setDelegate(jsonDelegate) callable.call() if (!inline) { @@ -89,7 +89,7 @@ abstract class JsonViewWritableScript extends AbstractWritableScript implements this.root = callable if (inline) { - def jsonDelegate = new StreamingJsonBuilder.StreamingJsonDelegate(out, true, generator) + def jsonDelegate = new grails.plugin.json.builder.StreamingJsonBuilder.StreamingJsonDelegate(out, true, generator) callable.setDelegate(jsonDelegate) callable.call() } From 027c203797fc48305f8da2675705abe39544570d Mon Sep 17 00:00:00 2001 From: James Fredley Date: Mon, 6 Apr 2026 17:27:10 -0400 Subject: [PATCH 09/26] fix: CodeNarc UnnecessaryDotClass in DefaultConstraintFactory Replace Object.class with Object in the constructor delegation call. Assisted-by: Claude Code --- .../constraints/factory/DefaultConstraintFactory.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/factory/DefaultConstraintFactory.groovy b/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/factory/DefaultConstraintFactory.groovy index 88e4178820e..4135f1311ce 100644 --- a/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/factory/DefaultConstraintFactory.groovy +++ b/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/factory/DefaultConstraintFactory.groovy @@ -48,7 +48,7 @@ class DefaultConstraintFactory implements ConstraintFactory { protected final Constructor constraintConstructor DefaultConstraintFactory(Class constraintClass, MessageSource messageSource) { - this(constraintClass, messageSource, [Object.class] as List) + this(constraintClass, messageSource, [Object] as List) } DefaultConstraintFactory(Class constraintClass, MessageSource messageSource, List targetTypes) { From 52aad53ed91e234b8cb33dbb960eafb95a1801ec Mon Sep 17 00:00:00 2001 From: James Fredley Date: Mon, 6 Apr 2026 18:46:54 -0400 Subject: [PATCH 10/26] fix: GormEntity.get(String) delegates to staticPropertyMissing for GORM properties When Groovy 6's genericGetMethod calls get(String) for property resolution, GORM-managed properties like datasource qualifiers (e.g., Book.moreBooks) were being treated as entity-by-ID lookups instead of routing through staticPropertyMissing. Fix: try staticPropertyMissing first (handles GORM property resolution including datasource qualifiers and dynamic properties), then fall back to get(Serializable) for entity-by-ID lookups. This preserves both property resolution and data binding paths. Assisted-by: Claude Code --- .../groovy/org/grails/datastore/gorm/GormEntity.groovy | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy index f76b7f47b49..7cbdcf6ac82 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy @@ -612,9 +612,13 @@ trait GormEntity implements GormValidateable, DirtyCheckable, GormEntityApi Date: Fri, 24 Apr 2026 21:39:18 -0400 Subject: [PATCH 11/26] fix: harden Groovy 6 canary against snapshot drift and add regression tests Three improvements driven by an architectural review of the Groovy 6 canary work and a fresh build that surfaced new SNAPSHOT-related issues. 1) SbomPlugin: introduce LICENSE_GROUP_MAPPING fallback (build fix) The Groovy 6.0.0-SNAPSHOT just bumped its transitive jline pull from 4.0.7 to 4.0.12, which broke `cyclonedxBom` for grails-shell-cli, grails-console, and grails-dependencies-starter-web with: Unpermitted License found for bom dependency: pkg:maven/org.jline/jansi@4.0.12?type=jar : BSD-4-Clause The previous fix added per-version entries for 4.0.7 only. Per-version entries for an entire dependency group that drifts on every SNAPSHOT bump is unmaintainable. Replace the per-version `pkg:maven/org.jline/*` entries with a single group-level mapping that forces BSD-3-Clause for the whole group. The fallback kicks in only after the exact-match LICENSE_MAPPING fails, so existing per-version overrides keep their fast path. Verified locally: Forcing license for pkg:maven/org.jline/jansi@4.0.12?type=jar to BSD-3-Clause via group rule pkg:maven/org.jline/ ... BUILD SUCCESSFUL in 42s The criteria for adding a group rule are documented inline (stable license + cyclonedx-core-java#205 misreport + SNAPSHOT version drift), so future maintainers know when to extend it and when to stick with per-version entries. 2) MappingContextAwareConstraintFactory: defensive sibling fix Architectural review flagged this class as carrying the same default-valued `List` constructor parameter that triggered the Groovy 6 VerifyError in DefaultConstraintFactory. The class itself is not @CompileStatic, so the bug does not currently fire here, but the parent constructor it delegates to is, and it is cheaper to apply the same explicit two-constructor pattern now than to reproduce the same debugging session if a future Groovy 6 alpha tightens bytecode rules. 3) GormEntityTransformSpec: regression tests for the GROOVY-11829 shim The original PR added a `get(String)` overload to GormEntity to work around Groovy 6's relaxed `MetaClassImpl.isGenericGetMethod`, but did not add focused tests. Architectural review correctly pointed out that the shim has user-visible behavioral consequences for String-id entities (e.g. `Book.get("simpleName")` no longer means "load the entity whose id is the string 'simpleName'") and those need test coverage so the regression surface is documented and any future change is caught. Add three feature methods to GormEntityTransformSpec: - "test Groovy 6 genericGetMethod regression workaround (GROOVY-11829)" asserts the new `get(String)` exists and is @Generated alongside the original `get(Serializable)`, and that Class bean property access (`Book.simpleName`, `Book.name`) still resolves through the workaround. - "test get(String) throws MissingPropertyException when GORM not initialized and string is not a Class property" pins the contract that genuinely-missing names raise MissingPropertyException, not the IllegalStateException that an uninitialised GORM static API would otherwise leak. - "test get(String) returns Class bean property when name matches Class property and GORM not initialized" pins the user-visible behavior change vs Groovy 5: `Book.get("simpleName")` returns the Class.simpleName, not an entity-by-id lookup. The test docstring references GormEntity.get(String) and GROOVY-11829 so the trade-off is discoverable from the test rather than buried in commit history. All three new tests pass against Groovy 6.0.0-SNAPSHOT locally: ./gradlew :grails-datamapping-core:test \ --tests "org.grails.compiler.gorm.GormEntityTransformSpec" -> 12 tests, 0 failures, BUILD SUCCESSFUL in 36s Assisted-by: claude-code:claude-opus-4-7 --- .../apache/grails/buildsrc/SbomPlugin.groovy | 57 ++++++++++++------- ...appingContextAwareConstraintFactory.groovy | 6 +- .../gorm/GormEntityTransformSpec.groovy | 32 +++++++++++ 3 files changed, 72 insertions(+), 23 deletions(-) diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy index 8b4769b61c0..b12d9bb6d0f 100644 --- a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy @@ -91,34 +91,32 @@ class SbomPlugin implements Plugin { 'pkg:maven/com.oracle.coherence.ce/coherence-bom@25.03.1?type=pom': 'UPL-1.0', // does not have map based on license id 'pkg:maven/com.oracle.coherence.ce/coherence-bom@25.03.2?type=pom': 'UPL-1.0', // does not have map based on license id 'pkg:maven/com.oracle.coherence.ce/coherence-bom@22.06.2?type=pom': 'UPL-1.0', // does not have map based on license id - 'pkg:maven/jline/jline@2.14.6?type=jar' : 'BSD-2-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 + 'pkg:maven/jline/jline@2.14.6?type=jar' : 'BSD-2-Clause', // legacy jline:jline group, BSD-2; maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 'pkg:maven/opensymphony/sitemesh@2.6.0?type=jar' : 'OpenSymphony', // custom license approved by legal LEGAL-707 'pkg:maven/org.antlr/antlr4-runtime@4.7.2?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 - 'pkg:maven/org.jline/jansi@3.30.9?type=jar' : 'BSD-3-Clause', // jline group resolved at 3.30.9 transitively via groovy-groovysh; main org.jline:jline pinned at 3.30.6 directly - 'pkg:maven/org.jline/jansi@4.0.7?type=jar' : 'BSD-3-Clause', // jline group resolved at 4.0.7 transitively via groovy-groovysh on Groovy 6 - 'pkg:maven/org.jline/jline@3.30.6?type=jar' : 'BSD-3-Clause', // direct dependency declared at jline.version in dependencies.gradle - 'pkg:maven/org.jline/jline-builtins@3.30.9?type=jar' : 'BSD-3-Clause', // jline group resolved at 3.30.9 transitively via groovy-groovysh; main org.jline:jline pinned at 3.30.6 directly - 'pkg:maven/org.jline/jline-builtins@4.0.7?type=jar' : 'BSD-3-Clause', // jline group resolved at 4.0.7 transitively via groovy-groovysh on Groovy 6 - 'pkg:maven/org.jline/jline-console@3.30.9?type=jar' : 'BSD-3-Clause', // jline group resolved at 3.30.9 transitively via groovy-groovysh; main org.jline:jline pinned at 3.30.6 directly - 'pkg:maven/org.jline/jline-console@4.0.7?type=jar' : 'BSD-3-Clause', // jline group resolved at 4.0.7 transitively via groovy-groovysh on Groovy 6 - 'pkg:maven/org.jline/jline-console-ui@4.0.7?type=jar' : 'BSD-3-Clause', // jline group resolved at 4.0.7 transitively via groovy-groovysh on Groovy 6 - 'pkg:maven/org.jline/jline-native@3.30.9?type=jar' : 'BSD-3-Clause', // jline group resolved at 3.30.9 transitively via groovy-groovysh; main org.jline:jline pinned at 3.30.6 directly - 'pkg:maven/org.jline/jline-native@4.0.7?type=jar' : 'BSD-3-Clause', // jline group resolved at 4.0.7 transitively via groovy-groovysh on Groovy 6 - 'pkg:maven/org.jline/jline-reader@3.30.9?type=jar' : 'BSD-3-Clause', // jline group resolved at 3.30.9 transitively via groovy-groovysh; main org.jline:jline pinned at 3.30.6 directly - 'pkg:maven/org.jline/jline-reader@4.0.7?type=jar' : 'BSD-3-Clause', // jline group resolved at 4.0.7 transitively via groovy-groovysh on Groovy 6 - 'pkg:maven/org.jline/jline-shell@4.0.7?type=jar' : 'BSD-3-Clause', // jline group resolved at 4.0.7 transitively via groovy-groovysh on Groovy 6 - 'pkg:maven/org.jline/jline-style@3.30.9?type=jar' : 'BSD-3-Clause', // jline group resolved at 3.30.9 transitively via groovy-groovysh; main org.jline:jline pinned at 3.30.6 directly - 'pkg:maven/org.jline/jline-style@4.0.7?type=jar' : 'BSD-3-Clause', // jline group resolved at 4.0.7 transitively via groovy-groovysh on Groovy 6 - 'pkg:maven/org.jline/jline-terminal@3.30.9?type=jar' : 'BSD-3-Clause', // jline group resolved at 3.30.9 transitively via groovy-groovysh; main org.jline:jline pinned at 3.30.6 directly - 'pkg:maven/org.jline/jline-terminal@4.0.7?type=jar' : 'BSD-3-Clause', // jline group resolved at 4.0.7 transitively via groovy-groovysh on Groovy 6 - 'pkg:maven/org.jline/jline-terminal-jansi@3.30.9?type=jar' : 'BSD-3-Clause', // jline group resolved at 3.30.9 transitively via groovy-groovysh; main org.jline:jline pinned at 3.30.6 directly - 'pkg:maven/org.jline/jline-terminal-jna@3.30.9?type=jar' : 'BSD-3-Clause', // jline group resolved at 3.30.9 transitively via groovy-groovysh; main org.jline:jline pinned at 3.30.6 directly - 'pkg:maven/org.jline/jline-terminal-jni@3.30.9?type=jar' : 'BSD-3-Clause', // jline group resolved at 3.30.9 transitively via groovy-groovysh; main org.jline:jline pinned at 3.30.6 directly - 'pkg:maven/org.jline/jline-terminal-jni@4.0.7?type=jar' : 'BSD-3-Clause', // jline group resolved at 4.0.7 transitively via groovy-groovysh on Groovy 6 'pkg:maven/org.jruby/jzlib@1.1.5?type=jar' : 'BSD-3-Clause', // https://web.archive.org/web/20240822213507/http://www.jcraft.com/jzlib/LICENSE.txt shows it's a 3 clause 'pkg:maven/org.liquibase.ext/liquibase-hibernate5@4.27.0?type=jar': 'Apache-2.0', // maps incorrectly because of https://github.com/liquibase/liquibase/issues/2445 & the base pom does not define a license ] + /** + * Group-level license overrides applied AFTER {@link #LICENSE_MAPPING} fails to match. + * The key is a purl prefix (e.g. {@code 'pkg:maven/org.jline/'}) and the value is the + * SPDX license id to force for any artifact whose bomRef starts with that prefix. + * + * This exists for groups that: + * (a) have a stable license across all artifacts and versions, AND + * (b) suffer from cyclonedx-core-java#205 (license is misreported), AND + * (c) are pulled transitively by SNAPSHOT dependencies (e.g. groovy-groovysh -> + * org.jline:* drifts on every Groovy SNAPSHOT bump), making per-version entries + * unmaintainable. + * + * Only add a group entry when ALL three conditions hold. Per-version entries in + * {@link #LICENSE_MAPPING} should still be preferred for one-off overrides. + */ + private static Map LICENSE_GROUP_MAPPING = [ + 'pkg:maven/org.jline/': 'BSD-3-Clause', // entire org.jline group is BSD-3-Clause; cyclonedx misreports it (cyclonedx-core-java#205) and versions drift via groovy-groovysh on every SNAPSHOT bump + ] + // we don't distribute these so these licenses are considered acceptable, but we still prefer ASF licenses. // Require a whitelist of any case of category X licenses to prevent accidental inclusion in a distributed artifact // this list will need to be updated anytime we change versions so we can revise the licenses @@ -338,6 +336,21 @@ class SbomPlugin implements Plugin { return licenseBlock } + // Fallback: group-level override matched by purl prefix. See LICENSE_GROUP_MAPPING + // for criteria (stable license + cyclonedx misreport + SNAPSHOT version drift). + def groupOverride = LICENSE_GROUP_MAPPING.find { prefix, _ -> bomRef.startsWith(prefix) } + if (groupOverride) { + def licenseId = groupOverride.value + logger.lifecycle('Forcing license for {} to {} via group rule {}', bomRef, licenseId, groupOverride.key) + + def licenseBlock = LICENSES[licenseId] + if (!licenseBlock) { + throw new GradleException("Cannot find license information for id ${licenseId} to use for bomRef ${bomRef} in project ${projectName}") + } + + return licenseBlock + } + if (!(licenseChoices instanceof List) || licenseChoices.isEmpty()) { throw new GradleException("No License was found for dependency: ${bomRef} in project ${projectName}") } diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/validation/constraints/MappingContextAwareConstraintFactory.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/validation/constraints/MappingContextAwareConstraintFactory.groovy index c25501ec391..99871cd5ab8 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/validation/constraints/MappingContextAwareConstraintFactory.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/validation/constraints/MappingContextAwareConstraintFactory.groovy @@ -35,7 +35,11 @@ class MappingContextAwareConstraintFactory extends DefaultConstraintFactory { final MappingContext mappingContext - MappingContextAwareConstraintFactory(Class constraintClass, MessageSource messageSource, MappingContext mappingContext, List targetTypes = [Object]) { + MappingContextAwareConstraintFactory(Class constraintClass, MessageSource messageSource, MappingContext mappingContext) { + this(constraintClass, messageSource, mappingContext, [Object] as List) + } + + MappingContextAwareConstraintFactory(Class constraintClass, MessageSource messageSource, MappingContext mappingContext, List targetTypes) { super(constraintClass, messageSource, targetTypes) this.mappingContext = mappingContext } diff --git a/grails-datamapping-core/src/test/groovy/org/grails/compiler/gorm/GormEntityTransformSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/compiler/gorm/GormEntityTransformSpec.groovy index 3e1d65cfdd9..18c685edbda 100644 --- a/grails-datamapping-core/src/test/groovy/org/grails/compiler/gorm/GormEntityTransformSpec.groovy +++ b/grails-datamapping-core/src/test/groovy/org/grails/compiler/gorm/GormEntityTransformSpec.groovy @@ -217,6 +217,38 @@ class GormEntityTransformSpec extends Specification{ thrown(MissingPropertyException) } + void 'test Groovy 6 genericGetMethod regression workaround (GROOVY-11829)'() { + expect: 'Class bean properties remain accessible via dynamic property access (the workaround target)' + Book.simpleName == 'Book' + Book.name.endsWith('Book') + + and: 'the get(String) Groovy-6 compatibility overload exists and is @Generated' + def getStringMethod = Book.getMethod('get', String) + getStringMethod != null + getStringMethod.isAnnotationPresent(Generated) + + and: 'the original get(Serializable) overload still exists for entity-by-id lookups' + def getSerializableMethod = Book.getMethod('get', Serializable) + getSerializableMethod != null + getSerializableMethod.isAnnotationPresent(Generated) + } + + void 'test get(String) throws MissingPropertyException when GORM not initialized and string is not a Class property'() { + when: 'a name that is neither a Class property nor a known qualifier is passed' + Book.get('definitelyNotAClassPropertyOrEntityIdABCXYZ') + + then: 'we do NOT leak the IllegalStateException raised by uninitialized GORM' + thrown(MissingPropertyException) + } + + void 'test get(String) returns Class bean property when name matches Class property and GORM not initialized'() { + expect: 'explicit get("simpleName") returns the Class.simpleName because the Groovy 6 generic-getter workaround intercepts Class properties before delegating to the GORM static API' + Book.get('simpleName') == 'Book' + + and: 'this is a documented behavior change vs Grails on Groovy 5: prior to GROOVY-11829, Book.get("simpleName") would call get(Serializable) and attempt an entity-by-id lookup. See GormEntity.get(String) docstring.' + Book.get('canonicalName') == Book.canonicalName + } + void 'test that all GormEntity/GormValidateable trait methods are marked as Generated'() { expect: 'all GormEntity methods are marked as Generated on implementation class' From c2aa269e5400b5da9877d685043d77192a85fb16 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Fri, 24 Apr 2026 21:57:05 -0400 Subject: [PATCH 12/26] fix: Groovy 6 closure dispatch regression in ControllerActionTransformer A fresh Groovy 6.0.0-SNAPSHOT pull broke grails-rest-transforms compile: Execution failed for task ':grails-rest-transforms:compileGroovy'. > Unrecoverable compilation error: startup failed: General error during semantic analysis: No signature of method: doCall for class: ControllerActionTransformer$1 is applicable for argument types: (org.codehaus.groovy.ast.MethodNode) values: [org.codehaus.groovy.ast.MethodNode@... index(java.lang.Integer) from grails.rest.RestfulController] The transformer used `DefaultGroovyMethods.count(Iterable, Closure)` with an inline anonymous Closure subclass that overrode `call(Object)`. Under Groovy 5 that dispatched via Closure.call(Object) directly. Under Groovy 6 the count helper now goes through MOP `doCall` lookup first, and a Java inner class overriding `call(Object)` does not advertise a matching `doCall(MethodNode)`, so dispatch fails at compile time when the AST transform itself runs against any controller subclass that has typed overload methods on the supertype (e.g. RestfulController.index(Integer)). The Closure roundtrip is unnecessary here. Replace it with a plain Java counting loop. This is shorter, allocates no Closure, removes the implicit MOP dependency entirely, and works on every Groovy version. The DefaultGroovyMethods import is no longer used in this file, so remove it too. Verified locally: ./gradlew :grails-rest-transforms:compileGroovy -PskipCodeStyle -> BUILD SUCCESSFUL in 29s Other `new Closure(this)` sites in the codebase use either no-arg call() or call(Object...) varargs and were not affected by the new MOP path; if that changes those should get the same treatment. Assisted-by: claude-code:claude-opus-4-7 --- .../compiler/web/ControllerActionTransformer.java | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/grails-controllers/src/main/groovy/org/grails/compiler/web/ControllerActionTransformer.java b/grails-controllers/src/main/groovy/org/grails/compiler/web/ControllerActionTransformer.java index 280249d6fed..8bd79d2fa33 100644 --- a/grails-controllers/src/main/groovy/org/grails/compiler/web/ControllerActionTransformer.java +++ b/grails-controllers/src/main/groovy/org/grails/compiler/web/ControllerActionTransformer.java @@ -73,7 +73,6 @@ import org.codehaus.groovy.classgen.GeneratorContext; import org.codehaus.groovy.control.CompilationUnit; import org.codehaus.groovy.control.SourceUnit; -import org.codehaus.groovy.runtime.DefaultGroovyMethods; import org.codehaus.groovy.syntax.Token; import org.codehaus.groovy.syntax.Types; import org.codehaus.groovy.transform.trait.Traits; @@ -267,12 +266,12 @@ private void processMethods(ClassNode classNode, SourceUnit source, if (methodShouldBeConfiguredAsControllerAction(method)) { final List declaredMethodsWithThisName = classNode.getDeclaredMethods(method.getName()); if (declaredMethodsWithThisName != null) { - final int numberOfNonExceptionHandlerMethodsWithThisName = DefaultGroovyMethods.count((Iterable) declaredMethodsWithThisName, new Closure(this) { - @Override - public Object call(Object object) { - return !isExceptionHandlingMethod((MethodNode) object); + int numberOfNonExceptionHandlerMethodsWithThisName = 0; + for (MethodNode candidate : declaredMethodsWithThisName) { + if (!isExceptionHandlingMethod(candidate)) { + numberOfNonExceptionHandlerMethodsWithThisName++; } - }).intValue(); + } if (numberOfNonExceptionHandlerMethodsWithThisName > 1) { String message = "Controller actions may not be overloaded. The [" + method.getName() + From 8e9cdbc50f42fe0065147303907e28aebfcedc91 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sat, 25 Apr 2026 11:44:27 -0400 Subject: [PATCH 13/26] fix: move Groovy 6 generic-getter guard from GormEntity trait to AST CI surfaced a regression in every Hibernate5 / Functional / Mongodb test suite that exercised connection-aware entities, all failing with: java.lang.IllegalArgumentException: Unknown entity: java.util.LinkedHashMap at org.hibernate.internal.SessionImpl.fireDelete(...) at AbstractHibernateGormInstanceApi.delete(...) at GormStaticApi.delete(GormStaticApi.groovy:536) at DataServiceConnectionRoutingSpec.deleteAllFromConnection (line 280) That stack maps onto the cleanup helper DataServiceRoutingProduct."secondary".list().each { it."secondary".delete(flush: true) } The class-level `DataServiceRoutingProduct.secondary` was being routed through the existing GROOVY-11829 workaround on the GormEntity trait (`static Object get(String nameOrId)`) and correctly returned a connection-scoped `GormStaticApi`. The instance-level `it.secondary` however - which should resolve through the entity's `propertyMissing(String)` to a `DelegatingGormEntityApi` - was finding the SAME static method as its instance generic-getter under Groovy 6. Verified directly: metaClass.respondsTo(entity, 'get', String) -> [public static java.lang.Object DataServiceRoutingProduct.get(java.lang.String)] So `it.secondary` returned a `GormStaticApi` instead of a `DelegatingGormEntityApi`. The subsequent `.delete(flush: true)` then matched `GormStaticApi.delete(D instance)` with the `[flush: true]` LinkedHashMap cast as `D`, which Hibernate finally rejected at `session.delete(LinkedHashMap)`. The same misrouting also explained the secondary failure pattern seen across CrossLayerMultiDataSourceSpec: java.lang.NullPointerException: Cannot invoke "org.springframework.validation.Errors.getFieldErrors()" because "originalErrors" is null at HibernateRuntimeUtils.setupErrorsProperty(...:79) `it.errors` was being similarly hijacked by the static `get(String)` on a multi-datasource entity, leaving the `getErrors()` accessor used by `setupErrorsProperty` returning `null` instead of a real `Errors`. Fix --- Drop the trait-level `static Object get(String nameOrId)` and instead have `GormEntityTransformation` add an INSTANCE `Object get(String name)` method directly to every `@Entity` class. Its body is a one-line delegate to the existing `propertyMissing(String)`: // generated on every @Entity class public Object get(String name) { propertyMissing(name) } Why this works: 1. Trait-merge no longer rejects the trait. We could not declare BOTH `static get(String)` and instance `get(String)` on the trait itself - Groovy reports "static and instance methods having the same signature". Adding the instance overload via AST keeps it on the entity class, where static + instance with the same name and params is legal. 2. Instance dispatch picks the more specific candidate. Because the instance method now lives directly on the entity class (not just on the trait), Groovy's instance MOP finds it before falling back to any trait-static `get(...)` method, so `it.secondary` routes through the existing `propertyMissing` and yields the correct `DelegatingGormEntityApi`. 3. Class-level dynamic property access still works. `Class` bean properties (`simpleName`, `name`, `canonicalName`, ...) are resolved by Groovy's normal Class metaclass before any genericGetMethod is consulted, and connection-name lookups like `Book.secondary` continue to land on the existing `staticPropertyMissing` in GormEntity. The trait keeps its original `static D get(Serializable id)` (the public entity-by-id API) untouched. Tests ----- Updated `GormEntityTransformSpec` to assert the new shape: - the AST-added instance `get(String)` exists and is `@Generated`, - it is NOT static, - the original `get(Serializable)` is still present. The earlier tests that documented the old static-overload behaviour (`Book.get('simpleName') == 'Book'`, etc.) were specific to the removed shim and have been deleted alongside it. Verified locally on Groovy 6.0.0-SNAPSHOT: ./gradlew :grails-datamapping-core:test \ :grails-data-hibernate5-core:test \ --tests 'org.grails.compiler.gorm.GormEntityTransformSpec' \ --tests 'org.apache.grails.data.testing.tck.tests.Domain*' \ --tests 'org.apache.grails.data.testing.tck.tests.CrossLayer*' \ --tests 'org.apache.grails.data.testing.tck.tests.DataService*' -> 42 tests, 0 failures, BUILD SUCCESSFUL Assisted-by: claude-code:claude-opus-4-7 --- .../gorm/GormEntityTransformation.groovy | 24 +++++++++++++++++ .../grails/datastore/gorm/GormEntity.groovy | 24 ----------------- .../gorm/GormEntityTransformSpec.groovy | 27 +++++-------------- 3 files changed, 30 insertions(+), 45 deletions(-) diff --git a/grails-datamapping-core/src/main/groovy/org/grails/compiler/gorm/GormEntityTransformation.groovy b/grails-datamapping-core/src/main/groovy/org/grails/compiler/gorm/GormEntityTransformation.groovy index a154019d157..f121d846505 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/compiler/gorm/GormEntityTransformation.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/compiler/gorm/GormEntityTransformation.groovy @@ -292,6 +292,30 @@ class GormEntityTransformation extends AbstractASTTransformation implements Comp classNode.addMethod('$static_propertyMissing', Modifier.PUBLIC | Modifier.STATIC, AstUtils.OBJECT_CLASS_NODE, propertyMissingGetParameters, null, propertyMissingGetBody) markAsGenerated(classNode, propertyMissingNodeGetter) + // INSTANCE Object get(String name) - Groovy 6 GROOVY-11829 instance dispatch guard. + // The STATIC get(String) on the GormEntity trait is picked up by Groovy 6's instance + // MOP as the generic-getter for instance property access (it shows in + // metaClass.respondsTo(instance, 'get', String)), returning a connection-scoped + // GormStaticApi where the entity-level propertyMissing should have returned a + // DelegatingGormEntityApi. The mismatched type silently corrupts call chains like + // book.someConnection.delete(flush: true) - "Unknown entity: java.util.LinkedHashMap". + // Adding an instance overload directly to the entity class here gives instance MOP a + // more specific candidate than the inherited trait-static method, so it wins dispatch + // and delegates back to the existing instance propertyMissing. Adding via AST instead + // of declaring on the trait avoids the "static and instance methods having the same + // signature" trait-merge error since the trait still owns only the static get(String). + def instanceGetBody = new BlockStatement() + def instanceGetNameParam = new Parameter(ClassHelper.make(String), 'name') + def instanceGetArgs = new ArgumentListExpression(instanceGetNameParam) + def instanceGetMethodCall = new MethodCallExpression(new VariableExpression('this'), 'propertyMissing', instanceGetArgs) + instanceGetBody.addStatement( + new ExpressionStatement(instanceGetMethodCall) + ) + def instanceGetParameters = [instanceGetNameParam] as Parameter[] + MethodNode instanceGetNode = + classNode.addMethod('get', Modifier.PUBLIC, AstUtils.OBJECT_CLASS_NODE, instanceGetParameters, null, instanceGetBody) + markAsGenerated(classNode, instanceGetNode) + // now process named query associations // see https://grails.apache.org/docs/latest/ref/Domain%20Classes/namedQueries.html diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy index 997deb6c327..6010581f821 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy @@ -606,30 +606,6 @@ trait GormEntity implements GormValidateable, DirtyCheckable, GormEntityApi Date: Sat, 25 Apr 2026 11:48:19 -0400 Subject: [PATCH 14/26] fix: serialise GSP compilation under Groovy 6 to dodge ListHashMap race Every CI job that compiled GSPs against Groovy 6.0.0-SNAPSHOT failed with a Groovy compiler stack like: General error during instruction selection: Index 3 out of bounds for length 3 java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3 at org.codehaus.groovy.util.ListHashMap.toMap(ListHashMap.java:207) at org.codehaus.groovy.util.ListHashMap.put(ListHashMap.java:146) at java.base/java.util.Map.computeIfAbsent(Map.java:1067) at org.codehaus.groovy.ast.NodeMetaDataHandler.getNodeMetaData(NodeMetaDataHandler.java:65) at org.codehaus.groovy.ast.AnnotationNode.isTargetAllowed(AnnotationNode.java:168) at org.codehaus.groovy.classgen.ExtendedVerifier.visitAnnotations(ExtendedVerifier.java:354) at org.codehaus.groovy.classgen.ExtendedVerifier.visitConstructor(ExtendedVerifier.java:216) ... at org.grails.web.pages.GroovyPageForkedCompiler.main(GroovyPageForkedCompiler.groovy:106) `AnnotationNode.isTargetAllowed` was added in Groovy 6 (GROOVY-11838) to honour the new default annotation targets and uses `NodeMetaDataHandler.getNodeMetaData` (a `Map.computeIfAbsent` over an internal `ListHashMap`) on shared `Annotation*` AST nodes. That cache is touched concurrently by the Grails `GroovyPageCompiler` thread pool (`Executors.newFixedThreadPool(availableProcessors() * 2)`) once shared annotations like `@Inject`, `@CompileStatic`, etc. are seen by more than one GSP compile at the same time, which is exactly the case for test apps that pull in Spring/Grails compiled output. `ListHashMap` is not designed for concurrent mutation, so the resize fails with an `ArrayIndexOutOfBoundsException` and the entire GSP compile aborts. Replace the unconditional `availableProcessors() * 2` thread pool with a small `computeGspCompilerParallelism()` helper that: * defaults to 1 worker on Groovy 6 (eliminates the race), * defaults to `availableProcessors() * 2` on Groovy 5 and earlier (preserves prior behaviour), * honours `-Dgrails.gsp.compiler.parallelism=N` so callers can opt back into parallel GSP compilation once Groovy 6 fixes the race (or experimentally tune it down on Groovy 5). Trade-off: a small wall-clock increase on Groovy 6 GSP compilation in exchange for deterministic behaviour. The control knob is a single system property, so this is easy to revert once the upstream Groovy fix is available. Verified locally: ./gradlew :grails-gsp-core:compileGroovy --rerun-tasks -> BUILD SUCCESSFUL Assisted-by: claude-code:claude-opus-4-7 --- .../gsp/compiler/GroovyPageCompiler.groovy | 60 ++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageCompiler.groovy b/grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageCompiler.groovy index 9ebc8e1c144..dba23e0d653 100644 --- a/grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageCompiler.groovy +++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageCompiler.groovy @@ -109,11 +109,25 @@ class GroovyPageCompiler { } compilerConfig.setTargetDirectory(targetDir) compilerConfig.setSourceEncoding(encoding) - ExecutorService threadPool = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 2) + // GSP compilation parallelism is intentionally configurable via the + // grails.gsp.compiler.parallelism system property. The default is + // 1 (serial) under Groovy 6 because Groovy 6.0.0-SNAPSHOT contains + // a thread-safety bug in org.codehaus.groovy.util.ListHashMap that + // surfaces during AnnotationNode.isTargetAllowed -> NodeMetaDataHandler + // .getNodeMetaData -> Map.computeIfAbsent on shared annotation + // metadata (e.g. @Inject, @CompileStatic) when multiple GSPs are + // compiled concurrently. The symptom is "General error during + // instruction selection: Index N out of bounds for length N" with + // an ArrayIndexOutOfBoundsException in ListHashMap.toMap. Falling + // back to a single thread eliminates the race at a small cost in + // wall-clock time. Override with -Dgrails.gsp.compiler.parallelism=N + // (or 0 to use availableProcessors*2) once Groovy 6 fixes this. + int parallelism = computeGspCompilerParallelism() + ExecutorService threadPool = Executors.newFixedThreadPool(parallelism) CompletionService completionService = new ExecutorCompletionService(threadPool) List> futures = [] try { - Integer collationLevel = Runtime.getRuntime().availableProcessors() * 2 + Integer collationLevel = parallelism if (srcFiles.size() < collationLevel) { collationLevel = 1 } @@ -174,6 +188,48 @@ class GroovyPageCompiler { return compileGSPRegistry } + /** + * Resolves the worker-thread count for parallel GSP compilation. + * + * Honours -Dgrails.gsp.compiler.parallelism=N. A value of 0 (or any + * non-positive number) means "use availableProcessors() * 2" (the + * historical Grails default). When the property is unset we default + * to 1 on Groovy 6 (see the inline comment at the call site for why) + * and to availableProcessors() * 2 on Groovy 5 and earlier. + */ + private static int computeGspCompilerParallelism() { + int cores = Runtime.getRuntime().availableProcessors() + int defaultParallelism = isGroovy6OrLater() ? 1 : cores * 2 + + String override = System.getProperty('grails.gsp.compiler.parallelism') + if (override == null || override.isEmpty()) { + return defaultParallelism + } + try { + int requested = Integer.parseInt(override.trim()) + if (requested <= 0) { + return cores * 2 + } + return requested + } catch (NumberFormatException ignore) { + return defaultParallelism + } + } + + private static boolean isGroovy6OrLater() { + String version = groovy.lang.GroovySystem.getVersion() + if (version == null || version.isEmpty()) { + return false + } + try { + int dot = version.indexOf('.') + int major = Integer.parseInt(dot >= 0 ? version.substring(0, dot) : version) + return major >= 6 + } catch (NumberFormatException ignore) { + return false + } + } + /** * Compiles an individual GSP file * From 95e921235604512cb2e91529b9cdb7a0debf13cf Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sat, 25 Apr 2026 12:06:03 -0400 Subject: [PATCH 15/26] style: drop a leftover consecutive blank line in GormEntity Removing the obsolete static get(String) Groovy 6 workaround in 8e9cdbc50f left a doubled blank line above the read(Serializable) method, which the Core Projects CI job flagged via the CodeNarc ConsecutiveBlankLines rule: GormEntity.groovy:608 - File GormEntity.groovy has consecutive blank lines Tighten back to a single blank separator. No semantic change. Verified locally: ./gradlew :grails-datamapping-core:codenarcMain :grails-gsp-core:codenarcMain -> BUILD SUCCESSFUL Assisted-by: claude-code:claude-opus-4-7 --- .../src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy | 1 - 1 file changed, 1 deletion(-) diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy index 6010581f821..5400ca65c43 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy @@ -606,7 +606,6 @@ trait GormEntity implements GormValidateable, DirtyCheckable, GormEntityApi Date: Sat, 25 Apr 2026 13:14:04 -0400 Subject: [PATCH 16/26] fix(sbom): revert LICENSE_GROUP_MAPPING per @jdaugherty review Per @jdaugherty review on https://github.com/apache/grails-core/pull/15558#discussion_r2462498862: > This defeats the entire purpose of this plugin. We should not wholesale > map these. every version has to be checked because at any time a license > can change. We need to review these individually > > FYI: if these are really wrong, we should be pushing upstream on cyclone > or the jline project itself to fix their licensing. Both points are correct. The SBOM plugin's value is exactly that each artifact-version is auditable, and a wholesale group rule erases that guarantee the moment a transitive bumps onto a new major. Drop the LICENSE_GROUP_MAPPING map and the matching group-fallback branch in pickLicense, and go back to per-version entries with explicit provenance. Per-version replacements added (each carries the upstream-versioned LICENSE.txt URL inline so future maintainers can re-verify on the next SNAPSHOT bump): pkg:maven/org.jline/jansi@4.0.12 BSD-3-Clause pkg:maven/org.jline/jline@3.30.6 BSD-3-Clause (direct) pkg:maven/org.jline/jline-builtins@4.0.12 BSD-3-Clause pkg:maven/org.jline/jline-console@4.0.12 BSD-3-Clause pkg:maven/org.jline/jline-console-ui@4.0.12 BSD-3-Clause pkg:maven/org.jline/jline-native@4.0.12 BSD-3-Clause pkg:maven/org.jline/jline-reader@4.0.12 BSD-3-Clause pkg:maven/org.jline/jline-shell@4.0.12 BSD-3-Clause pkg:maven/org.jline/jline-style@4.0.12 BSD-3-Clause pkg:maven/org.jline/jline-terminal@4.0.12 BSD-3-Clause pkg:maven/org.jline/jline-terminal-jni@4.0.12 BSD-3-Clause Each was verified against https://github.com/jline/jline3/blob/jline-parent-/LICENSE.txt which carries the BSD-3-Clause text. The cyclonedx-core-java#205 misclassification (BSD-4-Clause) is the same root issue we have for the 2.14.6 / antlr4 entries. The 3.30.9 and 4.0.7 entries from the merge with grails8-groovy5-sb4 are dropped because Groovy 6.0.0-SNAPSHOT now resolves the entire org.jline:* group to 4.0.12 transitively via groovy-groovysh; verified with `:grails-shell-cli:dependencies --configuration runtimeClasspath` plus the `Forcing license for ...` log lines on cyclonedxBom. If a future SNAPSHOT bumps onto a new major (5.x), we add fresh per-version entries with re-verified provenance, exactly as the SBOM plugin intends. Verified locally: ./gradlew :grails-shell-cli:cyclonedxBom :grails-console:cyclonedxBom \ :grails-dependencies-starter-web:cyclonedxBom \ -PskipCodeStyle --rerun-tasks -> BUILD SUCCESSFUL in 1m 56s Assisted-by: claude-code:claude-opus-4-7 --- .../apache/grails/buildsrc/SbomPlugin.groovy | 45 +++++-------------- 1 file changed, 11 insertions(+), 34 deletions(-) diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy index b12d9bb6d0f..a053d547196 100644 --- a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy @@ -94,29 +94,21 @@ class SbomPlugin implements Plugin { 'pkg:maven/jline/jline@2.14.6?type=jar' : 'BSD-2-Clause', // legacy jline:jline group, BSD-2; maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 'pkg:maven/opensymphony/sitemesh@2.6.0?type=jar' : 'OpenSymphony', // custom license approved by legal LEGAL-707 'pkg:maven/org.antlr/antlr4-runtime@4.7.2?type=jar' : 'BSD-3-Clause', // maps incorrectly because of https://github.com/CycloneDX/cyclonedx-core-java/issues/205 + 'pkg:maven/org.jline/jansi@4.0.12?type=jar' : 'BSD-3-Clause', // jline 4.0.12 LICENSE.txt at https://github.com/jline/jline3/blob/jline-parent-4.0.12/LICENSE.txt confirms BSD-3-Clause; cyclonedx misreports as BSD-4-Clause (cyclonedx-core-java#205); resolved transitively via groovy-groovysh on Groovy 6 + 'pkg:maven/org.jline/jline@3.30.6?type=jar' : 'BSD-3-Clause', // jline 3.30.6 LICENSE at https://github.com/jline/jline3/blob/jline-parent-3.30.6/LICENSE.txt confirms BSD-3-Clause; direct dependency declared at jline.version in dependencies.gradle + 'pkg:maven/org.jline/jline-builtins@4.0.12?type=jar' : 'BSD-3-Clause', // jline 4.0.12 LICENSE.txt at https://github.com/jline/jline3/blob/jline-parent-4.0.12/LICENSE.txt confirms BSD-3-Clause; resolved transitively via groovy-groovysh on Groovy 6 + 'pkg:maven/org.jline/jline-console@4.0.12?type=jar' : 'BSD-3-Clause', // jline 4.0.12 LICENSE.txt at https://github.com/jline/jline3/blob/jline-parent-4.0.12/LICENSE.txt confirms BSD-3-Clause; resolved transitively via groovy-groovysh on Groovy 6 + 'pkg:maven/org.jline/jline-console-ui@4.0.12?type=jar' : 'BSD-3-Clause', // jline 4.0.12 LICENSE.txt at https://github.com/jline/jline3/blob/jline-parent-4.0.12/LICENSE.txt confirms BSD-3-Clause; resolved transitively via groovy-groovysh on Groovy 6 + 'pkg:maven/org.jline/jline-native@4.0.12?type=jar' : 'BSD-3-Clause', // jline 4.0.12 LICENSE.txt at https://github.com/jline/jline3/blob/jline-parent-4.0.12/LICENSE.txt confirms BSD-3-Clause; resolved transitively via groovy-groovysh on Groovy 6 + 'pkg:maven/org.jline/jline-reader@4.0.12?type=jar' : 'BSD-3-Clause', // jline 4.0.12 LICENSE.txt at https://github.com/jline/jline3/blob/jline-parent-4.0.12/LICENSE.txt confirms BSD-3-Clause; resolved transitively via groovy-groovysh on Groovy 6 + 'pkg:maven/org.jline/jline-shell@4.0.12?type=jar' : 'BSD-3-Clause', // jline 4.0.12 LICENSE.txt at https://github.com/jline/jline3/blob/jline-parent-4.0.12/LICENSE.txt confirms BSD-3-Clause; resolved transitively via groovy-groovysh on Groovy 6 + 'pkg:maven/org.jline/jline-style@4.0.12?type=jar' : 'BSD-3-Clause', // jline 4.0.12 LICENSE.txt at https://github.com/jline/jline3/blob/jline-parent-4.0.12/LICENSE.txt confirms BSD-3-Clause; resolved transitively via groovy-groovysh on Groovy 6 + 'pkg:maven/org.jline/jline-terminal@4.0.12?type=jar' : 'BSD-3-Clause', // jline 4.0.12 LICENSE.txt at https://github.com/jline/jline3/blob/jline-parent-4.0.12/LICENSE.txt confirms BSD-3-Clause; resolved transitively via groovy-groovysh on Groovy 6 + 'pkg:maven/org.jline/jline-terminal-jni@4.0.12?type=jar' : 'BSD-3-Clause', // jline 4.0.12 LICENSE.txt at https://github.com/jline/jline3/blob/jline-parent-4.0.12/LICENSE.txt confirms BSD-3-Clause; resolved transitively via groovy-groovysh on Groovy 6 'pkg:maven/org.jruby/jzlib@1.1.5?type=jar' : 'BSD-3-Clause', // https://web.archive.org/web/20240822213507/http://www.jcraft.com/jzlib/LICENSE.txt shows it's a 3 clause 'pkg:maven/org.liquibase.ext/liquibase-hibernate5@4.27.0?type=jar': 'Apache-2.0', // maps incorrectly because of https://github.com/liquibase/liquibase/issues/2445 & the base pom does not define a license ] - /** - * Group-level license overrides applied AFTER {@link #LICENSE_MAPPING} fails to match. - * The key is a purl prefix (e.g. {@code 'pkg:maven/org.jline/'}) and the value is the - * SPDX license id to force for any artifact whose bomRef starts with that prefix. - * - * This exists for groups that: - * (a) have a stable license across all artifacts and versions, AND - * (b) suffer from cyclonedx-core-java#205 (license is misreported), AND - * (c) are pulled transitively by SNAPSHOT dependencies (e.g. groovy-groovysh -> - * org.jline:* drifts on every Groovy SNAPSHOT bump), making per-version entries - * unmaintainable. - * - * Only add a group entry when ALL three conditions hold. Per-version entries in - * {@link #LICENSE_MAPPING} should still be preferred for one-off overrides. - */ - private static Map LICENSE_GROUP_MAPPING = [ - 'pkg:maven/org.jline/': 'BSD-3-Clause', // entire org.jline group is BSD-3-Clause; cyclonedx misreports it (cyclonedx-core-java#205) and versions drift via groovy-groovysh on every SNAPSHOT bump - ] - // we don't distribute these so these licenses are considered acceptable, but we still prefer ASF licenses. // Require a whitelist of any case of category X licenses to prevent accidental inclusion in a distributed artifact // this list will need to be updated anytime we change versions so we can revise the licenses @@ -336,21 +328,6 @@ class SbomPlugin implements Plugin { return licenseBlock } - // Fallback: group-level override matched by purl prefix. See LICENSE_GROUP_MAPPING - // for criteria (stable license + cyclonedx misreport + SNAPSHOT version drift). - def groupOverride = LICENSE_GROUP_MAPPING.find { prefix, _ -> bomRef.startsWith(prefix) } - if (groupOverride) { - def licenseId = groupOverride.value - logger.lifecycle('Forcing license for {} to {} via group rule {}', bomRef, licenseId, groupOverride.key) - - def licenseBlock = LICENSES[licenseId] - if (!licenseBlock) { - throw new GradleException("Cannot find license information for id ${licenseId} to use for bomRef ${bomRef} in project ${projectName}") - } - - return licenseBlock - } - if (!(licenseChoices instanceof List) || licenseChoices.isEmpty()) { throw new GradleException("No License was found for dependency: ${bomRef} in project ${projectName}") } From 48598b43b802a60e4c90279482006170c3e757d9 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sat, 25 Apr 2026 14:33:28 -0400 Subject: [PATCH 17/26] test(forge): capture both stdout and stderr from generated-app gradle build The Build Grails Forge CI jobs have been failing on this PR with: CreateControllerCommandSpec > test app with controller FAILED Condition not satisfied after 240.00 seconds and 240 attempts output.toString().contains(value) | false BUILD SUCCESSFUL | ... | > Task :compileTestGroovy FAILED | gradle/actions: Writing build results to ... We can see compileTestGroovy fails in the generated app, but the actual compiler error message is not visible anywhere in the CI log. The PollingConditions assertion only inspects what is captured in `output`, and `executeCommand` here only consumes the forked Gradle process's *stdout* (process.consumeProcessOutputStream(output)). Compile-error diagnostics from groovyc / Spock are written to *stderr* and are therefore silently dropped on every failed run. Switch to consumeProcessOutput(stdout, stderr) with the same StringBuilder for both streams so the next CI run surfaces the actual compiler error in the assertion failure (and in any future debugging). This is a test-only change to test infrastructure; production code is unaffected. Once the underlying compile failure is identified and fixed, this can stay (it is the more useful default) or be reverted at the maintainer's discretion. Assisted-by: claude-code:claude-opus-4-7 --- .../src/test/groovy/org/grails/forge/cli/CommandSpec.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grails-forge/grails-forge-cli/src/test/groovy/org/grails/forge/cli/CommandSpec.groovy b/grails-forge/grails-forge-cli/src/test/groovy/org/grails/forge/cli/CommandSpec.groovy index 7aeabecc167..c5cb33600cc 100644 --- a/grails-forge/grails-forge-cli/src/test/groovy/org/grails/forge/cli/CommandSpec.groovy +++ b/grails-forge/grails-forge-cli/src/test/groovy/org/grails/forge/cli/CommandSpec.groovy @@ -72,7 +72,7 @@ class CommandSpec extends Specification { pb.environment().put('JAVA_HOME', System.getenv('JAVA_HOME') ?: System.getProperty('java.home')) pb.environment().put('GRAILS_REPO_URL', System.getenv('GRAILS_REPO_URL') ?: null) process = pb.directory(dir).start() - process.consumeProcessOutputStream(output) + process.consumeProcessOutput(output, output) process } From 5d71eb4035de02016162ef98eb681cdc691624a5 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sat, 25 Apr 2026 15:19:31 -0400 Subject: [PATCH 18/26] fix(forge): bypass Spock Groovy-version check in generated app build.gradle The Build Grails Forge CI jobs were failing because the gradle build of each forge-generated test app aborted at compileTestGroovy with: Could not instantiate global transform class org.spockframework.compiler.SpockTransform specified at jar:.../spock-core-2.4-groovy-5.0.jar!/META-INF/services/... because of exception org.spockframework.util.IncompatibleGroovyVersionException: The Spock compiler plugin cannot execute because Spock 2.4.0-groovy-5.0 is not compatible with Groovy 6.0.0-SNAPSHOT. (Captured by the CommandSpec stderr fix in 48598b43b8 which was otherwise dropping this diagnostic on the floor.) The Grails 8 + Groovy 6 canary BOM still pins Spock to 2.4-groovy-5.0 because no Groovy 6-compatible Spock artifact is published yet. Spock's own version check is purely a guard - the compile itself completes when the bypass is enabled. The Grails core build does this in build-logic/.../CompilePlugin and the shared gradle/test-config.gradle. The generated apps did not have an equivalent, so they failed every time on this canary. Add the Spock bypass to the buildGradle.rocker.raw template under the existing `if (features.contains("spock"))` block, on both: - `tasks.withType(GroovyCompile)` via `options.forkOptions.jvmArgs` (the AST transform classpath where SpockTransform actually loads), - `tasks.withType(Test)` via `systemProperty` (the Test JVM where BeanBuilder.loadBeans() and similar compile Groovy scripts at runtime). The flag is a no-op when Spock and Groovy major versions match, so it is safe to set unconditionally. The inline comment in the template documents the symptom, the trade-off, and the removal trigger (grails-bom pinning a Spock artifact whose Groovy major matches groovy.version). Existing SpockSpec test still passes (it asserts on useJUnitPlatform() and the spock-core dependency, both preserved). Verified the rocker template compiles via: ./gradlew :grails-forge-core:generateRockerTemplateSource :grails-forge-core:compileGroovy -> BUILD SUCCESSFUL Assisted-by: claude-code:claude-opus-4-7 --- .../build/gradle/templates/buildGradle.rocker.raw | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/build/gradle/templates/buildGradle.rocker.raw b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/build/gradle/templates/buildGradle.rocker.raw index 495addb16ff..4d440819e8e 100644 --- a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/build/gradle/templates/buildGradle.rocker.raw +++ b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/build/gradle/templates/buildGradle.rocker.raw @@ -107,8 +107,21 @@ tasks.named('bootRun') { } @if (features.contains("spock")) { +// Spock 2.4-groovy-5.0 (managed by grails-bom while a Groovy 6-compatible +// Spock artifact is unreleased) refuses to load its compiler AST transform +// against Groovy 6 with IncompatibleGroovyVersionException. The runtime +// effect of this check is just a guard, so opt out on both the +// GroovyCompile classpath (where Spock's AST transform runs) and the Test +// JVM (where BeanBuilder.loadBeans() and similar compile Groovy at +// runtime). The flag is a no-op when Spock and Groovy major versions +// match, so it is safe to always set; remove this block once grails-bom +// pins a Spock artifact whose Groovy major matches groovy.version. +tasks.withType(GroovyCompile).configureEach { + options.forkOptions.jvmArgs = (options.forkOptions.jvmArgs ?: []) + ['-Dspock.iKnowWhatImDoing.disableGroovyVersionCheck=true'] +} tasks.withType(Test).configureEach { useJUnitPlatform() + systemProperty 'spock.iKnowWhatImDoing.disableGroovyVersionCheck', 'true' @if (features.contains("geb")) { systemProperty "geb.env", System.getProperty('geb.env') systemProperty "geb.build.reportsDir", reporting.file("geb/integrationTest") From 18b9e52a204832b91dbdf2e2f3821509692972f3 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sat, 25 Apr 2026 15:49:32 -0400 Subject: [PATCH 19/26] fix: serialise GSON/views template compilation under Groovy 6 Mongodb Functional Tests (Java 21, MongoDB 7.0, indy=true) failed in the latest run with the same Groovy 6 ListHashMap thread-safety regression that the GSP-side fix in ddc7ea20c6 already addressed, but now triggered through the views (.gson) compiler: > Task :grails-test-examples-hibernate5-grails-data-service:compileGsonViews FAILED Exception in thread "main" java.util.concurrent.ExecutionException: org.codehaus.groovy.control.MultipleCompilationErrorsException: startup failed: General error during instruction selection: Index 3 out of bounds for length 3 java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3 at org.codehaus.groovy.util.ListHashMap.toMap(ListHashMap.java:207) at org.codehaus.groovy.util.ListHashMap.put(ListHashMap.java:146) at java.base/java.util.Map.computeIfAbsent(Map.java:1067) at org.codehaus.groovy.ast.NodeMetaDataHandler.getNodeMetaData(...) at org.codehaus.groovy.ast.AnnotationNode.isTargetAllowed(...) `AbstractGroovyTemplateCompiler.compile(List)` was using `Executors.newFixedThreadPool(availableProcessors() * 2)`, the same historical default as `GroovyPageCompiler`, and the same fix applies: default parallelism to 1 on Groovy 6 to dodge the race; preserve `availableProcessors() * 2` on Groovy 5 and earlier; allow opt-back-in or override via `-Dgrails.views.compiler.parallelism=N`. Mirrors the GSP-side `computeGspCompilerParallelism()` helper from ddc7ea20c6 (`grails.gsp.compiler.parallelism` system property). The inline comment at the call site documents the symptom, the Groovy classes involved, the trade-off, and the toggle property. Verified locally: ./gradlew :grails-views-core:compileGroovy --rerun-tasks -> BUILD SUCCESSFUL Assisted-by: claude-code:claude-opus-4-7 --- .../AbstractGroovyTemplateCompiler.groovy | 59 ++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/grails-views-core/src/main/groovy/grails/views/AbstractGroovyTemplateCompiler.groovy b/grails-views-core/src/main/groovy/grails/views/AbstractGroovyTemplateCompiler.groovy index e12a3fbe823..e66e3910d66 100644 --- a/grails-views-core/src/main/groovy/grails/views/AbstractGroovyTemplateCompiler.groovy +++ b/grails-views-core/src/main/groovy/grails/views/AbstractGroovyTemplateCompiler.groovy @@ -82,11 +82,25 @@ abstract class AbstractGroovyTemplateCompiler { void compile(List sources) { - ExecutorService threadPool = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 2) + // Mirror the GSP-side guard in GroovyPageCompiler: Groovy 6.0.0-SNAPSHOT + // contains a thread-safety bug in org.codehaus.groovy.util.ListHashMap + // reachable through AnnotationNode.isTargetAllowed -> + // NodeMetaDataHandler.getNodeMetaData -> Map.computeIfAbsent on shared + // annotation metadata when multiple template compiles concurrently + // touch the same AST. Surfaces in CI as + // General error during instruction selection: Index N out of bounds + // java.lang.ArrayIndexOutOfBoundsException ... at ListHashMap.toMap + // during :grails-test-examples-*:compileGsonViews. Default to a single + // worker on Groovy 6 to dodge the race; preserve the historical + // availableProcessors() * 2 default on Groovy 5 and earlier. Override + // with -Dgrails.views.compiler.parallelism=N once Groovy 6 fixes this + // (or 0 to use availableProcessors() * 2 explicitly). + int parallelism = computeParallelism() + ExecutorService threadPool = Executors.newFixedThreadPool(parallelism) CompletionService completionService = new ExecutorCompletionService(threadPool) try { - Integer collationLevel = Runtime.getRuntime().availableProcessors() * 2 + Integer collationLevel = parallelism if (sources.size() < collationLevel) { collationLevel = 1 } @@ -143,6 +157,47 @@ abstract class AbstractGroovyTemplateCompiler { compile(Arrays.asList(sources)) } + /** + * Resolves the worker-thread count for parallel template compilation. + * Honours -Dgrails.views.compiler.parallelism=N. A non-positive override + * means "use availableProcessors() * 2" (the historical default). When the + * property is unset we default to 1 on Groovy 6 (see the inline comment at + * the call site for the ListHashMap thread-safety reasoning) and to + * availableProcessors() * 2 on Groovy 5 and earlier. + */ + private static int computeParallelism() { + int cores = Runtime.getRuntime().availableProcessors() + int defaultParallelism = isGroovy6OrLater() ? 1 : cores * 2 + + String override = System.getProperty('grails.views.compiler.parallelism') + if (override == null || override.isEmpty()) { + return defaultParallelism + } + try { + int requested = Integer.parseInt(override.trim()) + if (requested <= 0) { + return cores * 2 + } + return requested + } catch (NumberFormatException ignore) { + return defaultParallelism + } + } + + private static boolean isGroovy6OrLater() { + String version = groovy.lang.GroovySystem.getVersion() + if (version == null || version.isEmpty()) { + return false + } + try { + int dot = version.indexOf('.') + int major = Integer.parseInt(dot >= 0 ? version.substring(0, dot) : version) + return major >= 6 + } catch (NumberFormatException ignore) { + return false + } + } + static void run(String[] args, Class configurationClass, Class compilerClass) { if (args.length != 7) { System.err.println("Invalid arguments: [${args.join(',')}]") From 8af3b23216a6fdd317541cbddb847a6130ffe7b4 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sat, 25 Apr 2026 20:06:15 -0400 Subject: [PATCH 20/26] docs: drop incorrect GROOVY-11829 citation from GormEntity workaround comments The GROOVY-11829 cross-reference in three places turned out to be the wrong JIRA: https://issues.apache.org/jira/browse/GROOVY-11829 is "Properties located from a set(key, value) always use the same method even when the value type is better matched by another" - resolved 2026-01-01, fix version 6.0.0-alpha-1, and entirely about set(...) overload selection, not the get(...) dispatch behaviour we work around in GormEntity. Re-checked the actual mechanism on apache/groovy master HEAD `f5ab762500` (committed 2026-04-25 15:06 UTC, 11 minutes before the snapshot we test with): private static boolean isGenericGetMethod(MetaMethod method) { if (method.getName().equals("get")) { CachedClass[] parameterTypes = method.getParameterTypes(); return parameterTypes.length == 1 && parameterTypes[0].getTheClass() == String.class; } return false; } So the genericGetMethod selection still requires String.class. The regression we hit was a different one entirely: a trait-static get(String) is picked up by the *implementing class's* MOP as a candidate for instance-property generic-getter dispatch, returning a GormStaticApi where propertyMissing should produce a DelegatingGormEntityApi. There is no upstream Apache Groovy JIRA we could find for this dispatch behaviour at the time of writing. Update the three citations to: * GormEntity.get(Serializable) docstring: drop the relaxed-isGenericGetMethod story (it never happened), describe the actual symptom (instance-MOP picking up the trait-static get on @Entity classes, Hibernate "Unknown entity: java.util.LinkedHashMap"), point at the GormEntityTransformation AST shim as the home of the fix, and note that no upstream JIRA is filed. * GormEntityTransformation: same symptom narrative, drop the GROOVY-11829 reference, add an explicit "remove this once an upstream JIRA is filed and fixed (or once Spock 2.x ships a Groovy 6-compatible artifact and we re-validate)" pointer. * GormEntityTransformSpec: rename the feature method to "test Groovy 6 generic-getter instance-dispatch guard" (no JIRA in the title) and rewrite the docstring to match. Verified locally: ./gradlew :grails-datamapping-core:test \ --tests 'org.grails.compiler.gorm.GormEntityTransformSpec' -> 9 tests, 0 failures, BUILD SUCCESSFUL ./gradlew :grails-datamapping-core:codenarcMain \ :grails-datamapping-core:codenarcTest -> BUILD SUCCESSFUL No production-code behaviour changed; this is purely the comment / docstring / spec-method-name cleanup pass. Assisted-by: claude-code:claude-opus-4-7 --- .../gorm/GormEntityTransformation.groovy | 28 +++++++++++-------- .../grails/datastore/gorm/GormEntity.groovy | 25 +++++++++++------ .../gorm/GormEntityTransformSpec.groovy | 4 +-- 3 files changed, 34 insertions(+), 23 deletions(-) diff --git a/grails-datamapping-core/src/main/groovy/org/grails/compiler/gorm/GormEntityTransformation.groovy b/grails-datamapping-core/src/main/groovy/org/grails/compiler/gorm/GormEntityTransformation.groovy index f121d846505..e67361a19f1 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/compiler/gorm/GormEntityTransformation.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/compiler/gorm/GormEntityTransformation.groovy @@ -292,18 +292,22 @@ class GormEntityTransformation extends AbstractASTTransformation implements Comp classNode.addMethod('$static_propertyMissing', Modifier.PUBLIC | Modifier.STATIC, AstUtils.OBJECT_CLASS_NODE, propertyMissingGetParameters, null, propertyMissingGetBody) markAsGenerated(classNode, propertyMissingNodeGetter) - // INSTANCE Object get(String name) - Groovy 6 GROOVY-11829 instance dispatch guard. - // The STATIC get(String) on the GormEntity trait is picked up by Groovy 6's instance - // MOP as the generic-getter for instance property access (it shows in - // metaClass.respondsTo(instance, 'get', String)), returning a connection-scoped - // GormStaticApi where the entity-level propertyMissing should have returned a - // DelegatingGormEntityApi. The mismatched type silently corrupts call chains like - // book.someConnection.delete(flush: true) - "Unknown entity: java.util.LinkedHashMap". - // Adding an instance overload directly to the entity class here gives instance MOP a - // more specific candidate than the inherited trait-static method, so it wins dispatch - // and delegates back to the existing instance propertyMissing. Adding via AST instead - // of declaring on the trait avoids the "static and instance methods having the same - // signature" trait-merge error since the trait still owns only the static get(String). + // INSTANCE Object get(String name) - Groovy 6 instance-dispatch guard. + // On Groovy 6, a static get(String) on the GormEntity trait was being picked up + // by the implementing class's instance MOP as the generic-getter for instance + // property access (it appears in metaClass.respondsTo(instance, 'get', String)), + // returning a connection-scoped GormStaticApi where the entity-level + // propertyMissing should have returned a DelegatingGormEntityApi. The mismatched + // type silently corrupts call chains like book.someConnection.delete(flush: true) - + // "Unknown entity: java.util.LinkedHashMap" deep in Hibernate. + // Adding an instance overload directly to the entity class via AST gives instance + // MOP a more specific candidate than the trait-static path, so it wins dispatch + // and delegates back to the existing instance propertyMissing. Adding via AST + // instead of declaring on the trait also avoids the "static and instance methods + // having the same signature" trait-merge error. + // No upstream Apache Groovy JIRA identified for this dispatch behaviour; the AST + // shim should be removed once one is filed and fixed (or once Spock 2.x releases + // a Groovy 6-compatible artifact and we re-validate the canary end-to-end). def instanceGetBody = new BlockStatement() def instanceGetNameParam = new Parameter(ClassHelper.make(String), 'name') def instanceGetArgs = new ArgumentListExpression(instanceGetNameParam) diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy index 5400ca65c43..4c6e998069c 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEntity.groovy @@ -591,15 +591,22 @@ trait GormEntity implements GormValidateable, DirtyCheckable, GormEntityApi Date: Mon, 27 Apr 2026 12:40:26 -0400 Subject: [PATCH 21/26] Add standalone reproducer link to GormEntity get(String) AST shim Reproducer at https://github.com/jamesfredley/groovy6-get-as-generic-getter isolates the actual Groovy 6 MOP regression to four small files (no Grails, no GORM, no Hibernate). Updates the inline comment to point at the upstream bug (Groovy 6 picks the inherited Object get(Serializable) as the genericGetMethod for instance property access) rather than the previous 'no upstream JIRA identified' framing - the reproducer narrows it down to a specific apache/groovy MOP behaviour change between 5.0.6-SNAPSHOT and 6.0.0-SNAPSHOT. --- .../gorm/GormEntityTransformation.groovy | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/grails-datamapping-core/src/main/groovy/org/grails/compiler/gorm/GormEntityTransformation.groovy b/grails-datamapping-core/src/main/groovy/org/grails/compiler/gorm/GormEntityTransformation.groovy index e67361a19f1..0b75c73214c 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/compiler/gorm/GormEntityTransformation.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/compiler/gorm/GormEntityTransformation.groovy @@ -292,22 +292,21 @@ class GormEntityTransformation extends AbstractASTTransformation implements Comp classNode.addMethod('$static_propertyMissing', Modifier.PUBLIC | Modifier.STATIC, AstUtils.OBJECT_CLASS_NODE, propertyMissingGetParameters, null, propertyMissingGetBody) markAsGenerated(classNode, propertyMissingNodeGetter) - // INSTANCE Object get(String name) - Groovy 6 instance-dispatch guard. - // On Groovy 6, a static get(String) on the GormEntity trait was being picked up - // by the implementing class's instance MOP as the generic-getter for instance - // property access (it appears in metaClass.respondsTo(instance, 'get', String)), - // returning a connection-scoped GormStaticApi where the entity-level - // propertyMissing should have returned a DelegatingGormEntityApi. The mismatched - // type silently corrupts call chains like book.someConnection.delete(flush: true) - - // "Unknown entity: java.util.LinkedHashMap" deep in Hibernate. - // Adding an instance overload directly to the entity class via AST gives instance - // MOP a more specific candidate than the trait-static path, so it wins dispatch - // and delegates back to the existing instance propertyMissing. Adding via AST - // instead of declaring on the trait also avoids the "static and instance methods - // having the same signature" trait-merge error. - // No upstream Apache Groovy JIRA identified for this dispatch behaviour; the AST - // shim should be removed once one is filed and fixed (or once Spock 2.x releases - // a Groovy 6-compatible artifact and we re-validate the canary end-to-end). + // INSTANCE Object get(String name) - Groovy 6 generic-getter MOP regression workaround. + // On Groovy 6, MetaClassImpl picks up the inherited GormEntity.get(Serializable) + // entity-by-ID method as the genericGetMethod for instance property access on the + // implementing class, hijacking every dynamic property read - including ones that + // should fall through to propertyMissing(String) for datasource qualifiers. Result: + // book.someConnection.delete(flush: true) silently returns the get(Serializable) value + // (an entity row or null) instead of the expected DelegatingGormEntityApi, which then + // surfaces as "Unknown entity: java.util.LinkedHashMap" deep in Hibernate or as NPEs + // in HibernateRuntimeUtils.setupErrorsProperty. + // Workaround: add an instance Object get(String) directly on every @Entity class via + // AST. Groovy's instance MOP picks the more-specific String overload over the + // inherited Serializable one, so the generic-getter winds up routing through the + // existing propertyMissing(String) and yields a DelegatingGormEntityApi as expected. + // Standalone reproducer: https://github.com/jamesfredley/groovy6-get-as-generic-getter + // No upstream Apache Groovy JIRA filed yet; remove this shim once one is filed and fixed. def instanceGetBody = new BlockStatement() def instanceGetNameParam = new Parameter(ClassHelper.make(String), 'name') def instanceGetArgs = new ArgumentListExpression(instanceGetNameParam) From fb717d31baded2cb17aa677facff80ecf599a0e9 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Mon, 27 Apr 2026 12:48:47 -0400 Subject: [PATCH 22/26] Validateable: link standalone reproducer for the TraitReceiverTransformer regression Reproducer at https://github.com/jamesfredley/groovy-trait-static-method-override-bug isolates the trait static override hijacking to three small files. Confirms the regression is identical on Groovy 5.0.6-SNAPSHOT and 6.0.0-SNAPSHOT (passes on Groovy 4.0.31). Updates the inline javadoc on resolveDefaultNullable accordingly and points future maintainers at the upstream reproducer. --- .../groovy/grails/validation/Validateable.groovy | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/grails-validation/src/main/groovy/grails/validation/Validateable.groovy b/grails-validation/src/main/groovy/grails/validation/Validateable.groovy index 48b2ff77b11..bdcc1ec1977 100644 --- a/grails-validation/src/main/groovy/grails/validation/Validateable.groovy +++ b/grails-validation/src/main/groovy/grails/validation/Validateable.groovy @@ -283,11 +283,16 @@ trait Validateable { /** * Resolves {@code defaultNullable()} via Java reflection to preserve - * override semantics under {@code @CompileStatic}. In Groovy 5 the - * {@code TraitReceiverTransformer} routes {@code this.defaultNullable()} - * from a static trait method to the trait's helper, losing the - * implementing-class override. Calling through {@link java.lang.reflect.Method#invoke} - * dispatches to the actual bytecode on the implementing class. + * override semantics. Starting in Groovy 5, {@code TraitReceiverTransformer} + * rewrites {@code this.defaultNullable()} from another method inside the + * trait body to a direct call into the trait helper's static method, + * silently losing any implementing-class override. The same regression + * is present in Groovy 6.0.0-SNAPSHOT. {@link java.lang.reflect.Method#invoke} + * is opaque to the transform so it dispatches to the implementing-class + * bytecode directly. + * + * Standalone reproducer: + * https://github.com/jamesfredley/groovy-trait-static-method-override-bug * * Only the lookup path catches checked reflection failures; exceptions * thrown from the real {@code defaultNullable()} implementation are From e6332edb4a56ab577529e923202e932d2802776c Mon Sep 17 00:00:00 2001 From: James Fredley Date: Mon, 27 Apr 2026 13:08:09 -0400 Subject: [PATCH 23/26] ContainerSupport: link upstream PR #2495 (GROOVY-11968) and standalone reproducer Verified on absolute-latest Groovy 6.0.0-SNAPSHOT build #518 (2026-04-27 14:33 UTC) that the @CompileStatic + trait-static-field + indy=false VerifyError still reproduces. Apache Groovy PR #2495 (GROOVY-11968) by @paulk-asert is the explicit follow-up to GROOVY-11907 that should fix it; opened 2026-04-27, currently OPEN. The ContainerSupport @CompileDynamic shim should be reverted once that PR merges and a fresh snapshot publishes. --- .../geb/support/ContainerSupport.groovy | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/grails-geb/src/testFixtures/groovy/grails/plugin/geb/support/ContainerSupport.groovy b/grails-geb/src/testFixtures/groovy/grails/plugin/geb/support/ContainerSupport.groovy index 99f0023a3c8..1b93d93d2fb 100644 --- a/grails-geb/src/testFixtures/groovy/grails/plugin/geb/support/ContainerSupport.groovy +++ b/grails-geb/src/testFixtures/groovy/grails/plugin/geb/support/ContainerSupport.groovy @@ -34,16 +34,26 @@ import grails.plugin.geb.ContainerGebSpec * @author Mattias Reichel * @since 4.2 */ -// GROOVY-11907 / indy=false bytecode bug: @CompileStatic on a trait with static -// fields generates invalid bytecode for the static setter helpers when the -// downstream consumer is compiled with grailsIndy=false. The Trait$Helper -// methods come out with mismatched local slots (e.g. dload_3 on a 2-local -// frame) and trip a JVM VerifyError ("get long/double overflows locals") at -// ContainerGebSpec class init, which cascades into NoClassDefFoundError on -// every spec that extends ContainerGebSpec. Reproduced locally on Groovy -// 5.0.6-SNAPSHOT with ./gradlew :grails-test-examples-app2:integrationTest -// -PgrailsIndy=false. Keep @CompileDynamic until a Groovy fix lands that -// covers the static-setter path under indy=false. +// GROOVY-11907 follow-up / indy=false bytecode bug: @CompileStatic on a trait +// with static fields generates invalid bytecode for the static setter helpers +// when the downstream consumer is compiled with grailsIndy=false. The +// Trait$Helper methods come out with mismatched local slots (e.g. dload_3 on +// a 2-local frame) and trip a JVM VerifyError ("get long/double overflows +// locals") at ContainerGebSpec class init, cascading into NoClassDefFoundError +// on every spec that extends ContainerGebSpec. +// +// Tracked upstream as GROOVY-11968 (open as of 2026-04-27, apache/groovy +// PR #2495 by @paulk-asert): +// https://issues.apache.org/jira/browse/GROOVY-11968 +// https://github.com/apache/groovy/pull/2495 +// +// Standalone reproducer (`TraitStaticFieldsCheck.groovy` in `quick-checks/`): +// https://github.com/jamesfredley/groovy5-compiledynamic-trait-bug/tree/main/quick-checks +// +// Reproduces on Groovy 5.0.6-SNAPSHOT and on Groovy 6.0.0-SNAPSHOT build #518 +// (2026-04-27 14:33 UTC). Keep @CompileDynamic until apache/groovy#2495 merges +// and a fresh snapshot is published, then re-validate and switch back to +// @CompileStatic. @CompileDynamic @SelfType(ContainerGebSpec) trait ContainerSupport implements DownloadSupport { From 2a5e98355550b38d05b9de8ac538c99bb91da0cb Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sat, 2 May 2026 08:43:18 -0400 Subject: [PATCH 24/26] drop Groovy 6 workarounds whose upstream fixes have merged Re-audited every Groovy 6 workaround in this canary against the latest Apache Groovy master. Three fixes have merged and are present in the 6.0.0-SNAPSHOT artifact (latest publication: 2026-05-02 11:47:43 UTC, build #546), so the corresponding workarounds can be removed. GROOVY-11968 (apache/groovy#2495), merged 2026-05-01 03:40 UTC, SHA 84f2f37c4f93d6ea44ad8bc76570704c84499c6b - grails-geb/.../ContainerSupport.groovy: revert @CompileDynamic to @CompileStatic now that the trait-static-field VerifyError under indy=false no longer triggers. GROOVY-11967 (apache/groovy#2493), merged 2026-05-01 09:37 UTC, SHA 406feaf5082f1741c318f924b520c4c27bfa0754 - DefaultConstraintFactory.groovy: collapse the two explicit constructors back to a single constructor with a default-valued List parameter; the @CompileStatic VerifyError on the synthesised bridge constructor no longer reproduces. - MappingContextAwareConstraintFactory.groovy: same collapse. GROOVY-11966 (apache/groovy#2492), merged 2026-05-01 18:58 UTC, SHA 8dde1c84134ef6fdeecf26b5cbb5183d5aab4dac - GroovyPageCompiler.groovy: drop the parallelism guard and the grails.gsp.compiler.parallelism system property; restore the original Executors.newFixedThreadPool(availableProcessors() * 2) sizing now that AnnotationNode.isTargetAllowed -> ListHashMap is thread-safe again. - AbstractGroovyTemplateCompiler.groovy: same restoration; drop the grails.views.compiler.parallelism system property. Verified locally on Java 21 / Groovy 6.0.0-SNAPSHOT build #546: ./gradlew :grails-datamapping-validation:compileGroovy ./gradlew :grails-datamapping-core:compileGroovy ./gradlew :grails-gsp-core:compileGroovy ./gradlew :grails-views-core:compileGroovy ./gradlew :grails-geb:compileTestFixturesGroovy -> all BUILD SUCCESSFUL The remaining workarounds (TraitReceiverTransformer static-method override loss, MetaClassImpl genericGetMethod hijack on GORM entities, @CompileStatic named-argument render(Map) silent no-op, smart-cast in 'if (cond && !(x instanceof Y))', VariableScopeVisitor NPE, and ConfigObject [] mutation) have no upstream fix yet and stay in place. --- ...appingContextAwareConstraintFactory.groovy | 6 +- .../factory/DefaultConstraintFactory.groovy | 6 +- .../geb/support/ContainerSupport.groovy | 22 +------ .../gsp/compiler/GroovyPageCompiler.groovy | 60 +------------------ .../AbstractGroovyTemplateCompiler.groovy | 59 +----------------- 5 files changed, 7 insertions(+), 146 deletions(-) diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/validation/constraints/MappingContextAwareConstraintFactory.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/validation/constraints/MappingContextAwareConstraintFactory.groovy index 99871cd5ab8..c25501ec391 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/validation/constraints/MappingContextAwareConstraintFactory.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/validation/constraints/MappingContextAwareConstraintFactory.groovy @@ -35,11 +35,7 @@ class MappingContextAwareConstraintFactory extends DefaultConstraintFactory { final MappingContext mappingContext - MappingContextAwareConstraintFactory(Class constraintClass, MessageSource messageSource, MappingContext mappingContext) { - this(constraintClass, messageSource, mappingContext, [Object] as List) - } - - MappingContextAwareConstraintFactory(Class constraintClass, MessageSource messageSource, MappingContext mappingContext, List targetTypes) { + MappingContextAwareConstraintFactory(Class constraintClass, MessageSource messageSource, MappingContext mappingContext, List targetTypes = [Object]) { super(constraintClass, messageSource, targetTypes) this.mappingContext = mappingContext } diff --git a/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/factory/DefaultConstraintFactory.groovy b/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/factory/DefaultConstraintFactory.groovy index 4135f1311ce..001acf048f4 100644 --- a/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/factory/DefaultConstraintFactory.groovy +++ b/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/factory/DefaultConstraintFactory.groovy @@ -47,11 +47,7 @@ class DefaultConstraintFactory implements ConstraintFactory { protected final Constructor constraintConstructor - DefaultConstraintFactory(Class constraintClass, MessageSource messageSource) { - this(constraintClass, messageSource, [Object] as List) - } - - DefaultConstraintFactory(Class constraintClass, MessageSource messageSource, List targetTypes) { + DefaultConstraintFactory(Class constraintClass, MessageSource messageSource, List targetTypes = [Object]) { this.type = constraintClass this.name = Introspector.decapitalize(constraintClass.simpleName) - 'Constraint' this.messageSource = messageSource diff --git a/grails-geb/src/testFixtures/groovy/grails/plugin/geb/support/ContainerSupport.groovy b/grails-geb/src/testFixtures/groovy/grails/plugin/geb/support/ContainerSupport.groovy index e5828e8c73a..968e3a26e20 100644 --- a/grails-geb/src/testFixtures/groovy/grails/plugin/geb/support/ContainerSupport.groovy +++ b/grails-geb/src/testFixtures/groovy/grails/plugin/geb/support/ContainerSupport.groovy @@ -34,27 +34,7 @@ import grails.plugin.geb.ContainerGebSpec * @author Mattias Reichel * @since 4.2 */ -// GROOVY-11907 follow-up / indy=false bytecode bug: @CompileStatic on a trait -// with static fields generates invalid bytecode for the static setter helpers -// when the downstream consumer is compiled with grailsIndy=false. The -// Trait$Helper methods come out with mismatched local slots (e.g. dload_3 on -// a 2-local frame) and trip a JVM VerifyError ("get long/double overflows -// locals") at ContainerGebSpec class init, cascading into NoClassDefFoundError -// on every spec that extends ContainerGebSpec. -// -// Fixed for Groovy 5.0.6-SNAPSHOT (commit 74da8078b5 on grails8-groovy5-sb4 -// restored @CompileStatic for Groovy 5). Tracked upstream as GROOVY-11968 -// (apache/groovy PR #2495 by @paulk-asert): -// https://issues.apache.org/jira/browse/GROOVY-11968 -// https://github.com/apache/groovy/pull/2495 -// -// Standalone reproducer (`TraitStaticFieldsCheck.groovy` in `quick-checks/`): -// https://github.com/jamesfredley/groovy5-compiledynamic-trait-bug/tree/main/quick-checks -// -// Status to re-verify against latest Groovy 6.0.0-SNAPSHOT before each canary -// rebuild: if the fix has propagated to the Groovy 6 line, drop @CompileDynamic -// and switch back to @CompileStatic. -@CompileDynamic +@CompileStatic @SelfType(ContainerGebSpec) trait ContainerSupport implements DownloadSupport { diff --git a/grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageCompiler.groovy b/grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageCompiler.groovy index dba23e0d653..9ebc8e1c144 100644 --- a/grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageCompiler.groovy +++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/compiler/GroovyPageCompiler.groovy @@ -109,25 +109,11 @@ class GroovyPageCompiler { } compilerConfig.setTargetDirectory(targetDir) compilerConfig.setSourceEncoding(encoding) - // GSP compilation parallelism is intentionally configurable via the - // grails.gsp.compiler.parallelism system property. The default is - // 1 (serial) under Groovy 6 because Groovy 6.0.0-SNAPSHOT contains - // a thread-safety bug in org.codehaus.groovy.util.ListHashMap that - // surfaces during AnnotationNode.isTargetAllowed -> NodeMetaDataHandler - // .getNodeMetaData -> Map.computeIfAbsent on shared annotation - // metadata (e.g. @Inject, @CompileStatic) when multiple GSPs are - // compiled concurrently. The symptom is "General error during - // instruction selection: Index N out of bounds for length N" with - // an ArrayIndexOutOfBoundsException in ListHashMap.toMap. Falling - // back to a single thread eliminates the race at a small cost in - // wall-clock time. Override with -Dgrails.gsp.compiler.parallelism=N - // (or 0 to use availableProcessors*2) once Groovy 6 fixes this. - int parallelism = computeGspCompilerParallelism() - ExecutorService threadPool = Executors.newFixedThreadPool(parallelism) + ExecutorService threadPool = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 2) CompletionService completionService = new ExecutorCompletionService(threadPool) List> futures = [] try { - Integer collationLevel = parallelism + Integer collationLevel = Runtime.getRuntime().availableProcessors() * 2 if (srcFiles.size() < collationLevel) { collationLevel = 1 } @@ -188,48 +174,6 @@ class GroovyPageCompiler { return compileGSPRegistry } - /** - * Resolves the worker-thread count for parallel GSP compilation. - * - * Honours -Dgrails.gsp.compiler.parallelism=N. A value of 0 (or any - * non-positive number) means "use availableProcessors() * 2" (the - * historical Grails default). When the property is unset we default - * to 1 on Groovy 6 (see the inline comment at the call site for why) - * and to availableProcessors() * 2 on Groovy 5 and earlier. - */ - private static int computeGspCompilerParallelism() { - int cores = Runtime.getRuntime().availableProcessors() - int defaultParallelism = isGroovy6OrLater() ? 1 : cores * 2 - - String override = System.getProperty('grails.gsp.compiler.parallelism') - if (override == null || override.isEmpty()) { - return defaultParallelism - } - try { - int requested = Integer.parseInt(override.trim()) - if (requested <= 0) { - return cores * 2 - } - return requested - } catch (NumberFormatException ignore) { - return defaultParallelism - } - } - - private static boolean isGroovy6OrLater() { - String version = groovy.lang.GroovySystem.getVersion() - if (version == null || version.isEmpty()) { - return false - } - try { - int dot = version.indexOf('.') - int major = Integer.parseInt(dot >= 0 ? version.substring(0, dot) : version) - return major >= 6 - } catch (NumberFormatException ignore) { - return false - } - } - /** * Compiles an individual GSP file * diff --git a/grails-views-core/src/main/groovy/grails/views/AbstractGroovyTemplateCompiler.groovy b/grails-views-core/src/main/groovy/grails/views/AbstractGroovyTemplateCompiler.groovy index e66e3910d66..e12a3fbe823 100644 --- a/grails-views-core/src/main/groovy/grails/views/AbstractGroovyTemplateCompiler.groovy +++ b/grails-views-core/src/main/groovy/grails/views/AbstractGroovyTemplateCompiler.groovy @@ -82,25 +82,11 @@ abstract class AbstractGroovyTemplateCompiler { void compile(List sources) { - // Mirror the GSP-side guard in GroovyPageCompiler: Groovy 6.0.0-SNAPSHOT - // contains a thread-safety bug in org.codehaus.groovy.util.ListHashMap - // reachable through AnnotationNode.isTargetAllowed -> - // NodeMetaDataHandler.getNodeMetaData -> Map.computeIfAbsent on shared - // annotation metadata when multiple template compiles concurrently - // touch the same AST. Surfaces in CI as - // General error during instruction selection: Index N out of bounds - // java.lang.ArrayIndexOutOfBoundsException ... at ListHashMap.toMap - // during :grails-test-examples-*:compileGsonViews. Default to a single - // worker on Groovy 6 to dodge the race; preserve the historical - // availableProcessors() * 2 default on Groovy 5 and earlier. Override - // with -Dgrails.views.compiler.parallelism=N once Groovy 6 fixes this - // (or 0 to use availableProcessors() * 2 explicitly). - int parallelism = computeParallelism() - ExecutorService threadPool = Executors.newFixedThreadPool(parallelism) + ExecutorService threadPool = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 2) CompletionService completionService = new ExecutorCompletionService(threadPool) try { - Integer collationLevel = parallelism + Integer collationLevel = Runtime.getRuntime().availableProcessors() * 2 if (sources.size() < collationLevel) { collationLevel = 1 } @@ -157,47 +143,6 @@ abstract class AbstractGroovyTemplateCompiler { compile(Arrays.asList(sources)) } - /** - * Resolves the worker-thread count for parallel template compilation. - * Honours -Dgrails.views.compiler.parallelism=N. A non-positive override - * means "use availableProcessors() * 2" (the historical default). When the - * property is unset we default to 1 on Groovy 6 (see the inline comment at - * the call site for the ListHashMap thread-safety reasoning) and to - * availableProcessors() * 2 on Groovy 5 and earlier. - */ - private static int computeParallelism() { - int cores = Runtime.getRuntime().availableProcessors() - int defaultParallelism = isGroovy6OrLater() ? 1 : cores * 2 - - String override = System.getProperty('grails.views.compiler.parallelism') - if (override == null || override.isEmpty()) { - return defaultParallelism - } - try { - int requested = Integer.parseInt(override.trim()) - if (requested <= 0) { - return cores * 2 - } - return requested - } catch (NumberFormatException ignore) { - return defaultParallelism - } - } - - private static boolean isGroovy6OrLater() { - String version = groovy.lang.GroovySystem.getVersion() - if (version == null || version.isEmpty()) { - return false - } - try { - int dot = version.indexOf('.') - int major = Integer.parseInt(dot >= 0 ? version.substring(0, dot) : version) - return major >= 6 - } catch (NumberFormatException ignore) { - return false - } - } - static void run(String[] args, Class configurationClass, Class compilerClass) { if (args.length != 7) { System.err.println("Invalid arguments: [${args.join(',')}]") From 4a518983a2ab20230c12f5da07dbaa6b337b9113 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sat, 2 May 2026 09:07:40 -0400 Subject: [PATCH 25/26] fix: workaround Groovy 6 stub generator regression in HibernateSettings Apache Groovy 6.0.0-SNAPSHOT build #546 (and onward, until upstream fixes it) regresses the Java stub generator: when @AutoClone is applied to a class that extends a JDK type whose clone() override drops the `throws CloneNotSupportedException` clause (LinkedHashMap.clone() is the canonical example), the generated stub still emits @groovy.transform.Generated() public ... HibernateSettings clone() throws java.lang.CloneNotSupportedException { return null; } and javac rejects it because the parent LinkedHashMap.clone() doesn't declare that exception. CI was failing the entire 'Core Projects' job on grails-data-hibernate5-core:compileGroovy with: HibernateConnectionSourceSettings.java:89: error: clone() in HibernateSettings cannot override clone() in HashMap overridden method does not throw CloneNotSupportedException The fix is to define clone() explicitly. @AutoClone short-circuits its own clone() generation when the user already provides one, so the stub generator emits a stub matching this user-defined no-throws signature. Tested @AutoClone(style = COPY_CONSTRUCTOR) first - same stub still emitted, confirming the regression is in the stub generator and is independent of the @AutoClone style. The body mirrors what @AutoClone(style = CLONE) used to produce - a shallow LinkedHashMap.clone() followed by deep-cloning of the Cloneable typed fields (osiv, cache, flush, additionalProperties) - so multi-tenant settings cloning in HibernateDatastore.createTenantConnectionSource (line 597, getSettings().clone()) keeps the same isolation properties it had on Groovy 5 and earlier Groovy 6 snapshots. Verified locally on Java 21 / Groovy 6.0.0-SNAPSHOT build #546: ./gradlew :grails-data-hibernate5-core:compileGroovy --rerun-tasks -> BUILD SUCCESSFUL ./gradlew :grails-data-hibernate5-core:codeStyle -> BUILD SUCCESSFUL ./gradlew :grails-data-hibernate5-core:test --tests \ 'org.grails.orm.hibernate.connections.HibernateConnectionSourceSettingsSpec' -> 1 tests, 1 successes, 0 failures This is a separate Groovy 6 regression, not caused by the workaround removals in 2a5e983555. Confirmed by stashing those removals and reproducing the same failure on the unmodified merge state. Filing upstream against apache/groovy is the next step; revert this commit once the stub-generator fix lands and a fresh snapshot publishes. --- .../HibernateConnectionSourceSettings.groovy | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/connections/HibernateConnectionSourceSettings.groovy b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/connections/HibernateConnectionSourceSettings.groovy index 0c9aab26a2d..dd232fa49fc 100644 --- a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/connections/HibernateConnectionSourceSettings.groovy +++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/connections/HibernateConnectionSourceSettings.groovy @@ -78,6 +78,33 @@ class HibernateConnectionSourceSettings extends ConnectionSourceSettings { @AutoClone static class HibernateSettings extends LinkedHashMap { + // Groovy 6.0.0-SNAPSHOT (build #546+) stub generator regression: when + // @AutoClone is applied to a class that extends a JDK type whose + // clone() does not declare CloneNotSupportedException (here + // LinkedHashMap.clone()), the Java stub generator still emits the + // override with `throws CloneNotSupportedException`, and javac + // rejects it as not a valid override. Defining clone() explicitly + // suppresses the @AutoClone-generated method (AutoClone skips when + // a user-supplied clone() already exists) and keeps the stub + // signature in lock-step with LinkedHashMap.clone(). The body + // mirrors what @AutoClone(style = CLONE) would produce: a shallow + // copy from LinkedHashMap.clone() followed by deep-cloning of the + // Cloneable typed fields so that tenant-specific + // HibernateConnectionSourceSettings instances (cloned in + // HibernateDatastore.createTenantConnectionSource) do not share + // mutable nested settings. Removable once upstream Groovy fixes + // the stub generator. + @Override + HibernateSettings clone() { + HibernateSettings copy = (HibernateSettings) super.clone() + copy.osiv = osiv != null ? (OsivSettings) osiv.clone() : null + copy.cache = cache != null ? (CacheSettings) cache.clone() : null + copy.flush = flush != null ? (FlushSettings) flush.clone() : null + copy.additionalProperties = additionalProperties != null ? + (Properties) additionalProperties.clone() : null + return copy + } + /** * Whether OpenSessionInView should be read-only */ From bd7a30ae531b1293e0b8497ab2d227c6e7dd71a7 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sun, 3 May 2026 14:41:01 -0400 Subject: [PATCH 26/26] Drop two more Groovy 6 workarounds - upstream fixes merged 2026-05-02 Pulled apache/groovy master to commit 40499016 (HEAD as of 2026-05-03 18:03 UTC) and the 6.0.0-SNAPSHOT publication at build #571 (5.0.6-20260503.181740-571 on the snapshot timeline). Two more workarounds become removable: 1. grails-data-hibernate5/.../HibernateConnectionSourceSettings.groovy The explicit clone() override on the inner @AutoClone HibernateSettings class was the workaround for the Java stub generator regression that emitted 'clone() throws CloneNotSupportedException' on a class extending LinkedHashMap (whose JDK clone() does not declare the exception). Tracked as GROOVY-11980 (https://issues.apache.org/jira/browse/GROOVY-11980), committed to apache/groovy master 2026-05-02 21:29 UTC as ced726ce ('GROOVY-11980: @AutoClone clone() override adds CloneNotSupportedException not declared by superclass'). Build #571 contains the fix. Removed the explicit clone() body and the 16-line workaround comment. @AutoClone now generates the override with the correct (no-throws) signature, javac accepts it as a valid override of LinkedHashMap.clone(), and the deep- clone semantics for tenant connection-source settings are preserved by @AutoClone(style = CLONE) which is the default style. 2. grails-geb/.../testFixtures/grails/plugin/geb/ContainerGebConfiguration.groovy IContainerGebConfiguration converted from trait back to interface with default methods. The interface->trait workaround was for an indy=false IncompatibleClassChangeError ('Method '...\()' must be InterfaceMethodref constant') that fired when downstream classes compiled with -PgrailsIndy=false consumed the interface. Tracked as GROOVY-11982 (https://issues.apache.org/jira/browse/GROOVY-11982), committed to apache/groovy master 2026-05-02 23:16 UTC as 88ca738c ('GROOVY-11982: Default methods in interface throw IncompatibleClassChangeError under indy=false'). Build #571 contains the fix. Standalone reproducer in https://github.com/jamesfredley/groovy5-compiledynamic-trait-bug/blob/main/quick-checks/src/main/groovy/InterfaceDefaultsCheck.groovy was the basis for both the original workaround and this restoration; it now passes against build #571. Compilation re-verified locally on Groovy 6.0.0-SNAPSHOT build #571: ./gradlew :grails-data-hibernate5-core:compileGroovy --refresh-dependencies ./gradlew :grails-geb:compileTestFixturesGroovy --refresh-dependencies Both BUILD SUCCESSFUL. Runtime validation of the indy=false ContainerGebSpec class init path is deferred to the canary CI matrix - the affected specs (InheritedConfigSpec, ChildPreferenceInheritedConfigSpec) extend ContainerGebSpec implements IContainerGebConfiguration and exercise the exact \() InterfaceMethodref dispatch the upstream fix addresses. (Pre-existing :grails-fields:compileGroovy failure on this canary - unrelated to either of these workarounds; reproduces on the unmodified merged tree.) Net effect: two more rows leave the 'Real Groovy 6 regressions, no upstream PR yet' table in the PR description. Combined with the three inherited-from-#15557 workarounds dropped on the parent branch (GROOVY-11983 unlocking PersistentEntityCodec + DefaultHalViewHelper), five workarounds dropped against this round of upstream fixes. Assisted-by: claude-code:claude-opus-4.6 --- .../HibernateConnectionSourceSettings.groovy | 27 ------------------- .../geb/ContainerGebConfiguration.groovy | 17 ++++-------- 2 files changed, 5 insertions(+), 39 deletions(-) diff --git a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/connections/HibernateConnectionSourceSettings.groovy b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/connections/HibernateConnectionSourceSettings.groovy index dd232fa49fc..0c9aab26a2d 100644 --- a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/connections/HibernateConnectionSourceSettings.groovy +++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/connections/HibernateConnectionSourceSettings.groovy @@ -78,33 +78,6 @@ class HibernateConnectionSourceSettings extends ConnectionSourceSettings { @AutoClone static class HibernateSettings extends LinkedHashMap { - // Groovy 6.0.0-SNAPSHOT (build #546+) stub generator regression: when - // @AutoClone is applied to a class that extends a JDK type whose - // clone() does not declare CloneNotSupportedException (here - // LinkedHashMap.clone()), the Java stub generator still emits the - // override with `throws CloneNotSupportedException`, and javac - // rejects it as not a valid override. Defining clone() explicitly - // suppresses the @AutoClone-generated method (AutoClone skips when - // a user-supplied clone() already exists) and keeps the stub - // signature in lock-step with LinkedHashMap.clone(). The body - // mirrors what @AutoClone(style = CLONE) would produce: a shallow - // copy from LinkedHashMap.clone() followed by deep-cloning of the - // Cloneable typed fields so that tenant-specific - // HibernateConnectionSourceSettings instances (cloned in - // HibernateDatastore.createTenantConnectionSource) do not share - // mutable nested settings. Removable once upstream Groovy fixes - // the stub generator. - @Override - HibernateSettings clone() { - HibernateSettings copy = (HibernateSettings) super.clone() - copy.osiv = osiv != null ? (OsivSettings) osiv.clone() : null - copy.cache = cache != null ? (CacheSettings) cache.clone() : null - copy.flush = flush != null ? (FlushSettings) flush.clone() : null - copy.additionalProperties = additionalProperties != null ? - (Properties) additionalProperties.clone() : null - return copy - } - /** * Whether OpenSessionInView should be read-only */ diff --git a/grails-geb/src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy b/grails-geb/src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy index ef647e11766..cd917fb966d 100644 --- a/grails-geb/src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy +++ b/grails-geb/src/testFixtures/groovy/grails/plugin/geb/ContainerGebConfiguration.groovy @@ -72,31 +72,24 @@ import org.testcontainers.containers.GenericContainer /** * Inheritable version of {@link ContainerGebConfiguration}. - * Implemented as a trait instead of an interface with default methods to avoid - * Groovy 5 IncompatibleClassChangeError caused by $getCallSiteArray() being - * generated on the interface and then referenced via a Methodref constant - * (instead of InterfaceMethodref) from downstream classes compiled with - * grailsIndy=false. Verified still failing on Groovy 5.0.6-SNAPSHOT build #22 - * (2026-05-02). Distinct from GROOVY-11968 (resolved in 5.0.6) which fixes - * the trait-static-field VerifyError in @CompileStatic methods. * * @since 4.2 */ -trait IContainerGebConfiguration { +interface IContainerGebConfiguration { - String protocol() { + default String protocol() { ContainerGebConfiguration.DEFAULT_PROTOCOL } - String hostName() { + default String hostName() { ContainerGebConfiguration.DEFAULT_HOSTNAME_FROM_CONTAINER } - boolean reporting() { + default boolean reporting() { false } - Class fileDetector() { + default Class fileDetector() { ContainerGebConfiguration.DEFAULT_FILE_DETECTOR } }