Skip to content

Groovy 5.0.x support for Grails 8 + Spring Boot 4#15557

Open
jamesfredley wants to merge 149 commits into
8.0.xfrom
grails8-groovy5-sb4
Open

Groovy 5.0.x support for Grails 8 + Spring Boot 4#15557
jamesfredley wants to merge 149 commits into
8.0.xfrom
grails8-groovy5-sb4

Conversation

@jamesfredley

@jamesfredley jamesfredley commented Apr 5, 2026

Copy link
Copy Markdown
Contributor

Adds Apache Groovy 5.0.x support on top of 8.0.x, tracking 5.0.7-SNAPSHOT from GROOVY_5_0_X. Verified on JDK 21 under both indy=true and -PgrailsIndy=false.

Most of the diff is adapting to Groovy 5's stricter @CompileStatic type checking and a few API/behaviour changes (MetaClassHelper.capitalize removal, File truthiness, etc.). The two areas worth a closer review are below.

Target stack

Component Version
Apache Groovy 5.0.7-SNAPSHOT (GROOVY_5_0_X)
Spock 2.4-groovy-5.0
Spring Boot 4.1.0-RC1
Spring Framework 7.0.x
Gradle 9.x
Jakarta EE 10
JDK 21+

AST-level fixes

  • VariableScopeVisitor NPE. Groovy 5's VariableScopeVisitor.visitConstructorOrMethod reads the method exceptions array without a null check, so it NPEs on the null-exceptions methods Grails AST transforms create via ClassNode.addMethod(..., null, ...). Normalised with a proxy MethodNode at the processVariableScopes chokepoints (GrailsASTUtils, AstUtils, AbstractMethodDecoratingTransformation). Reproducer: groovy5-variablescope-canonicalization-bug.
  • setVariableScope(new VariableScope()) in ResourceTransform / AbstractMethodDecoratingTransformation fixes a synthesised-ClosureExpression null-scope ClosureWriter NPE. This is an all-version fix (it also reproduces on Groovy 4.0.31), not Groovy-5-specific.

Workarounds for open upstream Groovy bugs

Each works around an unfixed Groovy 5 defect (verified still failing on 5.0.7-SNAPSHOT) and can be removed once the upstream fix lands in GROOVY_5_0_X.

# Site Upstream bug Status Reproducer
1 ControllerActionTransformer tags controllers that declare parameterized actions with OptimizingStatementWriter.ClassNodeSkip Under indy=false, a parameterized action throws MissingPropertyException in a live app. The generated zero-arg dispatch wrapper binds the request params to locals, delegates via this.action(p1, p2), and wraps the body in a try/catch/finally. OptimizingStatementWriter emits that call twice behind a __$stMC guard: a fast path that loads the locals correctly and a slow path that re-resolves them as dynamic property reads (getProperty). The non-empty finally triggers a CompileStack scope restore that drops the locals between the two emissions, so the slow path's getVariable lookup misses and falls back to getProperty. The slow path is taken whenever the controller metaclass is non-standard - i.e. in any real app (Spring Security, plugins, ...) - so it fails at runtime even though the fast path (and any isolated script, which keeps the standard metaclass) is correct. ClassNodeSkip suppresses the fork so a single correct path is emitted. GROOVY-12062 (open) repro
2 ConstrainedProperty.DEFAULT_MESSAGES is built with a Groovy map literal instead of an anonymous-HashMap-with-instance-initialiser On Groovy 5 the bareword references to the sibling DEFAULT_*_MESSAGE constants inside such an initialiser compile to dynamic getProperty reads on this (the HashMap); because the receiver is a Map, the MOP resolves them as key lookups on the still-empty map, so every entry was captured as null and DEFAULT_MESSAGES.get(code) returned null (the interface <clinit> field-init order is actually correct - the earlier "initialiser runs before the constants" explanation was wrong). A map literal resolves the constants against the enclosing interface scope and is correct on every version, so the prior AbstractConstraint MESSAGE_BUNDLE fallback was reverted to the base form. GROOVY-12063 (open) in-repo DefaultMessageResolutionSpec
3 Validateable.resolveDefaultNullable() Method.invoke reflection TraitReceiverTransformer rewrites the in-trait this.defaultNullable() call to a direct trait-helper static call, losing the implementing-class override; reflection is the only path that honours it. GROOVY-11985 (open) repro

The g.taglib STC change (GroovyPageTypeCheckingExtension) matches the taglib namespace by name in methodNotFound - the approach @paulk-asert recommended for GROOVY-12041 - because when the receiver inherits getProperty(String) the STC unresolvedVariable/unresolvedProperty callbacks never fire. Reproducer: groovy5-stc-extension-node-identity-bug.

Related open PRs (target 8.0.x)

PR Title Relevance
#15677 Bump Micronaut platform to 5.0.0 (GA) Resolves the org.ow2.asm:asm 9.10.1-vs-9.9.1 grails-micronaut-bom version conflict (inherited from base).
#15710 Enable SiteMesh 3 on the 8.x (Spring Boot 4) line Independent; SiteMesh 3 is currently disabled on 8.0.x.
#15652 Stop leaking build-only deps into the Grails BOM (DRAFT) Long-term home for the docs-only javaparser-core BOM entry bumped here for groovy-groovydoc.
#15467 Replace Spring Dependency Management with Gradle platform + BOM property overrides Infrastructure for the BOM version-override conflicts (asm, javaparser).
#15669 @GrailsCompileStatic support for controllers that call tag libs Related to the g.taglib STC change.
#15703 Update Spock to 2.4 Spock alignment on the 8.0.x line.

matrei and others added 30 commits May 15, 2025 10:51
# Conflicts:
#	build.gradle
#	dependencies.gradle
#	grails-forge/build.gradle
#	grails-gradle/build.gradle
# Conflicts:
#	buildSrc/build.gradle
#	dependencies.gradle
#	grails-bootstrap/src/main/groovy/org/grails/config/NavigableMap.groovy
#	grails-gradle/buildSrc/build.gradle
# Conflicts:
#	dependencies.gradle
#	gradle/test-config.gradle
#	grails-forge/settings.gradle
#	settings.gradle
# Conflicts:
#	gradle.properties
#	grails-core/src/test/groovy/org/grails/plugins/BinaryPluginSpec.groovy
Cherry-picked comprehensive Groovy 5 compat from 9574fe8.

Conflict resolutions:
- dependencies.gradle: Groovy 5.0.5 GA (not SNAPSHOT) + Jackson 2.21.2
- LoggingTransformer: Keep manual log field injection (avoids Groovy 5 VariableScopeVisitor NPE entirely)
- TransactionalTransformSpec: Remove direct Spock feature method invocation (Groovy 5/Spock 2.x incompatible)
- grails-test-core/build.gradle: Remove spock-core transitive=false, keep junit-platform-suite
- grails-test-suite-uber/build.gradle: Remove spock-core transitive=false and explicit byte-buddy
timeZone(nullable: true)
}

// Manual Comparable implementation (replaces @Sortable which conflicts with @Entity in Groovy 5)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are Sortable & Entity conflicting in Groovy 5? This seems like a groovy bug or we need to document this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Under Groovy 5 the @Sortable AST transform and GORM's @Entity transform clash on the same class - @Sortable generates compareTo/comparators over the persistent properties while @Entity is also rewriting the class, and the combination fails to compile under Groovy 5's stricter checking. Since these are just test fixtures, I replaced @Sortable(includes=[...]) with an equivalent hand-written compareTo/Comparable. It is a test-only workaround for the transform interaction; I can open a Groovy issue with a standalone reproducer if you want it tracked upstream, and it may be worth an upgrade-guide note if it affects user @Entity + @Sortable classes generally.

@jdaugherty jdaugherty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR could be significantly cleaned up if we revert the transitive change & the binding changes. It would make this a lot easier to review. I also think far more integration tests are excluded than should be. I understand it may be broken when indy=false, but this seems like a major blocker to accepting this PR.


if (view != null) {
Map<String, ?> model
Map<String, Object> model

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why isn't ? allowed for Object? @paulk-asert is this an intentional generics change in Groovy?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Groovy 5's STC got stricter about assigning to a wildcard-typed local: Map<String, ?> model followed by model = map (a raw/Map value) no longer type-checks, so the variable is declared Map<String, Object>. It is purely a local type-declaration change with no runtime effect. @paulk-asert is the tighter handling of ?-typed assignment targets intended in Groovy 5?

@jamesfredley

Copy link
Copy Markdown
Contributor Author

@jdaugherty

I found a functional workaround for:

Groovy 5 + indy=false: a parameterized controller action throws MissingPropertyException at runtime only in a live application

working through that now and then will work through all these comments.

…meterized controller actions

Under indy=false, ControllerActionTransformer's generated zero-arg dispatch
wrapper binds request parameters to locals and delegates via this.action(p1, p2).
Groovy 5's OptimizingStatementWriter compiles that call as a __$stMC fast/slow
pair; the slow path - taken whenever the controller metaclass is non-standard,
i.e. in any live application - re-resolves the bound locals as dynamic property
reads (this.getProperty("p1")), throwing MissingPropertyException at runtime even
though the compiled fast path is correct.

Tag controllers that declare parameterized actions with
OptimizingStatementWriter.ClassNodeSkip so the fast/slow fork is suppressed and a
single correct path is emitted. This replaces the previous workaround, which
disabled integrationTest for five grails-test-examples modules; that
gradle/boot4-disabled-integration-test-config.gradle file is removed and those
integration tests run again (verified under -PgrailsIndy=false and -PgrailsIndy=true).

This is a workaround for an upstream Groovy defect, not a fix of it; remove once
OptimizingStatementWriter is fixed upstream.

Reproducer: https://github.com/jamesfredley/groovy5-controller-action-param-scope-bug

Assisted-by: claude-code:claude-4.8-opus
@jamesfredley

Copy link
Copy Markdown
Contributor Author

Controller action parameter scope under indy=false: approaches evaluated

For the record, here is the full set of approaches we identified for the parameterized-controller-action MissingPropertyException under -PgrailsIndy=false, what was tried, and what was not.

Confirmed root cause: for a parameterized action, ControllerActionTransformer generates a zero-arg dispatch wrapper that binds the request params to local variables and delegates via this.action(p1, p2). Groovy 5's OptimizingStatementWriter compiles that delegating call as a __$stMC fast/slow pair; the slow path - taken whenever the controller metaclass is non-standard, i.e. in any live app (Spring Security, plugins, ...) - re-resolves the bound locals as dynamic property reads (this.getProperty("p1")), throwing MissingPropertyException. The fast path (and any isolated script, which keeps the standard metaclass) loads the locals correctly, which is why this only failed in a running app. (The earlier hypothesis that the TryCatchStatement re-parenting / VariableScopeVisitor not rebinding was responsible turned out to be incorrect.)

Tried

# Approach Result
T1 Reuse the original method's BlockStatement as the try-body when exception-wrapping (avoid re-parenting a fresh block) Fails - re-parenting was never the cause
T11 Don't exception-wrap the parameterized original; only wrap the generated zero-arg dispatch wrapper Fails - the defect is in the wrapper's delegating call, not the original method
T12 Drop setMethodTarget(...) on the this.action(...) delegating call Fails - the __$stMC fork is still emitted and the slow path still uses getProperty
T13 Re-run VariableScopeVisitor (processVariableScopes) on the generated wrapper Fails - classic codegen resolves variables via CompileStack, not the AST scope
T14 Tag the controller ClassNode with OptimizingStatementWriter.ClassNodeSkip to suppress the fast/slow fork Works - a single correct path is emitted; all 5 re-enabled grails-test-examples modules pass integrationTest under both indy=false and indy=true, and the :grails-controllers:test unit suite passes

Identified but not tried

Approach Why not
Set accessedVariable on the delegating-call argument VariableExpressions Classic AsmClassGenerator ignores accessedVariable (it resolves by name via CompileStack), so it cannot help - the same reason T13 fails
Per-statement opt-out via OptimizingStatementWriter.StatementMeta(optimize=false) Not a usable API surface: StatementMeta.optimize is private and OptVisitor.addMeta forces it back to true
Inline the original action body into the wrapper instead of delegating Higher risk - returns, exception wrapping, parameter scope, and command-object behaviour would all become the transformer's responsibility
@CompileStatic the generated wrapper The request-binding logic (params.int(...), etc.) is not statically type-checkable
Force the fast path globally (disabledStandardMetaClass / set __$stMC) Global side effects; not a per-controller, production-safe option

Chosen: T14. It is the minimal, public, per-controller switch - ClassNodeSkip is a documented Groovy API and OptimizingStatementWriter.setNodeMeta(...) returns early when it is present - and it is a workaround for the upstream Groovy defect rather than a fix of it, to be removed once OptimizingStatementWriter is fixed upstream. Standalone reproducer: https://github.com/jamesfredley/groovy5-controller-action-param-scope-bug

@jdaugherty

Copy link
Copy Markdown
Contributor

Do we have a groovy ticket on the indy=false issue? Is this not likely to be fixed? Have you examined a before/after of the byte code to ensure what your AI is saying is actually what's happening?

GORM's ConfigurationBuilder binds a Class-typed setting (such as
hibernate.configClass) by delegating to the property resolver's ConversionService,
which relies on a String->Class converter that resolves the name with the framework
class loader (DatastoreUtils and HibernateGrailsPlugin use getClass().getClassLoader()).
That class loader cannot see an application-defined class such as
functional.tests.CustomHibernateMappingContextConfiguration, so the value was
silently left unbound; under Spring 7 the deep MapToMapConverter can also throw
ConverterNotFoundException for the Class<?> wildcard type.

Resolve Class-typed settings directly in ConfigurationBuilder via
ClassUtils.forName(name, Thread.currentThread().contextClassLoader), independent of
any registered converter and using the application class loader. This matches the
canonical GORM approach (grails-testing-support 5539ec0) and re-enables the
previously disabled hibernate.configClass assertion in the grails-hibernate
test-example BootStrap.

Verified by two new specs in HibernateConnectionSourceSettingsSpec, including one
that builds the settings from a plain StandardEnvironment with no String->Class
converter registered.

Assisted-by: claude-code:claude-4.8-opus
The Groovy 5 migration changed the render() guards from `if (template && destination)`
to `if (template != null && destination != null)`, which dropped Groovy-truth
semantics on the template argument: an empty CharSequence or empty Map value should be
falsy and skip rendering.

Restore Groovy truth on the template operand for the render(Map) and
render(CharSequence, File, ...) overloads in both grails-core and grails-shell-cli,
while keeping `destination != null` (the destination File is being created, so it must
not be gated on existence). The render(File) and render(Resource) overloads are
unchanged, as `!= null` is correct for those non-CharSequence types.

Assisted-by: claude-code:claude-4.8-opus
Commit fb725b1 fixed the Groovy 5 indy=false controller-action parameter-scope
bug at the framework level via OptimizingStatementWriter.ClassNodeSkip in
ControllerActionTransformer. The manual rewrites that converted parameterized actions
(def index(Integer max)) to zero-arg form using params.int(...) / params.long(...) are
therefore redundant and out of scope for this change.

Revert those rewrites to the original parameterized-action form across the
grails-test-examples controllers and grails.rest.RestfulController, restoring the
documented action style (including RestfulController.index's negative-max guard).

Assisted-by: claude-code:claude-4.8-opus
The Micronaut platform ships org.ow2.asm 9.10.1, above the 9.9.1 inherited via
grails-base-bom, so five micronaut test-example modules used ext.allowedBomOverrides
to exempt the mismatch from the dependency-validator. That escape hatch defeats the
validator's purpose.

Pin org.ow2.asm:asm and asm-util at 9.10.1 in the micronaut BOM
(customBomVersions/customBomDependencies) so the resolved version equals the
BOM-managed version, and remove the per-app allowedBomOverrides from all five modules.
Also drop the redundant micronaut-specific groovy.version override; it now tracks the
main bom (still 5.0.7-SNAPSHOT via combinedVersions).

validateDependencyVersions passes for all five micronaut modules. See #15677 / #15467
for the upstream Micronaut platform / Gradle platform work that resolves the asm
conflict longer term.

Assisted-by: claude-code:claude-4.8-opus
The spec "Test resolve paths for local and request version" was disabled with
@IgnoreIf on Groovy 5 because it stubbed the final method GrailsWebRequest.getRequest(),
which Spock cannot mock without extra configuration. The resolver under test
(SmartViewResolver.resolveView and buildQualifiers) uses the request and response
passed in directly and never calls webRequest.getRequest()/getResponse(), so those
stubs were superfluous.

Drop the @IgnoreIf, the now-unused isGroovy5OrLater() helper and IgnoreIf import, and
the two superfluous final-method stubs. No mocking library is needed; all specs in the
file pass on Groovy 5.

Assisted-by: claude-code:claude-4.8-opus
testAssociationsBinding installs a global metaclass via GroovySpy(Author, global: true).
Add an explicit cleanup: block that calls
GroovySystem.metaClassRegistry.removeMetaClass(Author) so the spy is torn down even if
the feature method fails, keeping the metaclass registry clean for tests that share the
fork.

Assisted-by: claude-code:claude-4.8-opus
Address review feedback on workaround comments without changing behavior:
- AbstractConstraint: explain why ConstrainedProperty.DEFAULT_MESSAGES is null under
  Groovy 5 (interface field initialization order) and why MESSAGE_BUNDLE is the fallback.
- Validateable / BeanPropertyAccessorFactory: explain the reflective defaultNullable()
  resolution and reference GROOVY-11985 (the upstream TraitReceiverTransformer regression).
- ResourceTransform: reword the setVariableScope comment as the API requirement it is,
  not a Groovy-5-specific workaround.
- ServiceTransformSpec: drop the chatty inline note.
- grails-gradle docs-config.gradle / tasks/build.gradle: simplify the gradle-embedded
  Groovy comments.

Assisted-by: claude-code:claude-4.8-opus
Add an "Apache Groovy 5 Behavior Changes" section to the Grails 8 upgrade guide
covering two stricter behaviours that can affect application code:
- Assigning a static field from inside a static closure (constraints/mapping/etc.)
  under static compilation now requires qualifying the field with the class name.
- Indexing a ConfigObject with the subscript operator for a missing key now inserts an
  empty nested ConfigObject; probe with containsKey/get instead.

Assisted-by: claude-code:claude-4.8-opus
@jamesfredley

Copy link
Copy Markdown
Contributor Author

Review feedback addressed - 8 commits pushed (fb725b178b..caad5429a0)

Worked through every open review thread. Each commit is scoped to one concern; resolved threads link their commit for traceability.

Reverted as out of scope (no Groovy 5 change needed):

  • 369d674597 - reverted the manual controller-action parameter rewrites back to def index(Integer max) form. Parameterized actions work via ControllerActionTransformer's OptimizingStatementWriter.ClassNodeSkip tag (fb725b178b), so the params.int(...) rewrites were redundant.
  • df4750c506 - removed the per-app allowedBomOverrides; pinned org.ow2.asm:asm/asm-util at 9.10.1 in the micronaut BOM so the resolved version matches the BOM (the validator's match-or-exclude contract), and dropped the redundant micronaut groovy.version override.

Fixes:

  • 013becf53e - hibernate.configClass now binds under Groovy 5. ConfigurationBuilder resolves Class-typed settings via the context class loader (handles both Class literals and class-name Strings), independent of the load-order-sensitive String->Class converter that resolved against the framework class loader. BootStrap assert re-enabled; covered by three new specs.
  • 3fbfc1aa4e - restored Groovy truth on the template argument in TemplateRendererImpl.
  • 9dd79859c9 - re-enabled JsonViewTemplateResolverSpec: the disabled spec stubbed the final GrailsWebRequest.getRequest() the resolver never calls, so the stub was dropped - no mocking library needed.
  • c884184677 - explicit global GroovySpy cleanup in DataBindingTests.

Docs / comments:

  • caad5429a0 - new "Apache Groovy 5 Behavior Changes" section in the upgrade guide (static-field assignment in a static closure now needs the class-name qualifier; ConfigObject subscript auto-vivifies missing keys).
  • d67db7e8d6 - clarified/trimmed the Groovy 5 workaround comments; the trait static-method reflection workaround now cites GROOVY-11985.

Still open for your input (explained on each thread rather than worked around blindly):

  • GSP @CompileStatic undeclared-variable detection specs - regressed in Groovy 5 STC (GROOVY-6362 / GROOVY-11817 / GROOVY-12041).
  • The VariableScopeVisitor null-exceptions chokepoint proxy (upstream Groovy NPE), @Sortable+@Entity interaction, the geb STC accessor, @Slf4j-applied-in-transform, and the DefaultViewRenderer generics tightening - a few flagged for @paulk-asert.
  • Two items that genuinely cannot be reverted, with rationale inline: the $tt__ direct-invocation assertions (Spock 2.4 reads the per-iteration context, which is unavailable off the runner) and the TestTrait<F extends Serializable> bound (the bound makes Groovy 5 generate an abstract trait setter on the implementer).

Goal throughout was no change where possible, and where a change/workaround was unavoidable, the most concrete one - aligned with the guidance on these threads.

@jamesfredley

jamesfredley commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

Controller-action parameter scope under indy=false - root cause + workaround (commit fb725b178b)

Recording the details for the one Groovy 5 issue in this PR that, as far as we know, has no active JIRA ticket. Until today it was attributed to something else entirely - an AST / VariableScope problem (the action body being re-parented into a TryCatchStatement by ControllerActionTransformer, "dropping" method-parameter scope). That theory was a dead end: re-running VariableScopeVisitor or re-scoping the generated wrapper did nothing. Today we traced the real root cause and landed a workaround that builds and executes correctly.

Symptom: under -PgrailsIndy=false on Groovy 5, invoking a controller action declared with parameters throws MissingPropertyException at runtime - but only inside a real application. Unit tests, indy=true, and isolated scripts all pass.

Root cause - the OptimizingStatementWriter slow path. For a parameterized action, ControllerActionTransformer generates a zero-arg dispatch wrapper that binds the request params to local variables and then delegates to the real method via a hand-built this.action(p1, p2) call. Under classic (indy=false) codegen, Groovy's OptimizingStatementWriter emits that delegating call twice, behind a __$stMC ("standard metaclass") guard: a fast path that loads the bound locals correctly, and a slow path that re-resolves the same locals as dynamic property reads (this.getProperty("counter")). In a live application every controller's metaclass is non-standard (Spring Security and plugins contribute to it), so the slow path runs and the bound local falls through to propertyMissing -> MissingPropertyException. An isolated class keeps the standard metaclass and always hits the fast path - which is why it never reproduces standalone, and why it was mis-diagnosed as a scope problem. The defect is in the slow path's argument codegen (classic codegen resolves variables through CompileStack, not the AST scope), so no amount of re-scoping helps. indy=true uses a different call-site writer and is unaffected.

Literal javap - before vs after. Both blocks are from the same source - def blockCache(int counter) in grails-test-examples/cache DemoController - compiled with -PgrailsIndy=false, differing only by the OptimizingStatementWriter.ClassNodeSkip tag. Both pivot at 320: goto 323. Across the whole controller, the tag drops the in-method fork-guards from 9 getstatic __$stMC branches to 0.

Before (ClassNodeSkip off - the bug):

 320: goto          323
 323: getstatic     #834   // Field __$stMC:Z               <- standard-metaclass guard
 326: ifne          338                                     <- standard MC  -> fast path
 329: invokestatic  #840   // BytecodeInterface8.disabledStandardMetaClass:()Z
 332: ifne          338
 335: goto          430                                     <- non-standard MC -> SLOW path
 338: aload_0                                               // FAST path (correct):
 339: iload_3                                               //   load the bound local 'counter'
 340: invokevirtual #1037  // Method blockCache:(I)         //   this.blockCache(counter)
 343: astore        4
  ... (allowed-methods handling elided) ...
 430: aload_0                                               // SLOW path (taken in any live app):
 432: aload_1
 433: ldc_w         #1042  // int 556
 436: aaload
 437: aload_0
 438: invokeinterface #231  // CallSite.callGroovyObjectGetProperty   // this.getProperty("counter")  <- BUG

After (ClassNodeSkip on - the fix in this PR):

 320: goto          323
 323: aload_0                                               // single path, no __$stMC fork:
 324: iload_3                                               //   load the bound local 'counter'
 325: invokevirtual #1003  // Method blockCache:(I)         //   this.blockCache(counter)
 328: astore        4

ControllerActionTransformer tags controllers that declare parameterized actions with org.codehaus.groovy.classgen.asm.OptimizingStatementWriter.ClassNodeSkip, which suppresses the fast/slow fork so only the correct path is emitted. With it in place the affected grails-test-examples integration tests pass under both indy=false and indy=true, and the previous boot4-disabled-integration-test-config.gradle (which simply disabled those tests) was removed.

Status: this is a workaround, not a fix of the underlying Groovy defect. Unlike the other Groovy 5 items in this PR, it has no JIRA ticket yet (it was misattributed until today). It should be removed once the OptimizingStatementWriter slow-path codegen is fixed upstream. Full root-cause writeup + standalone harness: https://github.com/jamesfredley/groovy5-controller-action-param-scope-bug

Forward-merge the 8.0.x base into the Groovy 5 / Spring Boot 4 branch:
code-analysis + jacoco convention plugins, SiteMesh 3 enablement, GORM
TCK domain registration, bindData id/version dirty-check fix, and the
release-drafter overhaul.

Conflict: dependencies.gradle
- spock.version kept at 2.4-groovy-5.0; this branch targets Groovy 5 and
  needs the Groovy-5 Spock variant (2.3-groovy-4.0 would break Spock
  test compilation).
- starter-sitemesh.version / spring-webmvc-sitemesh.version adopted from
  8.0.x at 3.3.0-SNAPSHOT, the Spring Boot 4 SiteMesh upgrade from #15710,
  superseding the branch's pre-merge 3.2.3.

Assisted-by: claude-code:claude-4.8-opus
@codecov

codecov Bot commented Jun 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 18.2620%. Comparing base (41f9fb9) to head (8429505).

Additional details and impacted files

Impacted file tree graph

@@                 Coverage Diff                 @@
##                8.0.x     #15557         +/-   ##
===================================================
- Coverage     48.3483%   18.2620%   -30.0864%     
+ Complexity      15104        258      -14846     
===================================================
  Files            1870         54       -1816     
  Lines           85459       3176      -82283     
  Branches        14901        527      -14374     
===================================================
- Hits            41318        580      -40738     
+ Misses          37805       2486      -35319     
+ Partials         6336        110       -6226     

see 1816 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Replace the anonymous-HashMap-with-instance-initializer for
ConstrainedProperty.DEFAULT_MESSAGES with a Groovy map literal. On Groovy 5
(GROOVY-12063) the bareword references to the sibling DEFAULT_* constants
inside such an initializer compile to dynamic getProperty calls on `this`
(the HashMap); because the receiver is a Map, the MOP resolves them as key
lookups on the still-empty map, so every entry was captured as null. A map
literal resolves the constants against the enclosing interface scope and is
correct on every Groovy version.

This fixes DEFAULT_MESSAGES for all consumers, so the AbstractConstraint
MESSAGE_BUNDLE fallback is reverted to the base getDefaultMessage form. Also
add the previously-missing default.not.unique.message entry so the map covers
every key in DefaultErrorMessages.properties, and make DefaultMessageResolutionSpec
iterate the whole bundle as a complete regression guard.

Correct the now-disproven root-cause comments: the AbstractConstraint and
DefaultMessageResolutionSpec "interface field-initializer order" explanation
was wrong (the interface clinit field-init order is actually correct); the real
cause is the Map-MOP getProperty dispatch above. Reference GROOVY-12063. Add the
GROOVY-12062 id and the non-empty-finally trigger detail to the
ControllerActionTransformer ClassNodeSkip comment.

Assisted-by: claude-code:claude-4.8-opus
@jamesfredley

Copy link
Copy Markdown
Contributor Author

Upstream Groovy bugs filed + root-cause records corrected

The two previously-unfiled Groovy 5 defects in this PR now have tickets, each with a self-contained pure-Groovy reproducer (no Grails dependency), verified as a regression across 4.0.x -> 5.0.5 / 5.0.6 / 5.0.7-SNAPSHOT / 6.0.0-alpha-1 on JDK 21:

  • GROOVY-12062 - OptimizingStatementWriter's __$stMC slow branch resolves a try-block local as getProperty when the method has a non-empty finally (classic indy=false codegen). This is the controller-action parameter-scope bug; the ClassNodeSkip workaround stays.
  • GROOVY-12063 - an anonymous inner class extending Map resolves enclosing-class field references in its instance initializer as a dynamic getProperty on this, which the MOP turns into a key lookup on the still-empty map (so every value is null). This is the ConstrainedProperty.DEFAULT_MESSAGES bug.

Records corrected (commit 9b47e14932)

The earlier root-cause explanations were wrong and have been corrected in the description and in-code comments:

  • The GROOVY-12063 story was not "the interface field-initializer order changed / the map initialiser runs before the constants are assigned". A javap of the Groovy 5 bytecode shows the interface <clinit> assigns the DEFAULT_*_MESSAGE constants before the map is built; the real cause is the Map-MOP getProperty dispatch above. Corrected in AbstractConstraint, DefaultMessageResolutionSpec, and the PR description.
  • The GROOVY-12062 story was earlier (in now-deleted boot4-disabled comments) attributed to TryCatchStatement re-parenting / VariableScopeVisitor not rebinding. The real trigger is the non-empty finally causing a CompileStack scope restore that drops the locals between the two __$stMC emissions. Corrected in the ControllerActionTransformer comment + PR description.

GROOVY-12063 now fixed at the source, not patched downstream

Now that the mechanism is understood, the workaround is the most Grails-idiomatic form rather than a symptom patch:

  • ConstrainedProperty.DEFAULT_MESSAGES is built with a Groovy map literal instead of the new HashMap() {{ put(...) }} idiom, so the constant references resolve against the interface scope (correct on every Groovy version, for all consumers of the map).
  • The AbstractConstraint.getDefaultMessage MESSAGE_BUNDLE fallback is reverted to the base 8.0.x form - it was only ever a symptom patch.
  • Fixed a latent gap surfaced in review: default.not.unique.message (used by UniqueConstraint) was missing from DEFAULT_MESSAGES. The map now covers all 16 keys in DefaultErrorMessages.properties, and DefaultMessageResolutionSpec now iterates the whole bundle (MESSAGE_BUNDLE.keySet()) so a missing key can never silently recur.

:grails-datamapping-validation:test is green (16/16).

Assisted-by: claude-code:claude-4.8-opus

jamesfredley and others added 5 commits June 4, 2026 16:47
Forward-merge the 8.0.x base (hibernate7 staging module #15654 plus the
pull-forward commits) into the Groovy 5 / Spring Boot 4 branch.

Conflicts (4):
- .github/workflows/groovy-joint-workflow.yml: kept the branch's
  --init-script groovy-joint-build.init.gradle hook on the pTML step.
- build-logic/plugins/.../SbomPlugin.groovy: kept the branch's superset of
  CycloneDX license-exception mappings (antlr4-runtime, the org.jline:*@3.30.9
  family, liquibase-hibernate5) alongside 8.0.x's jzlib entry.
- grails-test-suite-uber/.../DomainClassWithInnerClassUsingStaticCompilationSpec.groovy:
  took 8.0.x's removal of the namedQueries closure (deprecated feature removed
  on 8.0.x; the corresponding field and test method were already dropped by the
  auto-merge, so the closure was orphaned).
- grails-data-hibernate7/core/.../HibernateEventListeners.java: accepted 8.0.x's
  hibernate7 staging file (false rename/delete pairing with a deleted
  logback.groovy).

Assisted-by: claude-code:claude-4.8-opus
The grails-hibernate7-micronaut-bom block in dependencies.gradle had
drifted from the grails-micronaut-bom / grails-hibernate5-micronaut-bom
block: it still hard-coded groovy.version 5.0.5 and lacked the org.ow2.asm
pin those blocks gained in df4750c.

Because these BOM entries are declared strictly, the stale groovy 5.0.5
made :grails-test-examples-micronaut-hibernate7:runtimeClasspath
unresolvable against the build's 5.0.7-SNAPSHOT, breaking the build,
the functional/hibernate/mongo test jobs and the CodeQL analyze job. The
missing asm pin separately failed validateDependencyVersions (resolved
9.10.1, expected 9.9.1).

Drop the redundant groovy.version override so it inherits 5.0.7-SNAPSHOT
via combinedVersions, and pin org.ow2.asm:asm / asm-util to 9.10.1,
matching the sibling micronaut BOM block.

Assisted-by: claude-code:claude-4.8-opus
The colon-alignment whitespace in the DEFAULT_MESSAGES map literal
violated the CodeNarc SpaceAroundMapEntryColon rule (no whitespace is
allowed before a map-entry colon), failing
:grails-datamapping-validation:codenarcMain.

Reformat to a single space after each colon and none before. Keys and
values are unchanged.

Assisted-by: claude-code:claude-4.8-opus
Removes workaround for Groovy 5 OptimizingStatementWriter slow-path
bug for parameterized controller actions.

Groovy issue https://issues.apache.org/jira/browse/GROOVY-12062 fixed by
apache/groovy#2590 upstream.
@testlens-app

testlens-app Bot commented Jun 8, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

⚠️ TestLens detected flakiness ⚠️

Test Summary

Check Project/Task Test Runs
CI / Functional Tests (Java 25, indy=false) :grails-test-examples-scaffolding:integrationTest UserControllerSpec > User list ❌ ✅

🏷️ Commit: d3506cf
▶️ Tests: 19091 executed
⚪️ Checks: 43/43 completed


Learn more about TestLens at testlens.app.

@matrei

matrei commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

GROOVY-12062, Controller-action parameter scope under indy=false, now fixed at the source, workaround removed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

5 participants