Skip to content

beans DSL: silent-failure diagnostics, new declaration forms, the rest of the @Conditional* family - #16292

Open
codeconsole wants to merge 56 commits into
apache:8.0.xfrom
codeconsole:feat/beans-dsl-diagnostics-8.0.x
Open

beans DSL: silent-failure diagnostics, new declaration forms, the rest of the @Conditional* family#16292
codeconsole wants to merge 56 commits into
apache:8.0.xfrom
codeconsole:feat/beans-dsl-diagnostics-8.0.x

Conversation

@codeconsole

@codeconsole codeconsole commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Work on the beans DSL from #16019, in four kinds: diagnostics for mistakes the DSL currently accepts in silence, new declaration forms, the rest of the @Conditional* family, and one tooling addition. 33 commits over 7 files, all in grails-beans-dsl plus the implicit-convention hook in grails-core.

Every in-tree beans = { } user still compiles — cache, core, databinding, domain-class, i18n, mail, url-mappings, sitemesh3 — and the beans-dsl / beans-dsl-plugin example projects pass.

Reading order

1. Behaviour changes first — these are the only commits that can turn a build that passed into one that fails, and they deserve the most scrutiny:

76c0cc3c 6509f299 a partly DSL-shaped block is reported instead of silently dropped
942cce57 a0c90c96 c2768dd6 2867e4d3 sibling bean calls that cannot return the singleton
9d685b98 a post-processor bean must be .staticMethod()
8f8e86a5 any condition qualifier now counts as discriminating between same-named beans (a relaxation)

2. Then the new syntax, which is purely additive: declaration forms (e27cb9bf b1c0c425 4ec20d1b 3c498ebb 41d36653), then qualifiers (5ea9efe4 507dd2c8 69c5de62 8f305841 a2603b0e e0f99fc5 4959bf49 bf31c481).

3. Then tooling and fixes: 910dc72b (dump), 7df2280e 6e32b25e (anonymous inner classes in a lifted body), 6fbfd28c (meta-annotation walk).

4. Docs, tests and tidy-ups last: dedfe26c 2aabf4b2 4a572c7d fb076690 16902348 683432b2 85b14177.

b8a8d00a renames .grailsEnv(...) to .conditionalOnGrailsEnv(...); both spellings were introduced in this PR, so nothing outside it is affected.


1. Diagnostics

A partly DSL-shaped block is reported, not dropped

A block is claimed only when every top-level statement is a bean/field/method call, so an unrelated beans property is left alone. Right for a block with no declarations — wrong for one that has some, where today every declaration is dropped without a word:

def beans = {
    bean('greeting', String) { 'hello' }
    bea('typo', String) { 'oops' }          // registers nothing, silently, for all three
    bean('farewell', String) { 'bye' }
}

Both application classes and plugin descriptors now fail, naming the statement and the way out for a beans property that genuinely is not the DSL — rename it. A block with no declarations at all stays silent, unchanged. Descriptors are not treated more leniently: no Grails version has ever read a beans property off one (plugin loading reads doWithSpring, watchedResources, onChange), and a descriptor is compiled by its author while its missing beans are felt by downstream applications that never see that build output.

A sibling bean call that cannot return the singleton

Getting the registered singleton from a call to another @Bean method is a CGLIB trick, and Spring only plays it for a full @Configuration class. @AutoConfiguration is @Configuration(proxyBeanMethods = false), a generated plugin sibling carries exactly that, and a Grails Application class is a configuration source without being annotated at all — so the DSL's three main hosts are all unproxied:

bean('filterChain', SecurityFilterChain) { HttpSecurity http ->
    http.formLogin { it.successHandler(targetUrlSuccessHandler()) }   // a SECOND handler
    http.build()
}

Nothing about that is visible at runtime: the context starts, every bean exists, and two objects live where one was meant. Inject it instead, as a closure parameter.

Scope, arrived at by testing rather than assumption: targets include hand-written @Bean methods on the same class (the mixed state a migration passes through); a .staticMethod() bean is still checked on a proxied host, because CGLIB cannot override a static method and Spring documents such calls as never intercepted; and the walk stops at nested closures, since new Registry().tap { initialize() } is delegate-first at runtime but implicit-this in the AST — DataBindingGrailsPlugin writes exactly that shape.

2. New declaration forms

// Declare a bean as the interface consumers inject, construct it as something else
bean('calendarUserProvider', CalendarUserProvider, L3meCalendarUserProvider)

// The construction settles type arguments where it binds them concretely
bean('auditorAware', AuditorAware, L3meAuditorAware)        // an AuditorAware<String>

// ...and .typeArguments states them where it cannot
bean('webExpressionHandler', SecurityExpressionHandler).typeArguments(FilterInvocation) { ... }

// Names may be any compile-time String constant, folded as .value(...) folds a config key
bean(VIEW_LOADER_BEAN, GroovyPageResourceLoader) { ... }

Type arguments matter because Spring resolves an injection point by its full generic type, and the type in bean(...) is a class literal, on which Groovy has no syntax for writing them.

group(...) — the nested conditionally-guarded configuration class

group('imageServing').conditionalOnClass(name: 'com.example.OptionalType') {
    bean('roomFileAccessProvider', RoomFileAccessProvider)
    bean('chatFileAccessProvider', ChatFileAccessProvider)
}

Generates a nested static @Configuration(proxyBeanMethods = false) named Host$ImageServingConfiguration, with the conditions on the class. Spring finds it unaided, since ConfigurationClassParser processes a configuration class's member classes.

This is the shape real auto-configurations take — Spring Boot's own JacksonAutoConfiguration carries four nested @ConditionalOnClass classes — and the DSL had no way to write one, so beans needing it had to move to a separate top-level class. It is also the only shape that works for a bean whose own signature names a class that may be absent: a condition is read from the bytecode before anything is loaded, but a @Bean method's parameter and return types resolve when its configuration class is parsed, so guarding such a bean on the method is not reliably safe. Moving it into a group moves the guard onto a class that is never parsed when the condition fails.

3. The rest of the @Conditional* family

bean('adapter', Adapter).conditionalOnBean(Transport) { ... }
bean('sender', Sender).conditionalOnProperty('app.sms.enabled', havingValue: 'true') { ... }
bean('either', Thing).conditionalOnExpression('${a.enabled:false} or ${b.enabled:false}') { ... }
bean('devOnly', Seeder).conditionalOnGrailsEnv('development') { ... }
bean('lockProvider', MongoLockProviderFactory)
        .conditionalOnClass(name: 'net.javacrumbs.shedlock.provider.mongo.MongoLockProvider') { ... }

bean('greeter', Greeter).aliases('legacyGreeter')
bean('cart', Cart).scope('session', proxyMode: ScopedProxyMode.TARGET_CLASS)
bean('client', Client).annotate(Bean, destroyMethod: '')     // merges into @Bean, not a collision

.conditionalOnBean has no zero-argument form: with nothing named Spring deduces the type from the bean's own return type, conditioning a bean on a bean of its own type already existing. .conditionalOnClass takes types and String names in the same positional list, because a literal is compiler-checked but can only name a class this module compiles against, while a class that may be absent has to be a String — and its javadoc says where to write the condition, since a @Bean method's parameter and return types resolve when the configuration class is parsed, making the class-level guard the right one for a signature that names the optional type.

4. Seeing what a block compiled to

-Dgrails.beans.dsl.dumpdir=build/beans

Writes one <qualified name>.beans.txt per host class:

@Bean(value = ["greeter"])
@Primary
@DependsOn(value = "names")
public java.lang.String greeter()

@Bean(value = ["shout"])
public java.lang.String shout(@Autowired(required = false) java.lang.StringBuilder input)

Declarations only — bean names, the annotations the qualifiers became, declared types with any type arguments they ended up carrying, and parameter annotations. Bodies are omitted, being the author's own closure bodies lifted verbatim. Nothing is written unless the property is set. Grails already takes this shape for its other compile-time generator, where grails.views.gsp.keepgenerateddir keeps the Groovy a GSP compiles to.

Also

Closure parameters carry their annotations onto the generated method, which is how an optional dependency is expressed — bean('smsSender', SmsSender) { @Autowired(required = false) SmsTransport t -> ... }. That worked before; it was undocumented and the required = false case was untested, so both are now covered.

The implicit `beans` convention claims a block only when every top-level
statement is a bean/field/method call, so that an unrelated `beans` property on
a pre-8.0 plugin descriptor is left alone rather than failed with a DSL error it
never asked for. That is the right call for a block with no declarations in it.

It is the wrong call for a block that has some. One stray statement among real
declarations is not an unrelated property by any reading - it is the DSL with a
mistake in it, typically a typo in a call name or an `if` wrapped around beans
that belong under a @conditional* qualifier. Today the entire block is dropped
in silence: an author who writes 28 bean() calls and fatfingers one gets zero
beans registered, no diagnostic, and a failure that surfaces far away as beans
that are simply absent at runtime.

So report it, splitting by what there is to protect. An application class fails
outright - `beans` has never meant anything but this DSL there, so there is no
source compatibility at stake. A plugin descriptor only warns and is still left
alone, which is the same conservatism the all-or-nothing claim already applies
to it. A block with no declarations at all stays silent in both, unchanged.

The message names the fix rather than just the symptom, since the two common
causes have different ones: put the condition on the bean
(.annotate(ConditionalOnProperty, ...) / .conditionalOnMissingBean()) rather
than wrapping it in an if, and use method(...) for logic shared between beans.
Calling one @bean method from another and getting the registered singleton back
is a CGLIB trick, and Spring only plays it for a full @configuration class. On a
lite configuration source the identical call is a plain Java call that
constructs a second instance.

Lite is the common case for this DSL, not the exotic one. @autoConfiguration is
@configuration(proxyBeanMethods = false); the sibling generated for a plugin
descriptor carries exactly that; and a Grails Application class is a
configuration source Spring reads @bean methods off without it being annotated
@configuration at all. So the DSL's three main hosts are all unproxied, while
the shape the mistake takes - `otherBean()` in a bean body - is the shape that
was correct in the @configuration class the beans were most likely moved out of.

Nothing about the failure is visible. The context starts, every bean exists, and
two objects live where the author meant one, so a listener registers on the
wrong instance or configuration applied to one is missing from the other.

Detected after both processing passes, over the methods this block generated, so
a synthesized method name is matched as reliably as a derived one. Only an
unqualified call (or one written against `this`) counts - a call with a real
receiver is somebody else's method that happens to share the name. Helper
bodies from method(...) are scanned too, since a helper reaching a bean has the
same problem, but a call TO a helper is left alone: there is no singleton there
to miss.

Proxying is resolved by walking the host's own annotations for a reachable
@configuration, skipping any branch that sets proxyBeanMethods = false. That
resolves @autoConfiguration through its own meta-annotation rather than by
name, so a composed annotation nobody has heard of gets the same answer.
The javadoc says closure parameters become the generated method's parameters
and stops there, so nothing promises their annotations survive - it reads as
unsupported. It works, and the @qualifier tests already rely on it, but a reader
deciding whether the DSL can express an optional dependency has no way to find
that out short of trying it and disassembling the result.

@Autowired(required = false) is the case worth naming, because it is the only
way the DSL can say "inject this if some other module supplied it" - the shape
every optional integration takes - and because unlike @qualifier it changes
whether the context starts at all, rather than which candidate is chosen. It
had no test.

Three now: the annotated parameter starts a context with no candidate and
receives null; the identical fixture without the annotation fails to refresh,
which is what makes the annotation load-bearing rather than decorative; and the
generated method really does carry the annotation, with required=false on it,
rather than merely being present.
The type in bean(...)/field(...)/method(...) is a class literal, and Groovy has
no syntax for writing type arguments on one, so a bean could only ever be
declared raw. That is not cosmetic: Spring resolves an injection point by its
full generic type, so a raw Handler bean cannot be told apart from another by
whoever asks for Handler<Order>, and neither ObjectProvider<Handler<Order>> nor
an injected List<Handler<Order>> can select it at all.

The workaround was to declare the implementation class instead and let Spring
read the arguments off its hierarchy, which only works when the implementation
happens to be the type the author wants the bean known by - and is invisible as
a workaround, so the raw declaration usually just stays and quietly narrows what
can resolve it.

.typeArguments(String) sits in the qualifier chain like the others but is
consumed before the member is built, since it shapes the declared type rather
than attaching anything. It applies to all three declaration forms: a field with
@value("${names}") wants List<String> for the conversion, and a helper method's
return type is read by the compiler the same way a bean's is.

The arity is checked against the type's own parameters, so a mismatch is a
located error naming both counts rather than an unchecked signature that
misleads the next reader. Tested through to behaviour: two Handler beans
differing only in their type argument, and Spring injecting the right one.
The javadoc called .annotate(...) an escape hatch for "any other single-valued
annotation". It has never been limited that way - addMembersFromMap takes as
many attributes as the annotation declares, and the mutually-exclusive-variants
test has been passing ConditionalOnProperty three at a time. (The adjective
itself went when that sentence was rewritten in the previous commit; this is the
rest of what it was getting wrong.)

Array-valued attributes are the part worth a test of their own rather than a
sentence. They work today, but only incidentally: ConditionalOnProperty.name()
is a String[] and that test passes it a bare string, so the widening is covered
by a test that is not about it and would not say so if it broke. It is also the
one behaviour here a reader has grounds to doubt - the transform builds these
annotations at canonicalization, after the point where Groovy's own verifier
does that widening, so it is reasonable to assume a single value would reach the
bytecode writer unwidened and be written as a non-array. It does not; the writer
widens it.

Both spellings pinned, single value and list, against @dependsOn.
Found by reading the DSL's own in-tree users for exactly this: DataBindingGrailsPlugin
builds its registry with `new DefaultDataBindingSourceRegistry().tap { ... initialize() }`.

Inside a closure an unqualified call is resolved against the delegate - tap and
with are delegate-first - so that call reaches the registry, not the
configuration class. The AST records implicit-this either way, so the check as
written could not tell the two apart, and would have rejected working code the
moment a bean in the same block shared a name with a method on some delegate.
Rejecting valid code is a much worse failure than missing an invalid case, and
that plugin is one grep away from being the first casualty.

So the walk stops at a nested closure. The call that motivated the check -
`otherBean()` used directly to build this bean - is a statement in the body and
is still caught; what is given up is the rare sibling call written inside a
nested closure, which is worth it.

The regression test uses that exact shape, with a bean deliberately named
`initialize` so the check has something to match on.
The previous commit split the severity: a compile error on an application
class, a warning on a plugin descriptor, on the grounds that a descriptor's
`beans` property might predate the DSL. Three things are wrong with that.

The warning is not a warning. GrailsASTUtils.warning is a System.err.println -
it never reaches the ErrorCollector, so there is no IDE marker, no Gradle
problem, and no record at all on the next build, which is UP-TO-DATE and prints
nothing while the descriptor still registers zero beans. Against a failure whose
whole problem is that it surfaces far from its cause, that is still silence.

The compatibility being protected has no population. No Grails version has ever
read a `beans` property off a descriptor - plugin loading reads doWithSpring,
watchedResources, onChange and friends; the only "beans" in DefaultGrailsPlugin,
on 8.0.x and 7.0.x alike, is BeanBuilder's own beans() method being handed the
doWithSpring closure. So the descriptor this spared has to be dead code that
also happens to contain a top-level bean(...) call.

And a descriptor is the case where loudness matters more, not less: it is
compiled by the plugin's author, but the missing beans are felt by every
downstream application, whose developers never see the plugin's build output.

Worse, isGrailsPluginDescriptorClass is a name-suffix test, so the split made
severity depend on the class name - moving an identical block from
Application.groovy into FooGrailsPlugin.groovy silently downgraded a build
failure to a line of stderr.

One rule now, host-independent: a `beans` closure containing any top-level
bean/field/method call is the DSL and must be entirely the DSL; one containing
none is not the DSL and is left alone. The message gains the way out for a
property that genuinely is not the DSL - rename it - which is the escape the
warning was standing in for.
@codecov

codecov Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.16667% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 55.2638%. Comparing base (0980623) to head (0c25aef).

Files with missing lines Patch % Lines
...rg/grails/compiler/beans/OnGrailsEnvCondition.java 78.5714% 2 Missing and 4 partials ⚠️
...ion/GlobalGrailsClassInjectorTransformation.groovy 80.0000% 1 Missing and 3 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16292        +/-   ##
==================================================
+ Coverage     55.0529%   55.2638%   +0.2110%     
- Complexity      20773      21056       +283     
==================================================
  Files            2110       2111         +1     
  Lines          101368     102121       +753     
  Branches        18005      18233       +228     
==================================================
+ Hits            55806      56436       +630     
- Misses          37517      37556        +39     
- Partials         8045       8129        +84     
Files with missing lines Coverage Δ
...s/compiler/beans/GrailsBeansASTTransformation.java 84.5928% <ø> (-1.3885%) ⬇️
...ion/GlobalGrailsClassInjectorTransformation.groovy 85.5372% <80.0000%> (+1.0834%) ⬆️
...rg/grails/compiler/beans/OnGrailsEnvCondition.java 78.5714% <78.5714%> (ø)

... and 1 file 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.

Fixes Code Style / Core Projects CI failure on PR apache#16292: GenericsType
import was out of lexicographical order in GrailsBeansASTTransformation,
and the reportStrayBeansStatement message used a plain double-quoted
string with no interpolation.
@codeconsole
codeconsole requested review from borinquenkid, jamesfredley, jdaugherty, matrei and sbglasius and removed request for jdaugherty and matrei September 2, 2026 03:16
@codeconsole codeconsole changed the title beans DSL: two silent-failure diagnostics, .typeArguments(...), and two doc gaps M6 Fix: beans DSL: two silent-failure diagnostics, .typeArguments(...), and two doc gaps Sep 2, 2026

@sbglasius sbglasius 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.

Note: this is an AI-assisted review (Claude Code). Two findings on the new rejectUnproxiedSiblingBeanCalls diagnostic; everything else in the diff held up under review. Treat the comments as starting points, not verdicts.

Things I checked and found correct, for the record:

  • MethodNode does not override equals, so generatedMembers' removeAll is identity-based exactly as its comment claims.
  • ClassHelper.makeCached eagerly calls setAdditionalClassInformation, and DecompiledClassNode.getGenericsTypes() forces lazy init, so raw.redirect().getGenericsTypes() is populated for both classpath and cached JDK types — the .typeArguments(...) arity check is sound for Map, AuditorAware and friends alike.
  • isSelfCall cannot misfire on a closure- or Supplier-typed parameter invoked as name(): Groovy rewrites that to name.call() during SEMANTIC_ANALYSIS, before this CANONICALIZATION transform runs. (Verified by compiling the shape and dumping the AST per phase.)
  • The closure-trimming in declaredType(...) is consistent with the pre-existing outerCall handling, and bean(...) { }.qualifier(...) is already rejected upstream.

The exemption for a full @configuration class was too broad. Spring's
interception is CGLIB subclassing, and a static method cannot be overridden, so
a call to a .staticMethod() bean is never intercepted - not even there. The
early return skipped the whole class, so exactly the failure this diagnostic
exists to catch went unreported on the one host where the author had most reason
to believe the call was safe.

The bean-method map is now built first and narrowed rather than abandoned: on a
proxied host it keeps only the static bean methods, on any other host all of
them. A non-static sibling call on a full @configuration class is still allowed,
which is the exemption that host genuinely earns.

Pinned by a hand-written @configuration with a static @bean and a sibling call -
no DSL involved - asserting the two beans are different instances. That is the
premise the rule rests on, so it is worth a test that fails if Spring ever
changes it, rather than a comment asserting it.

Reported by sbglasius in review.
The walk recorded every type it looked at, including branches it then pruned. So
@configuration, reached through @autoConfiguration and immediately skipped on
proxyBeanMethods = false, was memoized as seen - and a real proxying
@configuration written alongside it was then skipped by that same guard, making
the walk answer false for a class Spring proxies. Every sibling bean call on it
would have been rejected as a compile error while returning the singleton
perfectly well.

visited exists to stop a cycle in the meta-annotation graph, which only needs it
on the path that actually recurses. So the @configuration test and the
proxyBeanMethods = false prune now both happen before anything is recorded, and
only the descent records.

The regression test uses @autoConfiguration and @configuration on one class -
verified to fail with the previous walk, rather than assumed to.

Reported by sbglasius in review.
bean(["name", ] Type) constructs the declared type, so declaring a bean as
the interface its consumers inject forced a factory closure whose whole
content was the construction the bodyless form would have generated:

    bean('communityHostUserProvider', CommunityHostUserProvider) {
        new PixotoCommunityUserProvider()
    }

Twenty-one such beans across two host applications say the same thing
twice. Naming the implementation after the type says it once:

    bean('communityHostUserProvider', CommunityHostUserProvider, PixotoCommunityUserProvider)

The generated method returns the declared type and constructs the named
one, taking constructor arguments from the closure parameters exactly as
the bodyless form does. Recognised by two adjacent type literals, which
cannot collide with (name, Type) since a name is a String literal, so
field(...) and method(...) - which declare members, not beans - keep the
two-argument shape and their existing errors.

Naming an implementation is itself the construction, so that form takes
no body; a body alongside it is rejected rather than silently ignored.
The implementation is checked to be a concrete subtype at the bean(...)
statement, so the failure names both types instead of surfacing as an
assignment error inside a body the author never wrote. The bodyless
error on an interface now points at this form as the fix.
A bean declared as a generic interface and built from an implementation
that binds it had to restate the binding:

    bean('auditorAware', AuditorAware).typeArguments(Long) { new SpringSecurityAuditorAware() }

SpringSecurityAuditorAware implements AuditorAware<Long>, so the
construction already proves what Spring will match injection points
against. Restating it is only an opportunity to state it differently
from the truth. It is now inferred, from a named implementation type or
from a body that is exactly a `new ...` expression, leaving the above as

    bean('auditorAware', AuditorAware, SpringSecurityAuditorAware)

Inference only ever adds what the compiler could already see, and
declines rather than guesses: an explicit .typeArguments(...) always
wins, a body that is anything but a bare construction proves nothing,
and a binding that resolves to a type variable is no answer.

A raw construction of a generic type is declined for a sharper reason
than uninformativeness. Groovy resolves its parameters to their bounds,
so `new GenericBox()` would infer Holder<Object> - and a bean typed
Holder<Object> stops matching the Holder<String> injection point it
satisfied while raw. Narrowing a bean's type on that evidence would be a
silent behaviour change, so raw evidence leaves the raw type standing.
The DSL compiles a beans block into an @autoConfiguration but could not express
the shape auto-configurations actually take. Spring Boot's own
JacksonAutoConfiguration carries four nested @ConditionalOnClass configuration
classes; this DSL had no way to write one, so the beans that need that shape had
to move to a separate top-level class of their own.

They are not a stylistic preference. A condition is read from the bytecode
before anything is loaded, but a @bean method's parameter and return types
resolve when its configuration class is parsed - so a bean whose own signature
names a class that may be absent cannot be guarded on the method, which is
exactly the bean most likely to want guarding. Moving it into a group moves the
guard onto a class that is never parsed when the condition fails. The test for
that declares a bean taking a type nobody supplies, guarded on a class that does
not exist, and asserts the context still starts.

group("imageServing").conditionalOnClass(name: "...") { ... } generates a nested
static @configuration(proxyBeanMethods = false) named
Host$ImageServingConfiguration - the suffix because a bare name says nothing in a
stack trace or an /actuator/beans listing. Spring finds it unaided, since
ConfigurationClassParser processes a configuration class's member classes.

proxyBeanMethods = false matches what Spring Boot's nested classes carry, and
keeps the sibling-call diagnostic meaningful inside the group: its beans are as
unproxied as the ones outside it, and are checked the same way.

Takes the conditions and .annotate(...); the bean-shaped qualifiers have nothing
to attach to. Groups do not nest, and the closure takes no parameters - it
declares a class, not a bean. 'group' joins the root-call set in
GlobalGrailsClassInjectorTransformation too, or a block containing one would be
rejected by the stray-statement diagnostic rather than compiled.
CI failed on the positive half of the conditionalOnBean test: the transport bean
existed and the adapter still did not register. The test declared both in one
beans block, so whether the condition matched depended on @bean method order
within a single class - it passed locally and failed on CI, where the build now
compiles with invokedynamic off.

That is the annotation working as documented, not a transform bug.
@ConditionalOnBean matches only against bean definitions the context has already
processed, which is exactly why Spring restricts it to auto-configurations. A
bean naming another declared beside it is not a condition that reliably holds.

The transport now comes from a separate @configuration registered before the
fixture, which is the one ordering the annotation does guarantee, so both halves
are deterministic. The javadoc says so too - the qualifier had no note about it,
and the caveat is the kind this DSL otherwise makes a point of naming.

Also fixes the checkstyle ImportOrder violation that came in with group(...):
GenericsType sorts before InnerClassNode.
@matrei

matrei commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

AI review:

Review Findings

This PR bundles four themes on @GrailsBeans: diagnostics for previously silent failures, new declaration forms (bean(name, Interface, Impl), group(...)), the remaining @Conditional* qualifiers, and a -Dgrails.beans.dsl.dumpdir build-time introspection hook. The implementation is defensive, the diagnostic messages are actionable, and the spec grew ~4400 lines with matched happy-path and error-path coverage for every new form. The following items should be addressed before merge.

[P1] OnGrailsEnvCondition reflection catch misses NoClassDefFoundError

File: grails-beans-dsl/src/main/java/org/grails/compiler/beans/OnGrailsEnvCondition.java:76-93

The reflective lookup into grails.util.Environment is guarded by:

catch (ReflectiveOperationException | RuntimeException ignored) {
    // Not a Grails application, or an Environment that cannot answer - fall back to the
    // property rather than fail a condition the rest of the context depends on.
    return null;
}

The comment describes the "Grails not on the classpath" case, but Class.forName("grails.util.Environment", ...) raises ClassNotFoundException (caught) only when the class is missing from the loader that resolves it. When the class is referenced from bytecode that cannot be linked, or when a transitive class it needs is absent, the JVM raises NoClassDefFoundError — a LinkageError, which is neither a ReflectiveOperationException nor a RuntimeException. In that case the condition throws out of matches(...) and Spring fails the enclosing configuration.

Please broaden the catch to also cover LinkageError (or Throwable, with a targeted rethrow of Error subclasses you do want to surface such as VirtualMachineError), so the fallback matches the documented intent. Add a direct unit test for OnGrailsEnvCondition — the class is currently exercised only end-to-end through generated bytecode in the transform spec, and the "Grails absent" branch is not covered.

[P2] ClassLoader fallback in OnGrailsEnvCondition should also try the TCCL

File: grails-beans-dsl/src/main/java/org/grails/compiler/beans/OnGrailsEnvCondition.java:78

ClassLoader loader = classLoader != null ? classLoader : getClass().getClassLoader();

ConditionContext.getClassLoader() can legitimately return null early in Spring's bootstrapping. The current fallback to getClass().getClassLoader() is fine for the common case where grails-beans-dsl and grails-core share a loader, but it can miss a Grails Environment class supplied by a parent context loader in a multi-loader setup (e.g. Boot's LaunchedClassLoader, application server deployments, native-image agents). Adding Thread.currentThread().getContextClassLoader() as a third rung matches Spring's own class-presence checks and costs nothing here.

[P2] Stray-statement diagnostic changes plugin-descriptor compile behavior

File: grails-core/src/main/groovy/org/grails/compiler/injection/GlobalGrailsClassInjectorTransformation.groovy:412-478

The rule "any beans closure with at least one bean/field/method top-level statement must be entirely DSL" is correct for the DSL's own semantics, and the message is thorough about both the "typo/if around beans" case and the "not the DSL — rename it" case. However, extending this to grails.plugins.Plugin subclasses (commit 6509f29929) turns a formerly silent quirk into a compile error: any pre-8.0 plugin descriptor with a stray bean(...) call sitting inside a beans property that Grails never read now fails compilation.

The justification given in the doc-comment is sound — an undiscovered miswiring in a plugin surfaces far away in downstream applications, so loudness matters more, not less. But this is a behavior change even though it is a bug fix. Please flag it in the release notes for 8.0 and, if there is a grails-doc migration section, add a note there. See CLAUDE.md rule #7 (user-facing changes require doc coverage).

[P3] Direct unit test missing for OnGrailsEnvCondition

File: grails-beans-dsl/src/test/groovy/org/grails/compiler/beans/GrailsBeansASTTransformationSpec.groovy

OnGrailsEnvCondition is the runtime side of .conditionalOnGrailsEnv(...) and has no dedicated spec. The transform tests confirm the annotation is attached correctly, but they do not exercise:

  • matches(...) returning false when metadata.getAnnotationAttributes(...) is null.
  • Fallback to the grails.env property when grails.util.Environment is not on the classpath.
  • Case-insensitive matching.

A small standalone Spock spec wiring a mock ConditionContext and AnnotatedTypeMetadata locks the contract in, is cheap, and would catch a regression in the [P1] catch fix above.

[P3] Documentation coverage for the new DSL surface

Files: grails-doc/** (no changes in this PR)

The Javadoc on @GrailsBeans, @ConditionalOnGrailsEnv and the new qualifiers is thorough and self-contained. If the beans-DSL reference in the user guide is generated from the annotation Javadoc, this is already covered. If the guide has hand-written sections that enumerate the qualifiers or declaration forms, they need entries for bean(name, Interface, Impl), group(...), the five new .conditionalOn* forms, .aliases(...), .typeArguments(...), and the -Dgrails.beans.dsl.dumpdir build property.

What I Verified

  • Read the full diff of all seven changed files against origin/8.0.x (+6499/-1929, 40 commits).
  • Traced every addError(...) and addErrorAndContinue(...) site in GrailsBeansASTTransformation.java for accurate source positioning at the offending node.
  • Confirmed ClassNode comparisons use .equals(...) (no == slip-ups), null-guards on AST accessors, and closure-boundary handling in the CodeVisitorSupport used by the sibling-bean rejection.
  • Confirmed the dumpdir writer explicitly uses StandardCharsets.UTF_8 and reports failures via addError(...) rather than a runtime exception.
  • Confirmed both new Java files (ConditionalOnGrailsEnv.java, OnGrailsEnvCondition.java) carry the Apache license header, use jakarta.* where applicable, avoid wildcard imports, and use 4-space indentation.
  • Spot-checked the spec for happy-path and error-path coverage of every new declaration form, qualifier, and diagnostic.

OnGrailsEnvCondition reads grails.util.Environment reflectively and falls back to
the grails.env property when it cannot, so a non-Grails context is skipped
quietly instead of failing. The catch did not cover the ways that actually
happens.

ClassNotFoundException only covers the class being absent from the loader that
was asked. A class present but unlinkable - a transitive dependency missing, a
version mismatch - raises NoClassDefFoundError, and Class.forName here
initializes (reading getCurrent needs it), so a static initializer that blows up
raises ExceptionInInitializerError. Both are LinkageError, neither is a
ReflectiveOperationException, and either was thrown straight out of matches(),
failing the configuration this condition exists to skip. LinkageError joins the
catch; VirtualMachineError deliberately does not.

The loader fallback now goes through Spring's ClassUtils.getDefaultClassLoader()
rather than this class's own loader. ConditionContext.getClassLoader() may be
null early in bootstrapping, and the default tries the thread context loader
first - which is the one that can see Grails when this class and the application
are loaded separately, as under Boot's LaunchedClassLoader.

The condition had no spec of its own, being exercised only through generated
bytecode. It has one now: absent attributes, the property fallback,
case-insensitive matching across several environments, neither source answering,
and both LinkageError paths - the last two verified to fail against the
unbroadened catch rather than assumed to.

Reported by matrei in review.
The beans-DSL section of hookingIntoRuntimeConfiguration.adoc enumerates the
declaration forms and qualifiers by hand, so it described the DSL as of apache#16019
and none of what this PR adds. CLAUDE.md rule 7 asks for grails-doc coverage of
any user-facing change, and this is a large one.

Added: bean(name, Interface, Implementation) and what a construction settles
about type arguments, .typeArguments(...), the map-construction one-liner for a
property-configured implementation, constant member names, group(...) with the
signature-guarding case that is its whole reason to exist, and the five
remaining conditions - onBean (with the ordering it can and cannot promise),
onProperty, onExpression, onClass (types vs String names, and where to put the
condition), onGrailsEnv - plus .aliases(...) and .scope(...)'s other attributes.

Corrected: .staticMethod() is now required for a post-processor bean rather than
recommended, and .annotate(...) is not limited to single-valued annotations and
merges into an annotation a qualifier already attached rather than colliding
with it.

New sections for the two things a reader cannot discover from the syntax: the
three diagnostics, each with what used to happen instead, and the dumpdir
property. The diagnostics section is also where the behaviour change lands - a
plugin descriptor whose beans property has a stray statement among real
declarations now fails to compile where it used to be quietly ignored.

Reported by matrei in review.
@codeconsole

codeconsole commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in f7d22ce (code + spec) and cf68805 (docs).

[P1] LinkageError — added to the catch. ClassNotFoundException only covers the class being absent from the loader asked; a class present but unlinkable raises NoClassDefFoundError, and Class.forName here initializes, so a failing static initializer raises ExceptionInInitializerError. Both were thrown out of matches(...) and failed the configuration this condition exists to skip. VirtualMachineError deliberately still propagates.

[P2] class loader — the null case now goes through Spring's ClassUtils.getDefaultClassLoader(), which already tries the thread context loader first, then this class's, then the system loader.

[P3] spec — new OnGrailsEnvConditionSpec: absent attributes, the grails.env fallback, case-insensitive matching across several environments, neither source answering, and both LinkageError paths.

[P2] behaviour change + [P3] docshookingIntoRuntimeConfiguration.adoc now covers bean(name, Interface, Implementation), what a construction settles about type arguments, .typeArguments(...), constant member names, group(...), the five remaining conditions, .aliases(...) and .scope(...)'s other attributes. Corrects .staticMethod() (required for a post-processor bean, not recommended) and .annotate(...) (not single-valued; merges into an annotation a qualifier already attached). Adds sections for the three diagnostics and the -Dgrails.beans.dsl.dumpdir property. The plugin-descriptor behaviour change is called out in the diagnostics section.

One clarification: the reflective catch and the loader fallback are the runtime half of .conditionalOnGrailsEnv(...), not the transform — the two failure modes look alike in a stack trace but land in different places.

CI is also green again as of c4cadbb: a checkstyle ImportOrder violation, and a conditionalOnBean test that declared the supplier beside the consumer and so depended on @Bean method order within one class. The supplier now comes from a separate @Configuration registered first, and the qualifier's javadoc states the ordering the annotation does and does not promise.

@matrei matrei 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.

Review Findings

Two new commits since the previous round (f7d22cebd0, cf6880565a) address every item from the earlier review. No new findings — the branch is ready to merge.

Resolved Prior Findings

[P1] OnGrailsEnvCondition reflection catch — fixed

File: grails-beans-dsl/src/main/java/org/grails/compiler/beans/OnGrailsEnvCondition.java:76-107

The catch now covers LinkageError in addition to ReflectiveOperationException and RuntimeException, so both NoClassDefFoundError (transitive class missing at link time) and ExceptionInInitializerError (Class.forName initializes here, so a broken static initializer on Environment is reachable) fall back to the property rather than throwing out of matches(...). VirtualMachineError is deliberately still uncaught. The in-line comment enumerates the cases, so the intent will survive future edits.

[P2] TCCL fallback — fixed

File: grails-beans-dsl/src/main/java/org/grails/compiler/beans/OnGrailsEnvCondition.java:79-82

Loader fallback now goes through Spring's ClassUtils.getDefaultClassLoader() rather than getClass().getClassLoader(). That helper tries the thread context loader first — the correct loader to reach the application's Grails when this class and the application are loaded separately (e.g. under Boot's LaunchedClassLoader). Matches Spring's own class-presence pattern.

[P3] Missing direct spec for OnGrailsEnvCondition — fixed

File: grails-beans-dsl/src/test/groovy/org/grails/compiler/beans/OnGrailsEnvConditionSpec.groovy (new, 117 lines)

Six focused feature methods cover every path I asked for and a couple more:

  • Null annotation attributes → false (no NPE).
  • Property fallback when grails.util.Environment is absent (ClassNotFoundException).
  • Property fallback when the class is present but unlinkable (NoClassDefFoundError) — this test would fail against the previous, un-broadened catch, which is the point.
  • Property fallback when static initialization fails (ExceptionInInitializerError).
  • Case-insensitive matching, several environments named at once.
  • Neither Grails nor the property answering → false.

The loaderThrowing(...) helper is a nice touch: a ClassLoader that throws for grails.util.Environment specifically and defers otherwise, so the paths are exercised without polluting the real classpath.

[P3] Documentation coverage — fixed

File: grails-doc/src/en/guide/plugins/hookingIntoRuntimeConfiguration.adoc:167-230

The user guide now covers every new item, added inline in the existing bullet list rather than as a separate appendix:

  • bean(name, Interface, Implementation) and how a construction settles declared type arguments.
  • .typeArguments(Type, ...) for the cases where it does not.
  • The map-construction one-liner for a property-configured implementation.
  • Compile-time String constant names for members.
  • group("name") { ... } with the signature-guarding case that is its whole reason to exist.
  • All five new conditions: .conditionalOnBean(...) (with its ordering caveat), .conditionalOnProperty(...), .conditionalOnExpression(...), .conditionalOnClass(...) (types vs String names, and where to put the condition), .conditionalOnGrailsEnv(...).
  • .aliases("legacyName") and .scope("session", proxyMode: ...) with its other attributes.
  • New "Diagnostics" section listing the three checks that turn silent runtime failures into compile errors — including an explicit note that the stray-statement diagnostic on a plugin descriptor is a behavior change.
  • New "Seeing what a block compiled to" section for -Dgrails.beans.dsl.dumpdir=<dir>.

Two corrections beyond what the earlier review flagged: .staticMethod() is now stated as required (rather than "recommended") for post-processor beans, matching the compile-time rejection; and .annotate(...) is no longer described as single-valued and correctly explains that naming an annotation a qualifier already attached is a merge rather than a collision — which is the only way to reach attributes no qualifier sets, e.g. .annotate(Bean, destroyMethod: '').

Verification

  • Read git log origin/8.0.x..HEAD (42 commits, two new since the previous review).
  • Read the two new commits in full: f7d22cebd0 (Fall back rather than throw when Grails cannot answer) and cf6880565a (Document the new DSL surface in the user guide).
  • Read the new OnGrailsEnvConditionSpec.groovy and confirmed the NoClassDefFoundError case is asserted, which is the load-bearing regression test for the P1 fix.
  • Read the doc diff and cross-checked against the missing-items list from the previous review — every item is present, plus the two accuracy corrections above.
  • Both new Java changes carry the Apache license header from the existing file; the new spec carries a fresh header.

@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.

I compared the head of this branch against current 8.0.x with a set of small probe specs, on top of the module's own tests, a fresh compileGroovy of every in-tree beans = { } user (core, i18n, cache, url-mappings, databinding, domain-class, mail, sitemesh3) and the two beans-dsl example projects. All of that is green.

Three things need attention before this merges:

  1. A regression in the anonymous-inner-class re-homing: an anonymous class inside a nested closure worked on 8.0.x and now fails at runtime (instance bean) or at compile time (.staticMethod() bean). Details and a one-line fix inline.
  2. group(...) does not survive @CompileStatic on either host kind, failing in class generation with a GroovyBugError. Since every in-tree plugin descriptor is @GrailsCompileStatic, the feature is currently unusable where it is aimed.
  3. On a plugin descriptor, an anonymous class that reaches back to a method(...)/field(...) member fails with NoSuchFieldError at runtime.

Two smaller notes follow on shared-name validation for the new declaration forms and on how the dump property has to be set to reach the compiler.

Comment thread grails-doc/src/en/guide/plugins/hookingIntoRuntimeConfiguration.adoc Outdated
Re-homing an anonymous inner class descended into nested closures, and an
anonymous class written inside one has not lost its enclosing instance - that
closure survives the lift and is still it. Re-homing it anyway rewrote a correct
`this` into the configuration class, so a shape that works on 8.0.x failed at
runtime with the GroovyCastException the re-homing exists to prevent, or, under
.staticMethod(), was rejected outright though it compiles. The walk now stops at
closure boundaries, the same way the sibling-call check does.

group(...) did not survive @CompileStatic on either host kind, which is where it
is aimed - every in-tree plugin descriptor is statically compiled. Groovy's
InnerClassCompletionVisitor adds the MOP dispatch methods every inner class gets
as a post-transform CANONICALIZATION operation, after this transform, so marking
the nested class for static compilation here left those methods generated
without ever being type-checked and class generation died with
"StaticTypesCallSiteWriter#makeCallSite should not have been called". The pass is
deferred to INSTRUCTION_SELECTION, once the methods exist. The sibling's own pass
needed the same treatment, since it reaches any nested class the sibling holds.
A test asserts a body that only type-checks dynamically is still rejected, so
deferring cannot quietly become skipping. All four existing group tests used
dynamic hosts, which is why none of this showed.

Shared-name validation read the [name, ] Type head with its own copy of the old
rules, so both declaration forms this PR adds fell out of it: a three-argument
declaration had too many arguments, and a constant name was not a
ConstantExpression. Two declarations of one name compiled in either form and
Spring silently kept the first - the exact outcome the check exists to prevent.
It now reads the head the way processBeanStatement does, sharing the
implementation-splitting and the constant resolution.

Documented rather than fixed: on a plugin descriptor an anonymous inner class
keeps the descriptor as its outer class, so an unqualified reference from its
body to a field(...)/method(...) member fails with NoSuchFieldError at runtime.
Recognising that at compile time means deciding which names are the anonymous
class's own, and a false positive would reject working code - so it is a note in
the guide and the javadoc, not a check.

Also corrected the dumpdir instructions: GroovyCompile forks, so -D on the
Gradle command line never reaches the process that runs the transform. The guide
now shows groovyOptions.forkOptions.jvmArgs and says why an absolute path is the
safe form.

Reported by jdaugherty in review.
On a plugin descriptor the block's members compile onto the generated sibling,
but an anonymous inner class written in a bean body keeps the descriptor as its
outer class - Groovy fixes that when it creates the node and offers no way to
move it. Its MOP dispatch methods then read a this$0 typed as the descriptor
where the field holds the sibling, so an unqualified reference from its body to
one of those members failed with NoSuchFieldError inside a running application,
or under @CompileStatic as a "cannot find matching method" naming a synthetic
class nobody wrote.

Now a compile error, pointed at the reference. The test is narrow enough to be
safe: a name that is both a member this block generated and not resolvable on
the anonymous class itself or anything it inherits. A call to the anonymous
class's own method, or to one from the interface it implements, is untouched -
only names that actually moved cannot be reached. On a non-plugin host nothing
moved, so nothing is checked.

Three tests: the failing shape rejected with the reference named, a
self-contained anonymous class on a descriptor still compiling, and the same
reference on a plain host still working at runtime.
@codeconsole
codeconsole dismissed jdaugherty’s stale review September 8, 2026 19:30

Addressed requested changes

@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.

Re-checked the head (6663088) with the same probe set as last round, the module spec (313 green locally), and the in-tree beans = { } users. Items 1, 4 and 5 from the previous round are fixed as described - the nested-closure shapes run again, the three-argument and constant-named duplicates are rejected, and the dump property is documented where it counts. Two fixes are incomplete, and one of them introduced a regression:

  1. A @CompileStatic plugin descriptor with an anonymous inner class in a bean body now fails with a GroovyBugError. It compiled and ran at cf68805. Deferring the sibling's static-compilation pass reversed the order of the two passes over the anonymous class, and Groovy then dereferences an enclosing method the lift never set. Every group(...) body that constructs an anonymous class crashes on the same line, on both host kinds; closure-only group bodies are fine now. Root cause, a verified fix and the missing tests inline.
  2. An anonymous class in a group(...) body that reaches anything outside itself fails at runtime with NoSuchFieldError, on every host kind, and the new compile-time check does not cover groups. Inline.

One smaller note on property-style access to a moved accessor.

On CI: the two red jobs are the known 5% flake in UserControllerSpec (#16030) and a runner that lost contact with the server on the Hibernate7 indy=true job, which is green on the last five 8.0.x runs. Neither is this branch.

The parser sets `enclosingMethod` on every anonymous class declared inside a
method body and has none to set for one written in a property initializer, so a
class lifted out of the `beans` closure arrives in its new method without one.
Static compilation reads it through the constructor call whenever the class has
already been visited as an inner class of its outer, and that ordering is
reached two ways: a `@CompileStatic` plugin descriptor, whose own pass now runs
first at INSTRUCTION_SELECTION and marks the class before the deferred sibling
pass gets to the call, and a `group(...)` on either host kind, where the host
visits its inner classes in registration order and the anonymous class was
registered at parse. Both die in class generation with `GroovyBugError:
ClassNode.getEnclosingMethod() is null`.

Setting it alongside the `this$0` field, the constructor parameter and the call
argument the lift already corrects is what the parser would have done. The
bytecode gains an EnclosingMethod attribute naming the bean method, which is
what `this$0` already says.
An anonymous class in a `group(...)` bean body has the same problem the sibling
check exists to catch, and never reached it: the group's beans compile onto a
nested class while the anonymous class keeps the host as its outer class, so
every MOP dispatch out of it reads a `this$0` typed as the host and fails with
`NoSuchFieldError` at runtime - for a group member, for a host member, on either
host kind.

The reachable set for a group is smaller than for a sibling. The group is a
static nested class with no enclosing instance behind it, so nothing outside the
anonymous class is reachable at all, and the check rejects any implicit-this
call or dynamic variable the class cannot resolve on itself or what it inherits
rather than consulting the moved names. A self-contained class, and a captured
local, are unaffected - which is what the error suggests.
`method('getSuffix', String) { '!' }` on a descriptor plus `'hello' + suffix` in
an anonymous class is the same unreachable reference by another spelling, and
comparing raw names alone let it through to the `NoSuchFieldError` the check
exists to prevent - `suffix` is a DynamicVariable while the moved set holds
`getSuffix`. A variable now matches on the accessor names it would resolve to as
well as on itself, the way collectMethodNames already reserves them.
67d20dc set it only for a class written directly in a bean body, which is the
right scope for re-homing `this$0` - a class inside a nested closure keeps that
closure as its enclosing instance and must not be re-homed - but the wrong scope
for the enclosing method. The parser sets that per enclosing METHOD, so a
closure in between makes no difference to what it would have written, and
without it the same `GroovyBugError` fires one closure deeper: on a
`@CompileStatic` descriptor, in a `@CompileStatic` group on either host kind.

Split into its own walk, which descends where the re-homing walk deliberately
stops. Also corrects the reach error, which named only `NoSuchFieldError`: a
nested-closure class fails with a `ClassCastException` between the group (or
sibling) and the host instead, measured with the check disabled.
@testlens-app

This comment has been minimized.

635f284 mapped a property-style read onto a moved accessor, which was one of
four spellings of the same unreachable reference. The other three still compiled
and still failed with NoSuchFieldError at runtime, measured on a descriptor and
in a group:

  this.suffix()   an explicit-this call - the walk tested isImplicitThis, where
                  the sibling-call check next to it already uses isSelfCall
  this.suffix     a PropertyExpression, which the walk never visited at all
  getSuffix()     a call naming the accessor of a moved property or field, the
                  inverse of the mapping 635f284 added

So the name set now relates both ways, and the walk visits self-calls and
this-qualified properties as well as dynamic variables. A reference is left
alone when ANY of its spellings resolves on the anonymous class or what it
inherits, which is what keeps `this.name` reaching its own getName() legal.
Two defects in the reach check, both from its idea of what resolves.

The sibling branch reported only the names the block generated, on the premise
that "the descriptor is still what this$0 is typed as, so everything else about
it resolves the way it reads". That premise is wrong - the lift retypes this$0
to the sibling - so a call to a method the descriptor itself declares, a read of
a field it declares, and a member inherited from Plugin all failed at runtime
with the NoSuchFieldError this check exists to prevent, and none was reported.
Both host kinds now use the same rule: anything the anonymous class cannot
answer itself is out of reach. A field or property that VariableScopeVisitor
resolved against the ENCLOSING class counts as out of reach too - it reads as
resolved while still needing this$0 to be fetched - where a local or a parameter
does not, the lift copying those into the class.

The group branch, conversely, reported too much. "Resolve on itself" was only
the declared and inherited members, so every extension method every object has
- println, with, tap, identity, sleep - was rejected inside a group though the
runtime answers all of them against the instance without ever reading this$0.
Those names come from DefaultGroovyMethods now, and a class declaring its own
methodMissing/propertyMissing is skipped entirely, being able to answer
anything.

Also corrects the javadoc's dumpdir instruction, which still said to build with
-Dgrails.beans.dsl.dumpdir=<dir> - the spelling the guide documents as writing
nothing under a forking GroovyCompile - and omitted the absolute-path caveat.
Both walks took liftedMethod.getCode() as their only root, but a closure
parameter's default value comes across with the parameter, so a class written
there was invisible to the enclosing-method fix and to the re-homing. A
method(...) helper with such a default on a @CompileStatic descriptor still died
with the GroovyBugError two commits back addressed - the same defect, one
location deeper than the body and a nested closure inside it.
Two places the lift repairs a class and the check could not see it, both
reaching runtime as the NoSuchFieldError this exists to prevent:

  a parameter default   the previous commit taught the lift about these and not
                        the check, so a class there was re-homed silently
  a class nested inside  the check pulls an anonymous class's own code out of
  another anonymous one  the InnerClassNode by hand, and never recursed into the
                         anonymous classes it found in there

The recursion carries the reachable names down the chain, because a nested
anonymous class's own enclosing instance was never retyped - it holds the outer
anonymous class as written - so a name on that outer class does resolve from
inside, and only what lies beyond the outermost one is out of reach.

Also corrects two javadoc claims the recent behaviour changes falsified: that a
.staticMethod() bean "cannot carry" an anonymous class, when only one written
directly in its body is rejected and one inside a nested closure is fine and
tested; and that the dump writes one file per host class, when a group writes
its own file under the nested class's name.
Two false positives, each rejecting code that compiles and runs.

The walk descended into closures written inside the anonymous class body and
applied the implicit-this rule there, but a closure's resolve strategy and
delegate are runtime facts: `sb.tap { append(x) }` and `s.with { toLowerCase() }`
are answered by the delegate and were rejected. That is the same shape and the
same fix already applied to rejectUnproxiedSiblingBeanCalls, so this walk stops
at closures too. The cost is a missed diagnostic for a plain owner-first closure
reaching outward, which still fails at runtime with an error naming the class -
strictly better than refusing to compile working code.

And `this.tag` was reported for a field inherited by the anonymous class while
the bare `tag` was not: ClassNode.getFields() is declared fields only, so the
property path found nothing in the reachable set while the variable path
resolved to a real FieldNode and let it through. The inherited field names are
added, so the two spellings agree.
@codeconsole

Copy link
Copy Markdown
Contributor Author

All items from both rounds are addressed. 9 commits, 361 module tests (was 313), each fix confirmed to fail with it backed out. checkstyleMain, all eight in-tree beans = { } users, and both beans-dsl example projects green.

Round 1

Item Status
1. nested-closure re-homing regression fixed (your call), and the walk now stops at closures in 0c25aef too
2. group(...) under @CompileStatic 67d20dc, 55fa461, 5615328
3. descriptor anon reaching method(...)/field(...) 6663088, widened in b952b27, c4546d2
4. shared-name validation for the new forms fixed (your call)
5. dumpdir property guide fixed (your call); the javadoc still carried the -D form and no absolute-path caveat — b952b27

Round 2

Item Status
1. @CompileStatic descriptor anon GroovyBugError 67d20dc sets the enclosing method; 55fa461 covers a nested closure; 5615328 a parameter default
2. group anon reaching outward 191830e; c4546d2 covers a parameter default and a class nested in another
3. property-style access to a moved accessor 635f284; 4fe4570 adds this.suffix(), this.suffix, and getSuffix() against a moved property or field

Six further gaps in the same code paths, found while completing the above and each reproduced first:

  • this$0 is retyped to the sibling, so a member the descriptor itself declares, or one inherited from Plugin, failed identically and was not reported. Both host kinds now use one rule: anything the class cannot answer itself. b952b27
  • That rule rejected every DefaultGroovyMethods extension inside a group — println, with, tap, identity. Those resolve against the instance and never read this$0. b952b27
  • A reference in an anonymous class field initializer was invisible to the check. 4fe4570
  • A parameter default was re-homed but not reach-checked. c4546d2
  • sb.tap { append(x) } inside an anonymous class body was rejected — the delegate-first shape you filed in round 1, in the other walk. 0c25aef
  • this.tag was rejected for an inherited field while bare tag was not; getFields() is declared-only. 0c25aef

Also corrected: the javadoc said a .staticMethod() bean "cannot carry" an anonymous class (only one written directly in its body is rejected), and that the dump writes one file per host class (a group writes its own).

Not changed, and worth your view if you disagree: duplicate names across a group(...) boundary are not compared. Two @Bean methods of one name on two configuration classes is a loud BeanDefinitionOverrideException under Boot rather than the silent first-wins drop the check exists for, and a group carries the discriminating condition.

CI: the two reds are EndToEndSpec > async multiple levels of layouts and UserControllerSpec > User list, both on #16030 against 8.0.x itself at 2% and 3%.

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.

4 participants