From 013cb13343ded14a367750ded51604e827e91e13 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sun, 5 Apr 2026 15:12:51 -0400 Subject: [PATCH 01/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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 } } From 3cbd88b15a2f19b0e331f6abc57bea80166c5664 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Fri, 8 May 2026 17:43:13 -0400 Subject: [PATCH 27/34] Drop GORM generic-getter workaround - GROOVY-11986 fixed upstream Apache Groovy fixed the genericGetMethod over-permissive registration in: apache/groovy 999f6dcd "GROOVY-11986: genericGetMethod registration too permissive: matches any get(X) where X is a supertype of String" apache/groovy a4caaa4b "GROOVY-11986: ... (test)" Both committed shortly after build #571 (the audit baseline at canary commit a69a157b). Latest 6.0.0-SNAPSHOT publication on Apache snapshots is build #609 (timestamp 20260508.194756) which includes the fix. Removed: - GormEntityTransformation: per-entity AST INSTANCE Object get(String) shim (lines around 295-320). The shim was added to give Groovy 6's instance MOP a more specific candidate than the inherited GormEntity.get(Serializable) so dynamic property reads on @Entity instances would fall through to propertyMissing(String) instead of being hijacked by the generic-getter. With GROOVY-11986 in, the generic-getter is no longer registered for the supertype Serializable signature, so the dispatch routes correctly without the shim. - GormEntity: stale doc comment block on get(Serializable) describing the now-resolved Groovy 6 dispatch hijack and the AST workaround that replaced an earlier trait-static guard. - GormEntityTransformSpec: 'test Groovy 6 generic-getter instance- dispatch guard' regression test. It only verified the AST shim was added (Book.getDeclaredMethod('get', String) != null), so it has no meaning once the shim is gone. The actual dispatch behaviour is exercised by the Hibernate5 / Functional / Mongodb integration suites (DataServiceConnectionRoutingSpec, CrossLayerMultiDataSourceSpec) which originally surfaced the regression and will continue to gate the canary CI matrix. Verified locally on JDK 21 against the latest 6.0.0-SNAPSHOT cached from Apache snapshots (publication 20260508.194756, build #609): ./gradlew :grails-datamapping-core:compileGroovy BUILD SUCCESSFUL ./gradlew :grails-datamapping-core:test BUILD SUCCESSFUL Full integration validation (Hibernate5, Functional, Mongodb under both -PgrailsIndy=false and -PgrailsIndy=true) is deferred to the canary CI matrix on this PR. --- .../gorm/GormEntityTransformation.groovy | 27 ------------------- .../grails/datastore/gorm/GormEntity.groovy | 15 ----------- .../gorm/GormEntityTransformSpec.groovy | 17 ------------ 3 files changed, 59 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 0b75c73214c..a154019d157 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,33 +292,6 @@ 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 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) - 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 4c6e998069c..ca8b8b19d2c 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 @@ -592,21 +592,6 @@ trait GormEntity implements GormValidateable, DirtyCheckable, GormEntityApi Date: Fri, 22 May 2026 19:09:36 -0400 Subject: [PATCH 28/34] fix(ci): resolve 3 of 4 distinct CI failure categories on grails8-groovy6-canary Four mechanical fixes addressing CI failures observed on the merged canary branch. Each is scoped to the smallest change that restores the failing job to green; all were verified locally on Groovy 6.0.0-SNAPSHOT / JDK 21 / Windows before committing. 1. Code Style / Forge Projects: `org.jline:jansi@4.1.0` license `:grails-core:grails-shell-cli:cyclonedxDirectBom` and the same task in `:grails-console` and `:grails-dependencies-starter-web` failed with: Unpermitted License found for bom dependency: pkg:maven/org.jline/jansi@4.1.0?type=jar : BSD-4-Clause jline 4.1.0 LICENSE.txt (https://github.com/jline/jline3/blob/jline-parent-4.1.0/LICENSE.txt) confirms BSD-3-Clause. CycloneDX misreports as BSD-4-Clause per cyclonedx-core-java#205, identical to the existing 4.0.12 entry already in `SbomPlugin.LICENSE_MAPPING`. Added the 4.1.0 entry with the same justification. Verified .\gradlew :grails-shell-cli:cyclonedxDirectBom # BUILD SUCCESSFUL 2. Validate Dependency Versions: asm 9.10 vs 9.9.1 in 4 micronaut test-examples `:grails-test-examples-micronaut:validateDependencyVersions` and the same task in `-micronaut-groovy-only`, `-issue-11767`, and `-plugins-micronaut-singleton` failed with: org.ow2.asm:asm - resolved 9.10, expected 9.9.1 org.ow2.asm:asm-util - resolved 9.10, expected 9.9.1 Groovy 6.0.0-SNAPSHOT requires asm 9.10 (for JDK 27 bytecode support); Spring Boot 4 / Micronaut platform's BOM pin is still 9.9.1. The divergence is intentional on this canary. Added `ext.allowedBomOverrides = ['org.ow2.asm:asm', 'org.ow2.asm:asm-util']` to each of the 4 affected projects' `build.gradle`. This uses the existing contract documented on `GrailsDependencyValidatorPlugin.ALLOWED_OVERRIDES_EXT`. Verified .\gradlew :grails-test-examples-micronaut:validateDependencyVersions \ :grails-test-examples-micronaut-groovy-only:validateDependencyVersions \ :grails-test-examples-issue-11767:validateDependencyVersions \ :grails-test-examples-plugins-micronaut-singleton:validateDependencyVersions # BUILD SUCCESSFUL 3. Code Style / Core Projects: `TemplateRenderer.groovy` 5 abstract render() methods `:grails-views-gson:compileGroovy` failed at `TemplateRenderer.groovy:33` with 5 errors of the form: Can't have an abstract method in a non-abstract class. The class 'grails.plugin.json.view.api.internal.TemplateRenderer' must be declared abstract or the method 'grails.plugin.json.builder.JsonOutput$JsonWritable render(java.util.Map)' must be implemented. (+ 4 more `render(...)` overloads, all returning the inner abstract class `JsonOutput.JsonWritable`.) Under Groovy 6.0.0-SNAPSHOT + `@CompileStatic`, the `@Delegate` AST transform on `GrailsJsonViewHelper jsonViewHelper` does not satisfy the abstract-method-implementation check for interface methods whose return type is an inner abstract class. The 5 `inline(...)` overloads return void and are unaffected, so `@Delegate` still handles them. Added explicit forwarders for the 5 `GrailsJsonViewHelper#render(...)` overloads, each one a single-line delegate to `jsonViewHelper`. Behaviour is identical to what `@Delegate` generates on Groovy 5. This fix surfaces the next compile error in `grails-views-gson` at `DefaultGrailsJsonViewHelper.groovy:67`, which is the same class of Groovy 6 STC bug applied to a class that inherits from `DefaultJsonViewHelper` and implements `GrailsJsonViewHelper`. That one does not yield to the same fix (explicit overloads, fully qualified return types, removing @CompileStatic from the interface were all attempted and rejected); it is deferred as a follow-up workaround item on this PR. Verified .\gradlew :grails-views-gson:compileGroovy # progresses past TemplateRenderer; now fails at DefaultGrailsJsonViewHelper 4. Build Grails-Core: `BeanPropertyAccessorImpl` Map constructor `:grails-fields:compileGroovy` failed at `BeanPropertyAccessorFactory.groovy:83` with: Target constructor for constructor call expression hasn't been set The call site is `new BeanPropertyAccessorImpl(params)` where `params` is a `Map`. The target class is annotated `@Canonical @TupleConstructor(includes = [...])`. Under Groovy 6 `@Canonical` no longer implicitly includes `@MapConstructor` under `@CompileStatic`, so the named-arg call site can't bind to a constructor. Declared `@MapConstructor` explicitly. Restores the Groovy 4 / 5 behaviour without changing the positional `@TupleConstructor` or the `@Canonical`-generated toString / equals / hashCode contract. Verified .\gradlew :grails-fields:compileGroovy # BUILD SUCCESSFUL Remaining CI failures after this commit The merge of grails8-groovy5-sb4 + these 4 fixes also fixes the graphql-java 24.3 vs 25.0 BOM mismatch (2 docs projects) and the 3 `cyclonedxDirectBom` license failures (all sites resolve via the single `LICENSE_MAPPING` entry). The remaining red CI category is the `DefaultGrailsJsonViewHelper`-flavoured Groovy 6 STC bug on `grails-views-gson`, which will cascade into the Build Grails-Core / Functional Tests / Mongodb / Hibernate5 matrix until it is resolved. The test failures (`:grails-core:test`, `:grails-testing-support-http-client:test`) are post-compile and expected to clear once the views-gson compile is restored. Assisted-by: claude-code:claude-opus-4-7 --- .../apache/grails/buildsrc/SbomPlugin.groovy | 1 + .../BeanPropertyAccessorImpl.groovy | 10 ++++++ grails-test-examples/issue-11767/build.gradle | 7 ++++ .../micronaut-groovy-only/build.gradle | 7 ++++ grails-test-examples/micronaut/build.gradle | 7 ++++ .../plugins/micronaut-singleton/build.gradle | 7 ++++ .../view/api/internal/TemplateRenderer.groovy | 32 +++++++++++++++++++ 7 files changed, 71 insertions(+) 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 23cad34dae5..f9462f6bb4e 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 @@ -95,6 +95,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@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/jansi@4.1.0?type=jar' : 'BSD-3-Clause', // jline 4.1.0 LICENSE.txt at https://github.com/jline/jline3/blob/jline-parent-4.1.0/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 diff --git a/grails-fields/src/main/groovy/grails/plugin/formfields/BeanPropertyAccessorImpl.groovy b/grails-fields/src/main/groovy/grails/plugin/formfields/BeanPropertyAccessorImpl.groovy index 6e344c0033c..b33f9aceda3 100644 --- a/grails-fields/src/main/groovy/grails/plugin/formfields/BeanPropertyAccessorImpl.groovy +++ b/grails-fields/src/main/groovy/grails/plugin/formfields/BeanPropertyAccessorImpl.groovy @@ -20,6 +20,7 @@ package grails.plugin.formfields import groovy.transform.Canonical import groovy.transform.CompileStatic +import groovy.transform.MapConstructor import groovy.transform.Memoized import groovy.transform.TupleConstructor @@ -39,8 +40,17 @@ import org.grails.datastore.mapping.model.PersistentEntity import org.grails.datastore.mapping.model.PersistentProperty import org.grails.scaffolding.model.property.Constrained +// Groovy 6.0.0-SNAPSHOT: @Canonical no longer auto-generates @MapConstructor +// under @CompileStatic, so the named-argument call site in +// `BeanPropertyAccessorFactory.resolvePropertyFromPath` (`new BeanPropertyAccessorImpl(params)`) +// can't bind to a constructor and the compiler reports +// "Target constructor for constructor call expression hasn't been set". +// Declaring @MapConstructor explicitly restores the Groovy 4 / 5 behaviour +// without changing the positional @TupleConstructor or @Canonical-generated +// toString / equals / hashCode contract. @CompileStatic @Canonical +@MapConstructor @TupleConstructor(includes = ['beanType', 'propertyName', 'propertyType']) class BeanPropertyAccessorImpl implements BeanPropertyAccessor { diff --git a/grails-test-examples/issue-11767/build.gradle b/grails-test-examples/issue-11767/build.gradle index bfc6a13d8bb..b3337c23047 100644 --- a/grails-test-examples/issue-11767/build.gradle +++ b/grails-test-examples/issue-11767/build.gradle @@ -25,6 +25,13 @@ plugins { version = '0.1' group = 'issue11767.app' +// Groovy 6.0.0-SNAPSHOT requires asm 9.10 (for JDK 27 bytecode support), while +// the grails-micronaut-bom inherits asm 9.9.1 from Spring Boot 4 / Micronaut +// platform. The divergence is intentional on this canary; un-pin via the +// validator plugin's allowedBomOverrides mechanism (see GrailsDependencyValidatorPlugin +// `ALLOWED_OVERRIDES_EXT` for the contract). +ext.allowedBomOverrides = ['org.ow2.asm:asm', 'org.ow2.asm:asm-util'] as Set + apply plugin: 'org.apache.grails.gradle.grails-web' dependencies { diff --git a/grails-test-examples/micronaut-groovy-only/build.gradle b/grails-test-examples/micronaut-groovy-only/build.gradle index 62abb364315..32b731c1a40 100644 --- a/grails-test-examples/micronaut-groovy-only/build.gradle +++ b/grails-test-examples/micronaut-groovy-only/build.gradle @@ -25,6 +25,13 @@ plugins { version = '0.1' group = 'micronautgroovyonly' +// Groovy 6.0.0-SNAPSHOT requires asm 9.10 (for JDK 27 bytecode support), while +// the grails-micronaut-bom inherits asm 9.9.1 from Spring Boot 4 / Micronaut +// platform. The divergence is intentional on this canary; un-pin via the +// validator plugin's allowedBomOverrides mechanism (see GrailsDependencyValidatorPlugin +// `ALLOWED_OVERRIDES_EXT` for the contract). +ext.allowedBomOverrides = ['org.ow2.asm:asm', 'org.ow2.asm:asm-util'] as Set + apply plugin: 'org.apache.grails.gradle.grails-web' // This module intentionally has NO annotationProcessor dependencies. diff --git a/grails-test-examples/micronaut/build.gradle b/grails-test-examples/micronaut/build.gradle index 3a3c4e527b7..906e84f6d3e 100644 --- a/grails-test-examples/micronaut/build.gradle +++ b/grails-test-examples/micronaut/build.gradle @@ -27,6 +27,13 @@ plugins { version = '0.1' group = 'micronaut' +// Groovy 6.0.0-SNAPSHOT requires asm 9.10 (for JDK 27 bytecode support), while +// the grails-micronaut-bom inherits asm 9.9.1 from Spring Boot 4 / Micronaut +// platform. The divergence is intentional on this canary; un-pin via the +// validator plugin's allowedBomOverrides mechanism (see GrailsDependencyValidatorPlugin +// `ALLOWED_OVERRIDES_EXT` for the contract). +ext.allowedBomOverrides = ['org.ow2.asm:asm', 'org.ow2.asm:asm-util'] as Set + apply plugin: 'org.apache.grails.gradle.grails-web' apply plugin: 'cloud.wondrify.asset-pipeline' diff --git a/grails-test-examples/plugins/micronaut-singleton/build.gradle b/grails-test-examples/plugins/micronaut-singleton/build.gradle index 96d97930f73..d0154439656 100644 --- a/grails-test-examples/plugins/micronaut-singleton/build.gradle +++ b/grails-test-examples/plugins/micronaut-singleton/build.gradle @@ -25,6 +25,13 @@ plugins { version = '0.1-SNAPSHOT' group = 'com.example.grails.plugins' +// Groovy 6.0.0-SNAPSHOT requires asm 9.10 (for JDK 27 bytecode support), while +// the grails-micronaut-bom inherits asm 9.9.1 from Spring Boot 4 / Micronaut +// platform. The divergence is intentional on this canary; un-pin via the +// validator plugin's allowedBomOverrides mechanism (see GrailsDependencyValidatorPlugin +// `ALLOWED_OVERRIDES_EXT` for the contract). +ext.allowedBomOverrides = ['org.ow2.asm:asm', 'org.ow2.asm:asm-util'] as Set + apply plugin: 'org.apache.grails.gradle.grails-plugin' dependencies { diff --git a/grails-views-gson/src/main/groovy/grails/plugin/json/view/api/internal/TemplateRenderer.groovy b/grails-views-gson/src/main/groovy/grails/plugin/json/view/api/internal/TemplateRenderer.groovy index 905a7ad385f..d079a46a8e4 100644 --- a/grails-views-gson/src/main/groovy/grails/plugin/json/view/api/internal/TemplateRenderer.groovy +++ b/grails-views-gson/src/main/groovy/grails/plugin/json/view/api/internal/TemplateRenderer.groovy @@ -19,8 +19,10 @@ package grails.plugin.json.view.api.internal +import groovy.json.StreamingJsonBuilder import groovy.transform.CompileStatic +import grails.plugin.json.builder.JsonOutput import grails.plugin.json.view.api.GrailsJsonViewHelper import grails.util.GrailsNameUtils @@ -39,6 +41,36 @@ class TemplateRenderer { this.jsonViewHelper = jsonViewHelper } + // Explicit forwarders for the 5 GrailsJsonViewHelper#render(...) overloads. + // Under Groovy 6.0.0-SNAPSHOT the @Delegate AST transform no longer satisfies + // the abstract-method-implementation check for interface methods whose return + // type is an inner class (here JsonOutput.JsonWritable): the @CompileStatic + // verifier runs before @Delegate generates the forwarders, so the compiler + // reports "Can't have an abstract method in a non-abstract class". + // The 5 inline(...) overloads return void and are unaffected, so @Delegate + // still handles them. Behaviour is identical to what @Delegate generates on + // Groovy 5. + + JsonOutput.JsonWritable render(Map arguments) { + jsonViewHelper.render(arguments) + } + + JsonOutput.JsonWritable render(Object object, Map arguments, @DelegatesTo(StreamingJsonBuilder.StreamingJsonDelegate) Closure customizer) { + jsonViewHelper.render(object, arguments, customizer) + } + + JsonOutput.JsonWritable render(Object object, Map arguments) { + jsonViewHelper.render(object, arguments) + } + + JsonOutput.JsonWritable render(Object object) { + jsonViewHelper.render(object) + } + + JsonOutput.JsonWritable render(Object object, @DelegatesTo(StreamingJsonBuilder.StreamingJsonDelegate) Closure customizer) { + jsonViewHelper.render(object, customizer) + } + @Override Object invokeMethod(String name, Object args) { Object[] argArray = (Object[]) args From 5d3896d0f296e45e257c7468a8718c0eecd5f238 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Fri, 29 May 2026 00:50:00 -0400 Subject: [PATCH 29/34] fix: drop ConfigurationBuilder @Builder-detection workaround (GROOVY-12040 fixed in Groovy 6) GROOVY-12040 (apache/groovy#2565, merged to master 2026-05-27, present in 6.0.0-SNAPSHOT build #716) restores @Builder to @Retention(RUNTIME). The isLikelyBuilderType() heuristic was introduced on the Groovy 5 line because Class.getAnnotation(Builder) returned null under the SOURCE-retention regression. With the upstream fix, runtime annotation detection works again, so the heuristic and its three call-site disjuncts are removed and builder detection reverts to the pre-Groovy-5 getAnnotation(Builder) form. The Spring 7 Map-to-typed-config conversion fallbacks (handleConverterNotFoundException, handleConversionException) are retained - they are independent of the Groovy version and required regardless of @Builder annotation retention. GROOVY-12040 is not yet backported to GROOVY_5_0_X, so this workaround remains required on the grails8-groovy5-sb4 base branch (5.0.7-SNAPSHOT); it is removed here only on the Groovy 6 canary. Verified on Groovy 6.0.0-SNAPSHOT build #716 / Gradle 9.5.1 / Spring Boot 4.0.6: :grails-datastore-core:test --tests ConfigurationBuilderSpec (4/4 passed) and :grails-datastore-core:codeStyle green. Assisted-by: claude-code:claude-4.8-opus --- .../config/ConfigurationBuilder.groovy | 65 ++----------------- 1 file changed, 6 insertions(+), 59 deletions(-) diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/ConfigurationBuilder.groovy b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/ConfigurationBuilder.groovy index 51b7ede96ce..d0d2ed18c11 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/ConfigurationBuilder.groovy +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/ConfigurationBuilder.groovy @@ -168,11 +168,9 @@ abstract class ConfigurationBuilder { continue } else if (!hasBuilderPrefix && - ((org.grails.datastore.mapping.reflect.ReflectionUtils.isGetter(methodName, parameterTypes) && - method.returnType.getAnnotation(Builder) == null && !isLikelyBuilderType(method.returnType)) || + ((org.grails.datastore.mapping.reflect.ReflectionUtils.isGetter(methodName, parameterTypes) && method.returnType.getAnnotation(Builder) == null) || org.grails.datastore.mapping.reflect.ReflectionUtils.isSetter(methodName, parameterTypes))) { // don't process getters or setters, unless the getter returns a builder - // Note: @Builder annotation has SOURCE retention so we also check isLikelyBuilderType continue } else { @@ -244,13 +242,8 @@ abstract class ConfigurationBuilder { } } - // Check if this type should be treated as a builder type - // Note: @Builder annotation has SOURCE retention so we can't detect it at runtime - // Instead we check if the type is a likely configuration object (has no-arg constructor, - // isn't a primitive/wrapper/collection/etc.) Builder builderAnnotation = argType.getAnnotation(Builder) - if (builderAnnotation != null && builderAnnotation.builderStrategy() == SimpleStrategy || - isLikelyBuilderType(argType)) { + if (builderAnnotation != null && builderAnnotation.builderStrategy() == SimpleStrategy) { Method existingGetter = ReflectionUtils.findMethod(builderClass, NameUtils.getGetterName(methodName)) def newBuilder if (existingGetter != null) { @@ -309,8 +302,7 @@ abstract class ConfigurationBuilder { continue } } else if (methodName.startsWith('get') && parameterTypes.length == 0) { - // Note: @Builder annotation has SOURCE retention so we can't detect it at runtime - if (method.returnType.getAnnotation(Builder) || isLikelyBuilderType(method.returnType)) { + if (method.returnType.getAnnotation(Builder)) { def childBuilder = method.invoke(builder) if (childBuilder != null) { Object fallBackChildConfig = null @@ -466,11 +458,9 @@ abstract class ConfigurationBuilder { /** * Handle ConverterNotFoundException - for nested configuration types, - * try to instantiate and populate from Map. This handles Groovy 5 / Spring 6 compatibility where - * Spring can't auto-convert from LinkedHashMap to these types. - * - * Note: @Builder annotation has SOURCE retention, so we can't check for it at runtime. - * Instead we try instantiation for any type that has a no-arg constructor. + * try to instantiate and populate from Map. This handles Spring 7 compatibility where + * Spring can't auto-convert from LinkedHashMap to these types. This is independent of the + * Groovy version and is required regardless of @Builder annotation retention. */ @CompileDynamic private Object handleConverterNotFoundException(ConverterNotFoundException e, Class argType, String propertyPathForArg, Object fallBackValue) { @@ -516,47 +506,4 @@ abstract class ConfigurationBuilder { } return null } - - /** - * Check if a type is likely a builder/configuration type that should be recursively processed. - * This is needed because @Builder annotation has SOURCE retention and can't be detected at runtime. - * - * A type is considered a likely builder type if: - * - It has a public no-arg constructor - * - It's not a primitive, wrapper, String, enum, collection, map, or closure - * - It's in an org.grails package (to avoid false positives with third-party types) - */ - private static boolean isLikelyBuilderType(Class type) { - if (type == null) return false - - // Skip primitives, wrappers, common types - if (type.isPrimitive()) return false - if (type == String || type == CharSequence) return false - if (Number.isAssignableFrom(type)) return false - if (type == Boolean || type == Character) return false - if (type.isEnum()) return false - if (type.isInterface()) return false - if (java.lang.reflect.Modifier.isAbstract(type.getModifiers())) return false - if (Collection.isAssignableFrom(type)) return false - if (Closure.isAssignableFrom(type)) return false - if (type.isArray()) return false - if (Class.isAssignableFrom(type)) return false - - // Check if it's in a Grails package (to avoid false positives) - String packageName = type.getPackage()?.getName() - if (packageName == null) return false - if (!packageName.startsWith('org.grails') && !packageName.startsWith('grails.')) return false - - // Note: Map subtypes in Grails packages (e.g., HibernateSettings extends LinkedHashMap) - // are intentionally NOT excluded here. They serve as configuration objects with typed - // properties (cache, flush, etc.) and need recursive builder processing. - - // Check if it has a public no-arg constructor - try { - type.getDeclaredConstructor() - return true - } catch (NoSuchMethodException e) { - return false - } - } } From 8b349bcdb87c19b648a794107ce2fbe6c304f274 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Fri, 29 May 2026 13:25:35 -0400 Subject: [PATCH 30/34] fix(views-gson): work around Groovy 6 Verifier abstract-method regression (blocker #6) Under Groovy 6.0.0-SNAPSHOT, ClassCompletionVerifier.checkNoAbstractMethodsNonAbstractClass spuriously reports all 5 GrailsJsonViewHelper#render(...) overloads as unimplemented on DefaultGrailsJsonViewHelper, even though they are declared/overridden on the class. The check iterates ClassNode.getDeclaredMethodsMap() keyed by MethodNode.getTypeDescriptor() (which includes the return type); the concrete leaf render(...) overrides resolve a different return-type descriptor than the interface's abstract render(...) entries for the inner-class return type grails.plugin.json.builder.JsonOutput.JsonWritable, so they do not displace the abstract entries and survive as "unimplemented". (groovy.json.JsonOutput.JsonWritable, which the Grails inner class shadowed on Groovy 5, was removed on Groovy 6 - JsonOutput now only declares JsonUnescaped - which changes inner-class resolution.) It is a Verifier-layer defect, not the static type checker: it reproduces with @CompileStatic removed. Eleven earlier source-level workarounds were rejected (explicit forwarders, fully-qualified return types, inner-class rename, removing @CompileStatic from class and interface, explicit constructor, abstract-parent + concrete-subclass, @CompileDynamic, diamond removal, diamond + covariant-getG removal, and concrete render stubs on the intermediate superclass DefaultJsonViewHelper). The fix here targets the actual defect: the bug lives in the *abstract*-method check, so the 5 render(...) methods on GrailsJsonViewHelper are declared as `default` (concrete). They are then absent from getAbstractMethods(), the verifier has nothing to flag, and DefaultGrailsJsonViewHelper - the sole implementor - overrides all 5, so the throwing default bodies are never reached. Verified on Groovy 6.0.0-SNAPSHOT build #716 / Gradle 9.5.1 / Spring Boot 4.0.6: :grails-views-gson:compileGroovy -> green :grails-views-gson:test -> all pass (render / HAL / JSON-API / template-inheritance), 1 pre-existing @IgnoreIf skip :grails-views-gson:codeStyle -> green Remove once the upstream Groovy 6 Verifier regression is fixed. In-tree reproducer: grails-views-gson itself; a dependency-free standalone reproduction is still being isolated. Assisted-by: claude-code:claude-4.8-opus --- .../json/view/api/GrailsJsonViewHelper.groovy | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/grails-views-gson/src/main/groovy/grails/plugin/json/view/api/GrailsJsonViewHelper.groovy b/grails-views-gson/src/main/groovy/grails/plugin/json/view/api/GrailsJsonViewHelper.groovy index 114317700ea..6639e97071b 100644 --- a/grails-views-gson/src/main/groovy/grails/plugin/json/view/api/GrailsJsonViewHelper.groovy +++ b/grails-views-gson/src/main/groovy/grails/plugin/json/view/api/GrailsJsonViewHelper.groovy @@ -45,7 +45,18 @@ interface GrailsJsonViewHelper extends GrailsViewHelper { * @param arguments The named arguments: 'template', 'collection', 'model', 'var' and 'bean' * @return The unescaped JSON */ - JsonOutput.JsonWritable render(Map arguments) + // Groovy 6 Verifier workaround (blocker #6): declared as `default` (concrete) rather than + // abstract. Under Groovy 6.0.0-SNAPSHOT the concrete render(...) overrides in + // DefaultGrailsJsonViewHelper get a different return-type descriptor than these interface + // methods (inner-class return type JsonOutput.JsonWritable; groovy.json.JsonOutput.JsonWritable + // was removed in Groovy 6), so the abstract-method check in + // ClassCompletionVerifier.checkNoAbstractMethodsNonAbstractClass spuriously reports them + // unimplemented. Making them default removes them from getAbstractMethods() so the check has + // nothing to flag; every real implementor overrides them. Remove once the upstream regression + // is fixed. + default JsonOutput.JsonWritable render(Map arguments) { + throw new UnsupportedOperationException() + } /** * Renders the given object to JSON, typically a domain class, ignoring lazy and internal properties @@ -55,7 +66,9 @@ interface GrailsJsonViewHelper extends GrailsViewHelper { * @param customizer Used to customize the contents * @return The unescaped JSON */ - JsonOutput.JsonWritable render(Object object, Map arguments, @DelegatesTo(StreamingJsonBuilder.StreamingJsonDelegate) Closure customizer) + default JsonOutput.JsonWritable render(Object object, Map arguments, @DelegatesTo(StreamingJsonBuilder.StreamingJsonDelegate) Closure customizer) { + throw new UnsupportedOperationException() + } /** * Renders the given object to JSON, typically a domain class, ignoring lazy and internal properties @@ -64,7 +77,9 @@ interface GrailsJsonViewHelper extends GrailsViewHelper { * @param arguments The supported named arguments: 'includes' or 'excludes' list * @return The unescaped JSON */ - JsonOutput.JsonWritable render(Object object, Map arguments) + default JsonOutput.JsonWritable render(Object object, Map arguments) { + throw new UnsupportedOperationException() + } /** * Renders the given object to JSON, typically a domain class, ignoring lazy and internal properties @@ -72,7 +87,9 @@ interface GrailsJsonViewHelper extends GrailsViewHelper { * @param object The object to render * @return The unescaped JSON */ - JsonOutput.JsonWritable render(Object object) + default JsonOutput.JsonWritable render(Object object) { + throw new UnsupportedOperationException() + } /** * Renders the given object to JSON, typically a domain class, ignoring lazy and internal properties @@ -81,7 +98,9 @@ interface GrailsJsonViewHelper extends GrailsViewHelper { * @param customizer the customizer * @return The unescaped JSON */ - JsonOutput.JsonWritable render(Object object, @DelegatesTo(StreamingJsonBuilder.StreamingJsonDelegate) Closure customizer) + default JsonOutput.JsonWritable render(Object object, @DelegatesTo(StreamingJsonBuilder.StreamingJsonDelegate) Closure customizer) { + throw new UnsupportedOperationException() + } /** * Renders the given object inline within the current JSON object instead of creating a new JSON object From 04b50fbf2f50a93efd5402cae3500a8f97a2e63b Mon Sep 17 00:00:00 2001 From: James Fredley Date: Fri, 29 May 2026 14:02:42 -0400 Subject: [PATCH 31/34] fix(build): map JLine 4.1.0 transitive modules in SBOM license allowlist The Groovy 6 snapshot's groovy-groovysh now pulls the JLine 4.1.0 family transitively (grails-shell-cli, grails-console), but SbomPlugin.LICENSE_MAPPING only mapped the 4.0.12 family plus jansi@4.1.0. cyclonedx-core-java#205 misreports JLine's BSD-3-Clause as BSD-4-Clause, so :grails-shell-cli:cyclonedxDirectBom failed with "Unpermitted License found for bom dependency: ... jline-builtins@4.1.0 : BSD-4-Clause". Because `build` depends on cyclonedxDirectBom, this broke every CI job that runs build (Core Projects, Forge Projects, Functional, Hibernate5, Mongodb). It surfaced only after the views-gson Groovy 6 compile blocker was fixed and CI could finally reach the SBOM stage. Add the remaining 9 JLine 4.1.0 coordinates (builtins, console, console-ui, native, reader, shell, style, terminal, terminal-jni) -> BSD-3-Clause, mirroring the existing 4.0.12 entries. Each module LICENSE.txt at https://github.com/jline/jline3/blob/jline-parent-4.1.0/LICENSE.txt confirms BSD-3-Clause. Verified on Groovy 6.0.0-SNAPSHOT: :grails-shell-cli:cyclonedxDirectBom, :grails-console:cyclonedxDirectBom and :grails-test-core:cyclonedxDirectBom all green. Assisted-by: claude-code:claude-4.8-opus --- .../groovy/org/apache/grails/buildsrc/SbomPlugin.groovy | 9 +++++++++ 1 file changed, 9 insertions(+) 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 f9462f6bb4e..0a98b129ae0 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 @@ -106,6 +106,15 @@ class SbomPlugin implements Plugin { '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.jline/jline-builtins@4.1.0?type=jar' : 'BSD-3-Clause', // jline 4.1.0 LICENSE.txt at https://github.com/jline/jline3/blob/jline-parent-4.1.0/LICENSE.txt confirms BSD-3-Clause; resolved transitively via groovy-groovysh on Groovy 6 + 'pkg:maven/org.jline/jline-console@4.1.0?type=jar' : 'BSD-3-Clause', // jline 4.1.0 LICENSE.txt at https://github.com/jline/jline3/blob/jline-parent-4.1.0/LICENSE.txt confirms BSD-3-Clause; resolved transitively via groovy-groovysh on Groovy 6 + 'pkg:maven/org.jline/jline-console-ui@4.1.0?type=jar' : 'BSD-3-Clause', // jline 4.1.0 LICENSE.txt at https://github.com/jline/jline3/blob/jline-parent-4.1.0/LICENSE.txt confirms BSD-3-Clause; resolved transitively via groovy-groovysh on Groovy 6 + 'pkg:maven/org.jline/jline-native@4.1.0?type=jar' : 'BSD-3-Clause', // jline 4.1.0 LICENSE.txt at https://github.com/jline/jline3/blob/jline-parent-4.1.0/LICENSE.txt confirms BSD-3-Clause; resolved transitively via groovy-groovysh on Groovy 6 + 'pkg:maven/org.jline/jline-reader@4.1.0?type=jar' : 'BSD-3-Clause', // jline 4.1.0 LICENSE.txt at https://github.com/jline/jline3/blob/jline-parent-4.1.0/LICENSE.txt confirms BSD-3-Clause; resolved transitively via groovy-groovysh on Groovy 6 + 'pkg:maven/org.jline/jline-shell@4.1.0?type=jar' : 'BSD-3-Clause', // jline 4.1.0 LICENSE.txt at https://github.com/jline/jline3/blob/jline-parent-4.1.0/LICENSE.txt confirms BSD-3-Clause; resolved transitively via groovy-groovysh on Groovy 6 + 'pkg:maven/org.jline/jline-style@4.1.0?type=jar' : 'BSD-3-Clause', // jline 4.1.0 LICENSE.txt at https://github.com/jline/jline3/blob/jline-parent-4.1.0/LICENSE.txt confirms BSD-3-Clause; resolved transitively via groovy-groovysh on Groovy 6 + 'pkg:maven/org.jline/jline-terminal@4.1.0?type=jar' : 'BSD-3-Clause', // jline 4.1.0 LICENSE.txt at https://github.com/jline/jline3/blob/jline-parent-4.1.0/LICENSE.txt confirms BSD-3-Clause; resolved transitively via groovy-groovysh on Groovy 6 + 'pkg:maven/org.jline/jline-terminal-jni@4.1.0?type=jar' : 'BSD-3-Clause', // jline 4.1.0 LICENSE.txt at https://github.com/jline/jline3/blob/jline-parent-4.1.0/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 ] From 7daec90baa6d37a0d2329c463662df9045fa235d Mon Sep 17 00:00:00 2001 From: James Fredley Date: Fri, 29 May 2026 15:11:07 -0400 Subject: [PATCH 32/34] fix(build): propagate Spock version-check opt-out to the forked view compiler (Groovy 6 canary) compileGsonViews runs JsonViewCompiler in a forked JVM (AbstractGroovyTemplateCompileTask). The view-template classpath carries Spock's global AST transform (spock-core), which under Groovy 6 aborts compilation: "Could not instantiate global transform class SpockTransform ... IncompatibleGroovyVersionException: Spock 2.4.0-groovy-5.0 is not compatible with Groovy 6.0.0-SNAPSHOT". Every other compile/test fork already sets -Dspock.iKnowWhatImDoing.disableGroovyVersionCheck=true, but this fork did not, so :grails-test-examples-*:compileGsonViews failed - which broke the Build Grails-Core, Functional, Hibernate5 and Mongodb jobs that build the test-example apps' gson views. It only surfaced now that the views-gson compile blocker and the SBOM license gate were cleared and CI could reach it. AbstractGroovyTemplateCompileTask now propagates the build JVM's spock.iKnowWhatImDoing.disableGroovyVersionCheck system property into the fork (a no-op when the property is unset, so it is safe for released builds), and the canary build JVM carries the flag via org.gradle.jvmargs so it is available to propagate. Verified on Groovy 6.0.0-SNAPSHOT: :grails-test-examples-graphql-grails-multi-datastore-app:compileGsonViews now succeeds. Assisted-by: claude-code:claude-4.8-opus --- gradle.properties | 5 ++++- .../views/AbstractGroovyTemplateCompileTask.groovy | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 1c26a51a967..82560c3d4e3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -85,4 +85,7 @@ org.gradle.daemon=true #org.gradle.configureondemand=true # Note: groovydoc requires almost a doubling of this memory; if it could run in a process isolation, we could reduce this # This is a future TODO see groovydoc-tool-rewrite branch for experiementations with this -org.gradle.jvmargs=-Dfile.encoding=UTF-8 -Xmx5G +# grails8-groovy6-canary: carry Spock's compile-time Groovy version-check opt-out on the build JVM so +# the forked gson/gsp view compiler (AbstractGroovyTemplateCompileTask) can propagate it; Spock's global +# AST transform otherwise aborts view compilation under Groovy 6. Remove once Spock ships a groovy-6.0 build. +org.gradle.jvmargs=-Dfile.encoding=UTF-8 -Xmx5G -Dspock.iKnowWhatImDoing.disableGroovyVersionCheck=true diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/AbstractGroovyTemplateCompileTask.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/AbstractGroovyTemplateCompileTask.groovy index ff69e8a401e..aecc74ddc65 100644 --- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/AbstractGroovyTemplateCompileTask.groovy +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/AbstractGroovyTemplateCompileTask.groovy @@ -134,6 +134,15 @@ abstract class AbstractGroovyTemplateCompileTask extends AbstractCompile { if (jvmArgs) { javaExecSpec.jvmArgs(jvmArgs) } + + // The view template classpath can carry Spock's global AST transform, which + // aborts compilation when the building Groovy is newer than the Spock artifact's + // groovy variant. Propagate the build JVM's opt-out flag (if set) into this fork so + // the forked compiler matches the build's compile/test tasks. No-op when unset. + String spockVersionCheckOptOut = System.getProperty('spock.iKnowWhatImDoing.disableGroovyVersionCheck') + if (spockVersionCheckOptOut != null) { + javaExecSpec.systemProperty('spock.iKnowWhatImDoing.disableGroovyVersionCheck', spockVersionCheckOptOut) + } javaExecSpec.maxHeapSize = compileOptions.forkOptions.memoryMaximumSize javaExecSpec.minHeapSize = compileOptions.forkOptions.memoryInitialSize From a092b13f2f53e03db32a5bba8b48b337ae742699 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Fri, 29 May 2026 15:27:44 -0400 Subject: [PATCH 33/34] fix(testing-http-client): correct XmlUtils secure-slurper feature URIs and block external entities loudly XmlUtils declared the SAX/Xerces feature identifiers with an https scheme (https://apache.org/xml/features/..., https://xml.org/sax/features/...). The parser matches these by exact string, so setFeature threw SAXNotRecognizedException for each and the catch block swallowed it, leaving the parser at JDK defaults. On JDK 21/25 the FEATURE_SECURE_PROCESSING default disallows DOCTYPE entirely, so XmlUtilsSpec / TestHttpResponseSpec failed: an inline DOCTYPE with internal entities was rejected ("DOCTYPE is disallowed ..."). - Correct the identifiers to the http scheme so they are actually applied; disallow-doctype-decl is now explicitly false, so inline DOCTYPE with internal entities parses. - Leave external general entities enabled and instead block them via the JAXP accessExternalDTD / accessExternalSchema properties (set to ""), so a SYSTEM reference is attempted and then blocked with a thrown SAXParseException ("External Entity: ... access is not allowed") instead of being silently dropped (external-general-entities=false skips without throwing). Net external access is still fully blocked - it now fails loud, matching the specs. Verified on Groovy 6.0.0-SNAPSHOT: :grails-testing-support-http-client:test (103 tests) and :grails-testing-support-http-client:codeStyle are green. Assisted-by: claude-code:claude-4.8-opus --- .../testing/http/client/utils/XmlUtils.groovy | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/grails-testing-support-http-client/src/main/groovy/org/apache/grails/testing/http/client/utils/XmlUtils.groovy b/grails-testing-support-http-client/src/main/groovy/org/apache/grails/testing/http/client/utils/XmlUtils.groovy index 8051154047a..6d350a77162 100644 --- a/grails-testing-support-http-client/src/main/groovy/org/apache/grails/testing/http/client/utils/XmlUtils.groovy +++ b/grails-testing-support-http-client/src/main/groovy/org/apache/grails/testing/http/client/utils/XmlUtils.groovy @@ -45,12 +45,14 @@ import org.xml.sax.SAXException @CompileStatic class XmlUtils { - private static final String DISALLOW_DOCTYPE_DECL = 'https://apache.org/xml/features/disallow-doctype-decl' - private static final String EXTERNAL_GENERAL_ENTITIES = 'https://xml.org/sax/features/external-general-entities' - private static final String EXTERNAL_PARAMETER_ENTITIES = 'https://xml.org/sax/features/external-parameter-entities' + // SAX/Xerces feature identifiers are namespace-style URIs that use the http scheme; the parser + // matches them by exact string, so https variants throw SAXNotRecognizedException, get swallowed + // below, and silently leave the parser at its (JDK-version-dependent) defaults. + private static final String DISALLOW_DOCTYPE_DECL = 'http://apache.org/xml/features/disallow-doctype-decl' + private static final String EXTERNAL_PARAMETER_ENTITIES = 'http://xml.org/sax/features/external-parameter-entities' private static final String FEATURE_SECURE_PROCESSING = XMLConstants.FEATURE_SECURE_PROCESSING - private static final String LOAD_DTD_GRAMMAR = 'https://apache.org/xml/features/nonvalidating/load-dtd-grammar' - private static final String LOAD_EXTERNAL_DTD = 'https://apache.org/xml/features/nonvalidating/load-external-dtd' + private static final String LOAD_DTD_GRAMMAR = 'http://apache.org/xml/features/nonvalidating/load-dtd-grammar' + private static final String LOAD_EXTERNAL_DTD = 'http://apache.org/xml/features/nonvalidating/load-external-dtd' private static final Pattern SPACE_AND_EMPTY_ELEMENT_CLOSE = ~/ \/>/ private static final String EMPTY_ELEMENT_CLOSE = '/>' @@ -58,15 +60,27 @@ class XmlUtils { private static final Pattern LINE_ENDINGS = ~/\r\n|[\r\n]/ private static final Pattern XML_DECLARATION = ~/^\s*(<\?xml\b.*?\?>)/ + // Inline DOCTYPE with internal entities is allowed (disallow-doctype-decl=false). External general + // entities are intentionally left enabled so a SYSTEM reference is *attempted* and then blocked by + // the accessExternalDTD/Schema properties below, which throws a SAXParseException ("External Entity: + // ... access is not allowed") rather than silently dropping the reference. private static final Map SECURE_XML_SLURPER_FEATURES = [ (DISALLOW_DOCTYPE_DECL): false, - (EXTERNAL_GENERAL_ENTITIES): false, (EXTERNAL_PARAMETER_ENTITIES): false, (FEATURE_SECURE_PROCESSING): true, (LOAD_DTD_GRAMMAR): false, (LOAD_EXTERNAL_DTD): false ].asImmutable() + // JAXP parser properties: an empty value forbids every protocol for external DTD/entity access, + // so an inline DOCTYPE with internal entities still parses while any external SYSTEM reference + // throws a SAXParseException ("External Entity: ... access is not allowed"). Disabling the + // external-general-entities feature alone only skips the entity silently; these throw. + private static final Map SECURE_XML_SLURPER_PROPERTIES = [ + (XMLConstants.ACCESS_EXTERNAL_DTD): '', + (XMLConstants.ACCESS_EXTERNAL_SCHEMA): '' + ].asImmutable() + /** * Renders XML from the given {@link groovy.xml.MarkupBuilder} DSL closure * using the optionally provided rendering options. @@ -235,7 +249,16 @@ class XmlUtils { } } - saxParserFactory.newSAXParser() + def saxParser = saxParserFactory.newSAXParser() + SECURE_XML_SLURPER_PROPERTIES.each { name, value -> + try { + saxParser.setProperty(name, value) + } + catch (Exception ignored) { + // ignore, parser doesn't support + } + } + saxParser } } From 78b62c57e4c684dd4a13a3f3db7a1dc4e88c4e45 Mon Sep 17 00:00:00 2001 From: James Fredley Date: Fri, 29 May 2026 16:37:10 -0400 Subject: [PATCH 34/34] fix(config): track WriteFilteringMap mutations under Groovy 6 by excluding overridden methods from @Delegate WriteFilteringMap overrides put(String,Object), putAll(Map) and remove(Object) to record writes into the shared nestedDestinationMap (exposed via getWrittenValues()). But @Delegate on the `overlap` field also generated put(Object,Object)/putAll(Map)/remove(Object) forwarding straight to `overlap`, competing with those overrides. Under Groovy 6 a mutation can dispatch to the generated delegate method instead of the override, so the value lands in `overlap` but is never recorded in nestedDestinationMap. Effect: external .groovy config loading/merging silently lost values on Groovy 6 (ExternalConfigRunListener -> WriteFilteringMap), so getConfigProperty(...) returned null; and WriteFilteringMapSpec failed with getWrittenValues() empty. A plain-Groovy reproduction of the class works correctly, which is why it only surfaced through the full config-merge path and the Spock groovy-5.0 artifact's spec compilation - this is a genuine Groovy 6 production bug, not a test-only workaround. Exclude the three overridden mutators from @Delegate so only the tracking overrides (plus their compiler bridge methods) exist; every mutation is now recorded regardless of dispatch. Verified on Groovy 6.0.0-SNAPSHOT: :grails-core:test (309 tests) green, including WriteFilteringMapSpec :grails-test-examples-external-configuration:test green (ExternalConfigSpec, MergedConfigSpec) :grails-core:codeStyle green Assisted-by: claude-code:claude-4.8-opus --- .../grails/config/external/WriterFilteringMap.groovy | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/grails-core/src/main/groovy/grails/config/external/WriterFilteringMap.groovy b/grails-core/src/main/groovy/grails/config/external/WriterFilteringMap.groovy index 9667f677594..1a20e747338 100644 --- a/grails-core/src/main/groovy/grails/config/external/WriterFilteringMap.groovy +++ b/grails-core/src/main/groovy/grails/config/external/WriterFilteringMap.groovy @@ -26,7 +26,14 @@ class WriteFilteringMap implements Map { String keyPrefix private Map proxied // source map - @Delegate + // Groovy 6 / Spock workaround: exclude the mutating Map methods this class already overrides. + // Otherwise @Delegate also generates put(Object,Object)/remove(Object)/putAll(Map) forwarding + // straight to `overlap`, competing with the tracking overrides below. Under Groovy 6 (notably + // when specs are compiled by the Spock groovy-5.0 artifact) a put(...) call can dispatch to the + // generated delegate method instead of the override, so writes land in `overlap` but never in + // nestedDestinationMap and getWrittenValues() comes back empty. Excluding them leaves only the + // overrides (plus their bridge methods), so every mutation is tracked regardless of dispatch. + @Delegate(excludes = ['put', 'putAll', 'remove']) private Map overlap // written values, flattened -- shared private Map nestedDestinationMap // written keys at this level