jdaugherty commented on code in PR #16019:
URL: https://github.com/apache/grails-core/pull/16019#discussion_r3684785698


##########
grails-doc/src/en/guide/plugins/hookingIntoRuntimeConfiguration.adoc:
##########
@@ -119,6 +119,148 @@ class I18nGrailsPlugin extends Plugin {
 A plugin may override either `doWithSpring()` or `doWithSpring(BeanBuilder)`, 
but not both. The closure-returning `doWithSpring()` remains fully supported 
for backwards compatibility. Defining both forms in the same plugin is an 
error: the plugin will fail to load with an exception, since the two forms are 
alternatives and defining both indicates an incomplete migration between them. 
Consolidate the bean definitions into a single form.
 
 
+==== Compiling Bean Definitions into a Spring Boot AutoConfiguration
+
+
+`beanRegistrar()` and `doWithSpring` register beans at a single, fixed point 
in startup: before Spring Boot processes any 
`{springbootapi}org/springframework/boot/autoconfigure/AutoConfiguration.html[AutoConfiguration]`.
 That is enough for a bean to win against a Boot default guarded by 
`@ConditionalOnMissingBean`, but it cannot express ordering *relative to a 
specific* auto-configuration — running before `WebMvcAutoConfiguration` but 
after `MessageSourceAutoConfiguration`, say, regardless of where those two land 
in Boot's overall sort. Only a real `AutoConfiguration` class can declare that 
ordering, via `@AutoConfiguration(before = ..., after = ...)`.
+
+The link:{api}grails/compiler/beans/GrailsBeans.html[GrailsBeans] annotation 
lets you author bean definitions with DSL syntax similar to the classic Beans 
DSL, while compiling them at build time into genuine `@Bean` factory methods on 
a plain `AutoConfiguration` class — no closures, and nothing DSL-specific, 
survive into the compiled bytecode. Declare a class whose `beans` property is a 
closure of `bean(["name", ] Type) { ... }` statements:
+
+NOTE: On a `*GrailsPlugin.groovy` plugin descriptor or an application's 
`Application` class the annotation is implicit — a `beans` property is compiled 
automatically, the same way `doWithSpring` and `watchedResources` are 
conventions rather than annotated members. `@GrailsBeans` is written out only 
on a standalone class, as in the first example below. A class with no `beans` 
property is untouched.
+
+[source,groovy]
+----
+import grails.compiler.beans.GrailsBeans
+import org.springframework.boot.autoconfigure.AutoConfiguration
+import 
org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration
+import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration
+import org.springframework.context.MessageSource
+import 
org.springframework.context.support.ReloadableResourceBundleMessageSource
+import org.springframework.web.servlet.i18n.CookieLocaleResolver
+import org.springframework.web.servlet.i18n.LocaleChangeInterceptor
+
+@GrailsBeans
+@AutoConfiguration(before = [MessageSourceAutoConfiguration, 
WebMvcAutoConfiguration])
+class I18nBeans {
+
+    def beans = {
+        bean('messageSource', ReloadableResourceBundleMessageSource) {
+            new ReloadableResourceBundleMessageSource(basename: 
'WEB-INF/grails-app/i18n/messages')
+        }
+
+        bean('localeResolver', 
CookieLocaleResolver).conditionalOnMissingBean(CookieLocaleResolver) {
+            new CookieLocaleResolver('locale')
+        }
+
+        bean('localeChangeInterceptor', LocaleChangeInterceptor) { 
MessageSource messageSource ->
+            new LocaleChangeInterceptor(paramName: 'lang')
+        }
+    }
+}
+----
+
+Each `bean(["name", ] Type)` statement compiles to a public method returning 
the declared type and annotated `@Bean("name")`. The generated method's name is 
an implementation detail — Spring resolves the bean by its `@Bean("name")` 
value, never by the method name. It matches the bean name when that is a valid 
Java identifier not already taken by an existing member (declared, inherited, 
or defined elsewhere in the DSL — a bean named `toString` will not override 
`Object.toString()`); otherwise a synthesized `<type>$N` name (`service$0`, 
`service$1`, ...) is used instead, so any legal, non-blank Spring bean name 
compiles:
+
+* The bean name defaults to the decapitalized simple class name when omitted.
+* The factory closure itself may be omitted when a bean is nothing but its own 
no-argument construction: `bean(XmlDataBindingSourceCreator)` declares exactly 
what `bean(XmlDataBindingSourceCreator) { new XmlDataBindingSourceCreator() }` 
does, without restating the type twice. It takes an explicit name 
(`bean('xmlCreator', XmlDataBindingSourceCreator)`) and chains the qualifiers 
below just as the closure form does (`bean(Widget).primary().lazy()`). The 
declared type is the one constructed, so it must be concrete and have an 
accessible no-argument constructor; for an interface or abstract type, give the 
bean a body naming the implementation.
+* Leaving the closure's *body* empty means the same thing for a bean with 
dependencies: the parameters say what is injected, and the constructor call is 
generated. `bean('validateableConstraintsEvaluator', 
DefaultConstraintEvaluatorFactoryBean).lazy() { List<MessageSource> 
messageSources, @Qualifier('grailsDomainClassMappingContext') MappingContext 
mappingContext, GrailsApplication grailsApplication -> }` generates a method 
with exactly those three parameters whose body is `new 
DefaultConstraintEvaluatorFactoryBean(messageSources, mappingContext, 
grailsApplication)` — the parameters in the order written. Which constructor 
that selects is decided by the compiler from the parameter types, exactly as 
for a body you wrote yourself, so nothing here depends on the declared type's 
other constructors. A body is still needed whenever construction is more than 
passing the parameters straight through — `new 
ConfigProperties(grailsApplication.config)`, or anything followed by `.tap { }`.
+* The same bean name may be declared by more than one `bean(...)` statement — 
the standard Spring Boot pattern for mutually exclusive variants of one bean, 
such as the framework's own `grailsUrlConverter` selected by 
`@ConditionalOnProperty` — provided every declaration with the name carries its 
own discriminating condition (such as `.annotate(ConditionalOnProperty, ...)`), 
so that at most one of them registers at runtime. Duplicating a bean name 
without that is a compile-time error, since Spring would keep the first 
definition and silently skip the rest.
+* Chaining `.conditionalOnMissingBean(...)` adds a `@ConditionalOnMissingBean` 
annotation to the generated method, matching Spring Boot's usual back-off 
semantics. It takes positional types (`.conditionalOnMissingBean(Greeter)`), 
the annotation's own named attributes (`.conditionalOnMissingBean(name: 
'localeResolver', search: SearchStrategy.CURRENT)`), or both. With no arguments 
at all it compiles to the bare annotation, letting Spring Boot infer the 
back-off type from the method's return type — the recommended form when a bean 
simply backs off its own type, since 
`bean(AvailableLocaleResolver).conditionalOnMissingBean()` already says 
everything `.conditionalOnMissingBean(AvailableLocaleResolver)` would repeat.
+* Chaining `.conditionalOnMissingBeanName(...)` is the name-based counterpart: 
"register this bean unless a bean with *this bean's name* already exists". The 
condition's `name` member is set from the bean's own (explicit or 
convention-derived) name, so 
`bean(MessageSource).conditionalOnMissingBeanName(search: 
SearchStrategy.CURRENT) { ... }` states `messageSource` once and it feeds both 
`@Bean` and the condition — the two strings the explicit form has to keep in 
sync cannot diverge. It accepts the annotation's other attributes (`search:`, 
`ignored:`, ...) but rejects `name:` and types, which would contradict its 
purpose; use `.conditionalOnMissingBean(...)` for those.
+* Chaining `.primary()`, `.lazy()`, and/or `.scope("name")` adds `@Primary`, 
`@Lazy`, and `@Scope("name")` respectively.
+* Chaining `.staticMethod()` makes the generated factory method `static` — 
Spring's recommended shape for `BeanFactoryPostProcessor` and 
`BeanPostProcessor` beans, which must be creatable without instantiating their 
declaring configuration class. A static bean's closure cannot reference 
`field(...)` or `method(...)` members, since those are instance members; under 
`@CompileStatic` that mistake is a compile error.
+* Chaining `.annotate(AnnotationType[, attr: value, ...])` attaches any other 
single-valued annotation directly — any `@Conditional*` (built-in or a custom 
`Condition`), `@Order`, `@DependsOn`, or anything else a hand-written `@Bean` 
method could carry. It's repeatable, so several different annotations can be 
chained onto the same bean: `bean('special', Special).annotate(Order, value: 
1).annotate(ConditionalOnProperty, prefix: 'myapp', name: 'enabled', 
havingValue: 'true') { ... }`.
+* Any combination of the above can be chained together, in any order — for 
example `bean('slowGreeter', SlowGreeter).lazy().scope('prototype') { ... }`.
+* Typed closure parameters (`{ MessageSource messageSource -> ... }`) become 
the generated method's parameters, so other beans are injected the same way a 
hand-written `@Bean` method would declare them — and a parameter-level 
annotation such as `{ @Qualifier('special') Greeter g -> ... }` carries 
straight through too, since the closure's own parameters become the generated 
method's parameters directly.
+
+Two more statement kinds can appear inside `beans { }` alongside `bean(...)`, 
for state and logic shared across bean methods — the same role a hand-written 
`@Configuration` class's own fields and private methods play:
+
+* `field(["name", ] Type)`, optionally chained with `.value(...)` and/or 
(repeatably) `.annotate(AnnotationType[, attr: value, ...])`, declares a 
private field on the generated class. The usual case is injected configuration, 
and `.value(...)` covers it directly: `field('encoding', 
String).value('grails.views.gsp.encoding', 'UTF-8')` compiles to 
`@Value("${grails.views.gsp.encoding:UTF-8}")`. The two-argument form takes a 
config key and default — and because the DSL builds the placeholder itself, the 
key may be a bare constant reference (`.value(Settings.GSP_VIEW_ENCODING, 
'UTF-8')`), the one shape a directly-written annotation value can't accept. The 
one-argument form takes a bare config key with no default — 
`.value('grails.web.linkGenerator.useCache')` compiles to 
`@Value("${grails.web.linkGenerator.useCache}")` — while a string already 
containing a `${...}` placeholder or a `#{...}` SpEL expression passes through 
verbatim: `.value('#{T(java.lang.Runtime).getRuntime().avail
 ableProcessors()}')`.
+* `method(["name", ] Type) { ... }`, chainable with `.annotate(...)` only 
(`.value(...)` is field-specific), declares a private helper method the same 
way `bean(...)` declares a public one — typed closure parameters become method 
parameters, and the closure body becomes the method body.
+
+Both are ordinary private members of the generated class, so a `bean(...)` 
closure reads a `field(...)` or calls a `method(...)` exactly like a 
hand-written `@Bean` method would reference a sibling field or method on its 
own class:
+
+[source,groovy]
+----
+field('suffix', String).value('myapp.greeting-suffix', '!')
+
+method('buildGreeting', String) { String name ->
+    "Hello, ${name}${suffix}"
+}
+
+bean('greeting', Greeting) {
+    new Greeting(buildGreeting('World'))
+}
+----
+
+Because the result is an ordinary `AutoConfiguration` class, it must be 
registered the same way any Spring Boot auto-configuration is: listed in 
`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`.
 Modules built as part of this project can apply the 
`org.apache.grails.buildsrc.autoconfiguration-imports` Gradle convention plugin 
to generate that file automatically, by scanning the module's own compiled 
classes for `@AutoConfiguration` at build time. Projects outside this build 
register the class the same way any hand-written `AutoConfiguration` is 
registered — by listing it in that file directly.
+
+NOTE: `@GrailsBeans` does not require extending `Plugin` at all — it produces 
a plain Spring Boot `AutoConfiguration`, so it can be used by any Spring Boot 
module, not just a Grails plugin descriptor. Applications can use it too, 
including directly on the `Application` class — where no imports-file 
registration is needed, since Spring Boot processes the application class 
itself — see link:spring.html#springdslAdditional[Configuring Additional 
Beans]. Within a plugin, reach for it specifically when a bean's registration 
needs to be ordered against a particular other auto-configuration; otherwise 
`beanRegistrar()` remains the recommended way to register beans from a `Plugin`.
+
+The DSL still covers a narrower surface than `doWithSpring(BeanBuilder)`:
+
+* Each top-level statement inside `beans { }` must be exactly `bean(...)`, 
`field(...)`, or `method(...)`, with their respective chaining — an `if`, a 
`for` loop, or any other imperative Groovy directly inside `beans { }` is a 
compile-time error, not a runtime one. Note that this is about the DSL's own 
top-level statements, not the bodies of `bean(...)`/`method(...)` closures 
themselves, which accept arbitrary Groovy, subject only to what is reachable 
from the generated class (see the last bullet below) — and 
`field(...)`/`method(...)` already cover the common reasons to want shared 
state or logic in the first place.
+* `.annotate(...)` attribute values must be compile-time constants: strings, 
numbers, booleans, class literals, enum constants, or arrays of these. A bare 
constant *reference* counts, and so does a *concatenation* of them — 
`.annotate(Value, value: '${' + Settings.I18N_LOCALE_RESOLVER + ':session}')` 
folds, `@CompileStatic` included, the same way `.value(...)` folds its 
arguments. The one shape that does not fold is a `static final` declared on a 
Groovy *class*: that is a property with a generated getter, and 
`@CompileStatic` rewrites the reference into a call to it. An interface's 
constants are real `public static final` fields and fold correctly — which is 
every `grails.config.Settings` key. An attribute that itself takes a nested 
annotation as its value isn't supported.

Review Comment:
   The exception this bullet still carries is stale in the same way the 
concatenation one was, and for the same reason: `foldStringValue` resolves the 
value during canonicalization, before `@CompileStatic`'s transform runs, so 
there is no reference left for it to rewrite into a getter call.
   
   A `static final` on a Groovy *class* does fold. All four combinations, with 
`@CompileStatic` on the annotated class:
   
   | constant owner | `.value(KEY, 'x')` | `.annotate(Value, value: '${' + KEY 
+ ':y}')` |
   | --- | --- | --- |
   | same-compilation-unit Groovy class | `${probe.key:x}` | `${probe.key:y}` |
   | already-compiled Groovy class | `${precompiled.probe.key:x}` | 
`${precompiled.probe.key:y}` |
   
   A bare reference as the whole attribute value folds too — `.annotate(Value, 
value: PrecompiledKeys.KEY)` gives `precompiled.probe.key`.
   
   `findStaticFinalField` reads the `FieldNode`, and a Groovy property's 
backing field is still `static final` with its initial expression intact, so 
the property-versus-field distinction the bullet turns on no longer has any 
effect. As written it steers readers away from a shape that works — which is 
what the earlier version of this bullet did with `Settings`.



##########
grails-beans-dsl/src/main/java/org/grails/compiler/beans/GrailsBeansASTTransformation.java:
##########
@@ -0,0 +1,1354 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.compiler.beans;
+
+import java.beans.Introspector;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import javax.lang.model.SourceVersion;
+
+import groovy.transform.CompilationUnitAware;
+import groovy.transform.CompileStatic;
+import groovy.transform.TypeChecked;
+import org.apache.groovy.util.BeanUtils;
+import org.codehaus.groovy.GroovyBugError;
+import org.codehaus.groovy.ast.ASTNode;
+import org.codehaus.groovy.ast.AnnotatedNode;
+import org.codehaus.groovy.ast.AnnotationNode;
+import org.codehaus.groovy.ast.ClassHelper;
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.FieldNode;
+import org.codehaus.groovy.ast.MethodNode;
+import org.codehaus.groovy.ast.Parameter;
+import org.codehaus.groovy.ast.PropertyNode;
+import org.codehaus.groovy.ast.expr.ArgumentListExpression;
+import org.codehaus.groovy.ast.expr.BinaryExpression;
+import org.codehaus.groovy.ast.expr.ClassExpression;
+import org.codehaus.groovy.ast.expr.ClosureExpression;
+import org.codehaus.groovy.ast.expr.ConstantExpression;
+import org.codehaus.groovy.ast.expr.ConstructorCallExpression;
+import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.expr.ListExpression;
+import org.codehaus.groovy.ast.expr.MapEntryExpression;
+import org.codehaus.groovy.ast.expr.MapExpression;
+import org.codehaus.groovy.ast.expr.MethodCallExpression;
+import org.codehaus.groovy.ast.expr.PropertyExpression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
+import org.codehaus.groovy.ast.stmt.BlockStatement;
+import org.codehaus.groovy.ast.stmt.EmptyStatement;
+import org.codehaus.groovy.ast.stmt.ExpressionStatement;
+import org.codehaus.groovy.ast.stmt.ReturnStatement;
+import org.codehaus.groovy.ast.stmt.Statement;
+import org.codehaus.groovy.control.CompilationUnit;
+import org.codehaus.groovy.control.CompilePhase;
+import org.codehaus.groovy.control.SourceUnit;
+import org.codehaus.groovy.syntax.SyntaxException;
+import org.codehaus.groovy.syntax.Types;
+import org.codehaus.groovy.transform.ASTTransformation;
+import org.codehaus.groovy.transform.GroovyASTTransformation;
+import org.codehaus.groovy.transform.StaticTypesTransformation;
+import org.codehaus.groovy.transform.sc.StaticCompileTransformation;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfigureAfter;
+import org.springframework.boot.autoconfigure.AutoConfigureBefore;
+import org.springframework.boot.autoconfigure.AutoConfigureOrder;
+import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
+import 
org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import 
org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.ComponentScans;
+import org.springframework.context.annotation.Conditional;
+import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.ImportResource;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.context.annotation.Primary;
+import org.springframework.context.annotation.PropertySource;
+import org.springframework.context.annotation.PropertySources;
+import org.springframework.context.annotation.Scope;
+
+/**
+ * Rewrites the {@code beans} closure DSL on a {@link 
grails.compiler.beans.GrailsBeans}-annotated
+ * class into real {@code @Bean} factory methods, at compile time.
+ *
+ * <p>Recognises three kinds of top-level statement inside the {@code beans} 
closure:
+ * <ul>
+ * <li>{@code bean(["name", ] Type) { ... }}, optionally chained with any 
combination of
+ * {@code .conditionalOnMissingBean(...)} (positional types, named annotation 
attributes, or bare),
+ * {@code .conditionalOnMissingBeanName(...)} (backs off by this bean's own 
name, set
+ * automatically), {@code .primary()}, {@code .lazy()},
+ * {@code .scope("name")}, {@code .staticMethod()} (a static factory method, 
for
+ * {@code BeanFactoryPostProcessor}/{@code BeanPostProcessor} beans), and 
(repeatably)
+ * {@code .annotate(AnnotationType[, attr: value, ...])}. Synthesises a public 
method, returning
+ * the declared type, annotated {@code 
@org.springframework.context.annotation.Bean("name")} plus
+ * whichever qualifiers were chained. Its parameters are always the DSL 
closure's own, annotations
+ * included. Its body is the closure's, except where that body is empty - or 
the closure is omitted
+ * altogether - in which case a {@code new Type(...)} call over those same 
parameters is synthesised
+ * instead, leaving the compiler to select the constructor from their types 
exactly as it would for a
+ * body written out by hand. The generated method's name is an implementation 
detail: it matches the
+ * bean name when that is a usable Java identifier not already taken by an 
existing or generated
+ * member, and falls back to a synthesized {@code <type>$N} name otherwise (a 
non-identifier name
+ * like {@code "my-service"}, a reserved keyword, or a collision - a bean 
named {@code toString}
+ * never overrides {@code Object.toString()}) - Spring resolves the bean by 
its {@code @Bean("name")}
+ * value either way, never by the method name. One bean name may be declared 
by several
+ * {@code bean(...)} statements when every declaration carries its own 
discriminating condition
+ * (see {@link #validateSharedBeanNames}).</li>
+ * <li>{@code field(["name", ] Type)}, optionally chained with {@code 
.value(...)} (config
+ * injection: key + default, a bare key, or a verbatim placeholder/SpEL 
string) and/or (repeatably)
+ * {@code .annotate(AnnotationType[, attr: value, ...])}. Declares a private 
field on the
+ * generated class, for state shared across bean methods.</li>
+ * <li>{@code method(["name", ] Type) { ... }}, chainable with {@code 
.annotate(...)} only
+ * ({@code .value(...)} is field-specific).
+ * Declares a private helper method on the generated class, for logic shared 
across bean methods,
+ * lifted from the DSL closure the same way {@code bean(...)} is.</li>
+ * </ul>
+ *
+ * <p>Fields and helper methods declared this way are ordinary private members 
of the generated
+ * class - {@code bean(...)} closures reference them the same way a 
hand-written {@code @Bean}
+ * method would reference a sibling field or method on its {@code 
@Configuration} class. The
+ * {@code beans} property itself is removed so no closure survives into the 
compiled class.
+ *
+ * <p>When the annotated class extends {@code grails.plugins.Plugin}, the 
generated members land
+ * on a new sibling class in the same package instead of on the plugin class 
itself - named by
+ * swapping a {@code *GrailsPlugin} suffix for {@code AutoConfiguration}, or 
appending
+ * {@code AutoConfiguration} otherwise. A {@code Plugin} subclass is 
instantiated by
+ * {@code DefaultGrailsPlugin} via plain reflection, never as a Spring bean, 
so it cannot carry
+ * {@code @Bean} methods or a meaningful {@code @AutoConfiguration} annotation 
of its own.
+ * {@code @AutoConfiguration} and every annotation that gates or configures it 
- the
+ * {@code @Conditional*} family, {@code @Import}/{@code 
@ImportAutoConfiguration}/
+ * {@code @ImportResource}, {@code @ComponentScan}, {@code 
@EnableConfigurationProperties},
+ * {@code @PropertySource}/{@code @PropertySources}, and
+ * {@code @AutoConfigureOrder}/{@code Before}/{@code After} - found on the 
plugin class are moved
+ * onto the generated sibling, since that is the only place any of them has 
any effect; annotations
+ * outside that set can be named explicitly via {@code 
@GrailsBeans(moveAnnotations = ...)}. This lets a
+ * plugin author keep bean definitions in the familiar {@code 
*GrailsPlugin.groovy} file while
+ * everything else about the plugin class - {@code doWithApplicationContext}, 
{@code onChange},
+ * {@code watchedResources}, etc. - continues to work exactly as it does today.
+ *
+ * <p>{@code @CompileStatic}/{@code @GrailsCompileStatic} on the plugin class 
is propagated to the
+ * generated sibling. Since the sibling is created after Groovy schedules 
local annotation
+ * transforms, this transformation invokes Groovy's static-compilation 
transform directly after
+ * generating the sibling's members. This is the same approach used by other 
Grails AST transforms
+ * that generate code after local transform discovery.
+ */
+@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION)
+public class GrailsBeansASTTransformation implements ASTTransformation, 
CompilationUnitAware {
+
+    private static final String BEANS_PROPERTY = "beans";
+    private static final String BEAN_CALL = "bean";
+    private static final String FIELD_CALL = "field";
+    private static final String METHOD_CALL = "method";
+    private static final Set<String> ROOT_STATEMENT_CALL_NAMES = 
Set.of(BEAN_CALL, FIELD_CALL, METHOD_CALL);
+    private static final String CONDITIONAL_ON_MISSING_BEAN_CALL = 
"conditionalOnMissingBean";
+    private static final String CONDITIONAL_ON_MISSING_BEAN_NAME_CALL = 
"conditionalOnMissingBeanName";
+    private static final String PRIMARY_CALL = "primary";
+    private static final String LAZY_CALL = "lazy";
+    private static final String SCOPE_CALL = "scope";
+    private static final String STATIC_METHOD_CALL = "staticMethod";
+    private static final String ANNOTATE_CALL = "annotate";
+    private static final String VALUE_CALL = "value";
+    private static final Set<String> BEAN_QUALIFIER_CALL_NAMES = Set.of(
+            CONDITIONAL_ON_MISSING_BEAN_CALL, 
CONDITIONAL_ON_MISSING_BEAN_NAME_CALL,
+            PRIMARY_CALL, LAZY_CALL, SCOPE_CALL, STATIC_METHOD_CALL, 
ANNOTATE_CALL);
+    // field(...) and method(...) declare plain class members, not beans - 
bean-specific
+    // qualifiers don't apply; .value(...) (@Value config injection) is 
field-only.
+    private static final Set<String> FIELD_QUALIFIER_CALL_NAMES = 
Set.of(ANNOTATE_CALL, VALUE_CALL);
+    private static final Set<String> METHOD_QUALIFIER_CALL_NAMES = 
Set.of(ANNOTATE_CALL);
+    private static final Set<String> ALL_QUALIFIER_CALL_NAMES = Set.of(
+            CONDITIONAL_ON_MISSING_BEAN_CALL, 
CONDITIONAL_ON_MISSING_BEAN_NAME_CALL,
+            PRIMARY_CALL, LAZY_CALL, SCOPE_CALL, STATIC_METHOD_CALL, 
ANNOTATE_CALL, VALUE_CALL);
+    private static final String PLUGIN_SUPERCLASS_NAME = 
"grails.plugins.Plugin";
+    private static final String GRAILS_PLUGIN_SUFFIX = "GrailsPlugin";
+    private static final String AUTO_CONFIGURATION_SUFFIX = 
"AutoConfiguration";
+    private static final String AUTO_CONFIGURATION_NAME_MEMBER = 
"autoConfigurationName";
+    private static final String MOVE_ANNOTATIONS_MEMBER = "moveAnnotations";
+
+    private CompilationUnit compilationUnit;
+
+    @Override
+    public void setCompilationUnit(CompilationUnit compilationUnit) {
+        this.compilationUnit = compilationUnit;
+    }
+
+    @Override
+    public void visit(ASTNode[] nodes, SourceUnit source) {
+        AnnotationNode grailsBeansAnnotation = (AnnotationNode) nodes[0];
+        ClassNode classNode = (ClassNode) nodes[1];
+        PropertyNode beansProperty = classNode.getProperty(BEANS_PROPERTY);
+        if (beansProperty == null) {
+            addError(classNode, source, "@GrailsBeans requires a 'beans' 
property initialised to a closure");
+            return;
+        }
+
+        Expression initialExpression = beansProperty.getInitialExpression();
+        if (!(initialExpression instanceof ClosureExpression)) {
+            addError(beansProperty, source, "'beans' must be initialised to a 
closure, e.g. beans = { ... }");
+            return;
+        }
+
+        // An empty block is a no-op, not an error - an empty @Configuration 
class is legal in Spring
+        // and an empty resources.groovy is legal in Grails, so having nothing 
to declare should not
+        // fail the build. Returning before the sibling is created matters: 
generating it would leave
+        // a bean-less class holding the @AutoConfiguration and @Conditional* 
annotations moved off
+        // the plugin, which is worse than doing nothing. Only the DSL 
scaffolding is stripped.
+        List<Statement> statements = beanStatements((ClosureExpression) 
initialExpression);
+        if (statements.isEmpty()) {
+            removeBeansProperty(classNode, beansProperty);
+            return;
+        }
+
+        boolean isPlugin = extendsGrailsPlugin(classNode);
+        if (!isPlugin) {
+            for (String pluginOnlyMember : new String[] { 
AUTO_CONFIGURATION_NAME_MEMBER, MOVE_ANNOTATIONS_MEMBER }) {
+                if (grailsBeansAnnotation.getMember(pluginOnlyMember) != null) 
{
+                    addError(grailsBeansAnnotation, source, pluginOnlyMember + 
" has no effect here: it only applies " +
+                            "when @GrailsBeans is applied to a 
grails.plugins.Plugin subclass, where the compiled beans " +
+                            "land on a generated sibling class rather than on 
" + classNode.getNameWithoutPackage() + " itself");
+                }
+            }
+        }
+
+        ClassNode beanMethodHost = isPlugin ?
+                createAutoConfigurationSibling(classNode, 
grailsBeansAnnotation, source) : classNode;
+
+        Set<String> usedNames = existingMemberNames(beanMethodHost);
+        validateSharedBeanNames(statements, source);
+        // Two passes: field(...)/method(...) declare explicit member names, 
so they are processed
+        // first (along with anything malformed, so every statement is still 
processed exactly
+        // once) and bean(...) statements second. A bean's derived method name 
then adapts to every
+        // explicitly-named member wherever it appears in the block - 
reordering equivalent DSL
+        // statements must never change validity.
+        for (Statement statement : statements) {
+            if (!isBeanRootedStatement(statement)) {
+                processStatement(beanMethodHost, statement, source, usedNames);
+            }
+        }
+        for (Statement statement : statements) {
+            if (isBeanRootedStatement(statement)) {
+                processStatement(beanMethodHost, statement, source, usedNames);
+            }
+        }
+
+        if (beanMethodHost != classNode) {
+            applyStaticCompilation(classNode, beanMethodHost, source);
+        }
+
+        removeBeansProperty(classNode, beansProperty);
+    }
+
+    private void removeBeansProperty(ClassNode classNode, PropertyNode 
beansProperty) {
+        classNode.getProperties().remove(beansProperty);
+        // removeField, not getFields().remove: the latter leaves ClassNode's 
own fieldIndex entry
+        // behind, so another member still referring to 'beans' type-checks 
and compiles to a
+        // getfield against a field that is never emitted, failing with 
NoSuchFieldError at runtime.
+        classNode.removeField(BEANS_PROPERTY);
+    }
+
+    private boolean extendsGrailsPlugin(ClassNode classNode) {
+        for (ClassNode current = classNode.getSuperClass(); current != null; 
current = current.getSuperClass()) {
+            if (PLUGIN_SUPERCLASS_NAME.equals(current.getName())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    // Annotations that only make sense on whatever class Spring Boot actually 
evaluates as an
+    // auto-configuration - meaningless on a Plugin subclass, which is 
instantiated by
+    // DefaultGrailsPlugin via plain reflection and never processed by Spring 
as a bean. Matching
+    // is transitive through meta-annotations (see belongsOnSibling), so this 
list only needs the
+    // "root" annotations - a composed annotation built on top of any of these 
(e.g. a custom
+    // @ConditionalOnFeature meta-annotated with Spring Boot's own 
@ConditionalOnProperty, or a
+    // custom @EnableSomething meta-annotated with @Import) is found 
automatically.
+    private static final Set<String> SIBLING_ONLY_ANNOTATION_NAMES = Set.of(
+            AutoConfiguration.class.getName(), 
AutoConfigureOrder.class.getName(),
+            AutoConfigureBefore.class.getName(), 
AutoConfigureAfter.class.getName(),
+            Import.class.getName(), ImportAutoConfiguration.class.getName(), 
ImportResource.class.getName(),
+            ComponentScan.class.getName(), ComponentScans.class.getName(),
+            EnableConfigurationProperties.class.getName(),
+            PropertySource.class.getName(), PropertySources.class.getName(),
+            Conditional.class.getName());
+
+    private ClassNode createAutoConfigurationSibling(ClassNode pluginClass, 
AnnotationNode grailsBeansAnnotation, SourceUnit source) {
+        List<AnnotationNode> autoConfigurationAnnotations = 
pluginClass.getAnnotations(ClassHelper.make(AutoConfiguration.class));
+        if (autoConfigurationAnnotations.isEmpty()) {
+            addError(pluginClass, source, "A Plugin class using @GrailsBeans 
must also be annotated " +
+                    "@AutoConfiguration (even with no before=/after=) - 
otherwise the generated " +
+                    defaultSiblingSimpleName(pluginClass) +
+                    " class would never be processed by Spring Boot");
+        }
+
+        String siblingSimpleName = siblingSimpleName(pluginClass, 
grailsBeansAnnotation, source);
+        String packageName = pluginClass.getPackageName();
+        String siblingName = (packageName == null || packageName.isEmpty()) ?
+                siblingSimpleName : packageName + "." + siblingSimpleName;
+        ClassNode sibling = new ClassNode(siblingName, Modifier.PUBLIC, 
ClassHelper.OBJECT_TYPE);
+        // Without a position, anything Groovy later reports against a 
generated node - a sibling
+        // name clash, a typo in .annotate(...) - is reported at line -1, 
column -1.
+        sibling.setSourcePosition(pluginClass);
+        source.getAST().addClass(sibling);
+
+        // Matching annotations move entirely rather than being merely copied 
- they have no effect
+        // where the author wrote them (see SIBLING_ONLY_ANNOTATION_NAMES).
+        Set<String> moveAnnotationNames = 
parseMoveAnnotations(grailsBeansAnnotation, source);
+        List<AnnotationNode> siblingAnnotations = new ArrayList<>();
+        for (AnnotationNode annotation : pluginClass.getAnnotations()) {
+            if (belongsOnSibling(annotation.getClassNode(), 
moveAnnotationNames)) {
+                siblingAnnotations.add(annotation);
+            }
+        }
+        sibling.addAnnotations(siblingAnnotations);
+        pluginClass.getAnnotations().removeAll(siblingAnnotations);
+
+        return sibling;
+    }
+
+    private Set<String> parseMoveAnnotations(AnnotationNode 
grailsBeansAnnotation, SourceUnit source) {
+        Expression member = 
grailsBeansAnnotation.getMember(MOVE_ANNOTATIONS_MEMBER);
+        if (member == null) {
+            return Set.of();
+        }
+        List<Expression> entries = member instanceof ListExpression ?
+                ((ListExpression) member).getExpressions() : List.of(member);
+        Set<String> names = new HashSet<>();
+        for (Expression entry : entries) {
+            if (!(entry instanceof ClassExpression)) {
+                addError(entry, source, "moveAnnotations entries must be 
annotation class literals, " +
+                        "e.g. @GrailsBeans(moveAnnotations = 
[ComponentScan])");
+                continue;
+            }
+            ClassNode annotationType = ((ClassExpression) entry).getType();
+            if (!annotationType.isAnnotationDefinition()) {
+                addError(entry, source, "\"" + annotationType.getName() + "\" 
is not an annotation type");
+                continue;
+            }
+            names.add(annotationType.getName());
+        }
+        return names;
+    }
+
+    // A *GrailsPlugin name swaps that suffix for AutoConfiguration 
(I18nGrailsPlugin ->
+    // I18nAutoConfiguration - the name the hand-written class it replaces 
would have had);
+    // anything else appends AutoConfiguration.
+    private String defaultSiblingSimpleName(ClassNode pluginClass) {
+        String simpleName = pluginClass.getNameWithoutPackage();
+        if (simpleName.endsWith(GRAILS_PLUGIN_SUFFIX) && simpleName.length() > 
GRAILS_PLUGIN_SUFFIX.length()) {
+            return simpleName.substring(0, simpleName.length() - 
GRAILS_PLUGIN_SUFFIX.length()) + AUTO_CONFIGURATION_SUFFIX;
+        }
+        return simpleName + AUTO_CONFIGURATION_SUFFIX;
+    }
+
+    private String siblingSimpleName(ClassNode pluginClass, AnnotationNode 
grailsBeansAnnotation, SourceUnit source) {
+        String defaultName = defaultSiblingSimpleName(pluginClass);
+        Expression nameArg = 
grailsBeansAnnotation.getMember(AUTO_CONFIGURATION_NAME_MEMBER);
+        if (nameArg == null) {
+            return defaultName;
+        }
+        Object nameValue = nameArg instanceof ConstantExpression ? 
((ConstantExpression) nameArg).getValue() : null;
+        if (!(nameValue instanceof String)) {
+            addError(nameArg, source, "@GrailsBeans(autoConfigurationName = 
...) requires a String literal");
+            return defaultName;
+        }
+        String name = (String) nameValue;
+        if (name.isBlank()) {
+            addError(nameArg, source, "@GrailsBeans(autoConfigurationName = 
\"" + name + "\") must not be " +
+                    "blank - omit the attribute entirely to use the default " 
+ defaultName + " instead");
+            return defaultName;
+        }
+        if (!isValidJavaIdentifier(name)) {
+            addError(nameArg, source, "@GrailsBeans(autoConfigurationName = 
\"" + name + "\") is not a valid " +
+                    "name: it becomes the generated sibling's simple class 
name, so it must be a valid Java identifier");
+            return defaultName;
+        }
+        return name;
+    }
+
+    private boolean belongsOnSibling(ClassNode annotationType, Set<String> 
moveAnnotationNames) {
+        return belongsOnSibling(annotationType, moveAnnotationNames, new 
HashSet<>());
+    }
+
+    // Recurses through meta-annotations rather than checking only one level, 
since Spring's own
+    // composed-annotation convention is arbitrarily deep - e.g. a 
project-specific
+    // @ConditionalOnFeature is typically meta-annotated with an existing 
@ConditionalOnXxx (itself
+    // meta-annotated @Conditional), not with @Conditional directly. `visited` 
guards against cycles
+    // and, since every annotation type transitively reaches common JDK 
meta-annotations
+    // (@Retention, @Target, @Documented) from multiple paths, avoids 
redundant re-exploration.
+    private boolean belongsOnSibling(ClassNode annotationType, Set<String> 
moveAnnotationNames, Set<String> visited) {
+        if (!visited.add(annotationType.getName())) {
+            return false;
+        }
+        if (SIBLING_ONLY_ANNOTATION_NAMES.contains(annotationType.getName()) ||
+                moveAnnotationNames.contains(annotationType.getName())) {
+            return true;
+        }
+        for (AnnotationNode metaAnnotation : annotationType.getAnnotations()) {
+            if (belongsOnSibling(metaAnnotation.getClassNode(), 
moveAnnotationNames, visited)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    // The bean bodies are lifted onto a class the normal transform pipeline 
no longer visits, so
+    // whichever of these the author put on the plugin has to be re-applied 
here or the bodies are
+    // silently left unchecked. @GrailsCompileStatic and @GrailsTypeChecked 
need no handling of their
+    // own: @AnnotationCollector has already expanded them by canonicalization.
+    private void applyStaticCompilation(ClassNode pluginClass, ClassNode 
sibling, SourceUnit source) {
+        if (compilationUnit == null) {
+            return;
+        }
+        if (applyStaticTypesTransformation(pluginClass, sibling, source,
+                CompileStatic.class, new StaticCompileTransformation())) {
+            return;
+        }
+        applyStaticTypesTransformation(pluginClass, sibling, source,
+                TypeChecked.class, new StaticTypesTransformation());
+    }
+
+    private boolean applyStaticTypesTransformation(ClassNode pluginClass, 
ClassNode sibling, SourceUnit source,
+            Class<? extends java.lang.annotation.Annotation> annotationType, 
StaticTypesTransformation transformation) {
+        List<AnnotationNode> annotations = 
pluginClass.getAnnotations(ClassHelper.make(annotationType));
+        if (annotations.isEmpty()) {
+            return false;
+        }
+
+        AnnotationNode sourceAnnotation = annotations.get(0);
+        AnnotationNode siblingAnnotation = new 
AnnotationNode(ClassHelper.make(annotationType));
+        sourceAnnotation.getMembers().forEach(siblingAnnotation::setMember);
+        siblingAnnotation.setSourcePosition(sourceAnnotation);
+        sibling.addAnnotation(siblingAnnotation);
+
+        transformation.setCompilationUnit(compilationUnit);
+        transformation.visit(new ASTNode[] { siblingAnnotation, sibling }, 
source);
+        return true;
+    }
+
+    private List<Statement> beanStatements(ClosureExpression dsl) {
+        Statement code = dsl.getCode();
+        if (code instanceof BlockStatement) {
+            return ((BlockStatement) code).getStatements();
+        }
+        List<Statement> single = new ArrayList<>();
+        single.add(code);
+        return single;
+    }
+
+    // Generated names must not collide with anything the host class already 
has: its own fields
+    // and methods (in the standalone form the host is a real user-written 
class), every method
+    // inherited through its full type graph - superclasses and interfaces 
alike, since a bean
+    // named 'toString' or after an interface's default method must 
synthesize, not override -
+    // and the GroovyObject methods Groovy itself adds at class generation.
+    private Set<String> existingMemberNames(ClassNode host) {
+        Set<String> names = new HashSet<>();
+        for (FieldNode field : host.getFields()) {
+            names.add(field.getName());
+        }
+        Set<String> visited = new HashSet<>();
+        collectMethodNames(host, names, visited);
+        collectMethodNames(ClassHelper.GROOVY_OBJECT_TYPE, names, visited);
+        return names;
+    }
+
+    private void collectMethodNames(ClassNode type, Set<String> names, 
Set<String> visited) {
+        if (type == null || !visited.add(type.getName())) {
+            return;
+        }
+        for (MethodNode method : type.getMethods()) {
+            names.add(method.getName());
+        }
+        // A Groovy property's accessors are synthesized by the Verifier at 
class generation, AFTER
+        // this transform runs, so they are not in getMethods() yet - reserve 
the names they will
+        // occupy, or a same-named bean method would displace the real 
accessor.
+        for (PropertyNode property : type.getProperties()) {
+            String capitalized = BeanUtils.capitalize(property.getName());
+            names.add("get" + capitalized);
+            names.add("set" + capitalized);
+            if (ClassHelper.boolean_TYPE.equals(property.getType()) ||
+                    ClassHelper.Boolean_TYPE.equals(property.getType())) {
+                names.add("is" + capitalized);
+            }
+        }
+        collectMethodNames(type.getSuperClass(), names, visited);
+        for (ClassNode implemented : type.getInterfaces()) {
+            collectMethodNames(implemented, names, visited);
+        }
+    }
+
+    // Silent classification counterpart of processStatement's qualifier-chain 
walk: descends to
+    // the root call without reporting anything, so malformed statements are 
classified (not
+    // validated) here and still produce their usual errors when actually 
processed.
+    private boolean isBeanRootedStatement(Statement statement) {
+        if (!(statement instanceof ExpressionStatement) ||
+                !(((ExpressionStatement) statement).getExpression() instanceof 
MethodCallExpression)) {
+            return false;
+        }
+        MethodCallExpression call = (MethodCallExpression) 
((ExpressionStatement) statement).getExpression();
+        while (!ROOT_STATEMENT_CALL_NAMES.contains(call.getMethodAsString()) &&
+                call.getObjectExpression() instanceof MethodCallExpression) {
+            call = (MethodCallExpression) call.getObjectExpression();
+        }
+        return BEAN_CALL.equals(call.getMethodAsString());
+    }
+
+    // A Spring bean name may be declared by more than one bean(...) statement 
- the standard
+    // autoconfiguration pattern for mutually exclusive variants of one bean, 
e.g. Grails' two
+    // "grailsUrlConverter" beans selected by @ConditionalOnProperty - but 
only when every
+    // statement sharing the name carries a condition of its own that could 
discriminate between
+    // them at runtime. Without one, the duplicates can never all take effect 
(Spring keeps the
+    // first definition from a configuration class and silently skips the 
rest), so the likeliest
+    // explanation is a copy-paste accident - rejected at compile time 
instead. The shared-name
+    // back-off (.conditionalOnMissingBeanName(), or 
.conditionalOnMissingBean() with no
+    // arguments) does not count: it is identical on every duplicate by 
construction, so it can
+    // never tell them apart.
+    private void validateSharedBeanNames(List<Statement> statements, 
SourceUnit source) {
+        Map<String, List<BeanNameUse>> usesByName = new LinkedHashMap<>();
+        for (Statement statement : statements) {
+            if (!isBeanRootedStatement(statement)) {
+                continue;
+            }
+            BeanNameUse use = parseBeanNameUse(
+                    (MethodCallExpression) ((ExpressionStatement) 
statement).getExpression());
+            if (use != null) {
+                usesByName.computeIfAbsent(use.beanName, key -> new 
ArrayList<>()).add(use);
+            }
+        }
+        for (List<BeanNameUse> uses : usesByName.values()) {
+            if (uses.size() < 2) {
+                continue;
+            }
+            for (BeanNameUse use : uses) {
+                if (!use.conditioned) {
+                    addError(use.baseCall, source, "\"" + use.beanName + "\" 
is already used as the Spring " +
+                            "bean name of another bean(...) statement - 
declaring it more than once is only " +
+                            "allowed when every declaration with the name 
carries its own discriminating " +
+                            "condition (e.g. .annotate(ConditionalOnProperty, 
...)), so that at most one of " +
+                            "them registers at runtime");
+                }
+            }
+        }
+    }
+
+    private static final class BeanNameUse {
+        private final String beanName;
+        private final MethodCallExpression baseCall;
+        private final boolean conditioned;
+
+        BeanNameUse(String beanName, MethodCallExpression baseCall, boolean 
conditioned) {
+            this.beanName = beanName;
+            this.baseCall = baseCall;
+            this.conditioned = conditioned;
+        }
+    }
+
+    // Silent classification counterpart of processBeanStatement's parsing, in 
the same spirit as
+    // isBeanRootedStatement: extracts the bean name and whether the statement 
carries a
+    // discriminating condition, returning null for anything malformed - a 
malformed statement
+    // still produces its usual errors when actually processed.
+    private BeanNameUse parseBeanNameUse(MethodCallExpression outerCall) {
+        List<MethodCallExpression> qualifierCalls = new ArrayList<>();
+        MethodCallExpression baseCall = outerCall;
+        while 
(!ROOT_STATEMENT_CALL_NAMES.contains(baseCall.getMethodAsString())) {
+            if (!(baseCall.getObjectExpression() instanceof 
MethodCallExpression)) {
+                return null;
+            }
+            qualifierCalls.add(baseCall);
+            baseCall = (MethodCallExpression) baseCall.getObjectExpression();
+        }
+        if (!BEAN_CALL.equals(baseCall.getMethodAsString())) {
+            return null;
+        }
+
+        List<Expression> args = 
withoutTrailingClosure(flatten(baseCall.getArguments()), baseCall, outerCall);
+        if (args.isEmpty() || args.size() > 2 || !(args.get(args.size() - 1) 
instanceof ClassExpression)) {
+            return null;
+        }
+        String name;
+        if (args.size() == 1) {
+            name = decapitalize(((ClassExpression) 
args.get(0)).getType().getNameWithoutPackage());
+        }
+        else {
+            Object nameValue = args.get(0) instanceof ConstantExpression ?
+                    ((ConstantExpression) args.get(0)).getValue() : null;
+            if (!(nameValue instanceof String)) {
+                return null;
+            }
+            name = (String) nameValue;
+        }
+        return new BeanNameUse(name, baseCall, 
hasDiscriminatingCondition(qualifierCalls, outerCall));
+    }
+
+    private boolean hasDiscriminatingCondition(List<MethodCallExpression> 
qualifierCalls, MethodCallExpression outerCall) {
+        for (MethodCallExpression qualifierCall : qualifierCalls) {
+            String qualifierName = qualifierCall.getMethodAsString();
+            List<Expression> args = 
withoutTrailingClosure(flatten(qualifierCall.getArguments()), qualifierCall, 
outerCall);
+            if (CONDITIONAL_ON_MISSING_BEAN_CALL.equals(qualifierName) && 
discriminatesByType(args)) {
+                return true;
+            }
+            if (ANNOTATE_CALL.equals(qualifierName)) {
+                for (Expression arg : args) {
+                    if (arg instanceof ClassExpression && 
isConditionalAnnotation(((ClassExpression) arg).getType())) {
+                        return true;
+                    }
+                }
+            }
+        }
+        return false;
+    }
+
+    // @ConditionalOnMissingBean attributes that say nothing about a *type*. 
Where duplicates share
+    // one bean name - the only situation validateSharedBeanNames runs in - a 
name: or search: is
+    // identical on each of them and so can no more tell them apart than the 
bare form can.
+    private static final Set<String> 
NON_DISCRIMINATING_MISSING_BEAN_ATTRIBUTES = Set.of("name", "search");
+
+    private boolean discriminatesByType(List<Expression> args) {
+        boolean sawSomething = false;
+        for (Expression arg : args) {
+            if (arg instanceof ClassExpression) {
+                return true;
+            }
+            if (!(arg instanceof MapExpression)) {
+                // an argument shape this method does not model - stay lenient 
rather than reject
+                return true;
+            }
+            for (MapEntryExpression entry : ((MapExpression) 
arg).getMapEntryExpressions()) {
+                Object key = entry.getKeyExpression() instanceof 
ConstantExpression ?
+                        ((ConstantExpression) 
entry.getKeyExpression()).getValue() : null;
+                if (!(key instanceof String) || 
!NON_DISCRIMINATING_MISSING_BEAN_ATTRIBUTES.contains(key)) {
+                    return true;
+                }
+                sawSomething = true;
+            }
+        }
+        return !sawSomething && !args.isEmpty();
+    }
+
+    private List<Expression> withoutTrailingClosure(List<Expression> args, 
MethodCallExpression call,
+            MethodCallExpression outerCall) {
+        if (call == outerCall && !args.isEmpty() && args.get(args.size() - 1) 
instanceof ClosureExpression) {
+            return args.subList(0, args.size() - 1);
+        }
+        return args;
+    }
+
+    private boolean isConditionalAnnotation(ClassNode annotationType) {
+        return isConditionalAnnotation(annotationType, new HashSet<>());
+    }
+
+    // The same transitive meta-annotation walk belongsOnSibling does, against 
@Conditional alone:
+    // every @ConditionalOnXxx - Spring Boot's own and arbitrarily-deeply 
composed custom ones -
+    // eventually reaches @Conditional through its meta-annotations.
+    private boolean isConditionalAnnotation(ClassNode annotationType, 
Set<String> visited) {
+        if (!visited.add(annotationType.getName())) {
+            return false;
+        }
+        if (Conditional.class.getName().equals(annotationType.getName())) {
+            return true;
+        }
+        for (AnnotationNode metaAnnotation : annotationType.getAnnotations()) {
+            if (isConditionalAnnotation(metaAnnotation.getClassNode(), 
visited)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private void processStatement(ClassNode classNode, Statement statement, 
SourceUnit source, Set<String> usedNames) {
+        if (!(statement instanceof ExpressionStatement) ||
+                !(((ExpressionStatement) statement).getExpression() instanceof 
MethodCallExpression)) {
+            addError(statement, source, "Each 'beans' statement must be a 
bean(...), field(...), or method(...) call");
+            return;
+        }
+
+        MethodCallExpression outerCall = (MethodCallExpression) 
((ExpressionStatement) statement).getExpression();
+
+        // Walk from the outermost (last-written) call back to the 
bean(...)/field(...)/method(...)
+        // call at the root, collecting any chained qualifiers along the way.
+        List<MethodCallExpression> qualifierCalls = new ArrayList<>();
+        MethodCallExpression baseCall = outerCall;
+        while 
(!ROOT_STATEMENT_CALL_NAMES.contains(baseCall.getMethodAsString())) {
+            if 
(!ALL_QUALIFIER_CALL_NAMES.contains(baseCall.getMethodAsString()) ||
+                    !(baseCall.getObjectExpression() instanceof 
MethodCallExpression)) {
+                addError(statement, source, "Expected bean([\"name\", ] Type) 
{ ... }, field([\"name\", ] Type), " +
+                        "or method([\"name\", ] Type) { ... }, optionally 
chained with qualifiers");
+                return;
+            }
+            qualifierCalls.add(0, baseCall);
+            baseCall = (MethodCallExpression) baseCall.getObjectExpression();
+        }
+
+        // The walk above stops at the first root call name it meets, so 
without this a root
+        // statement chained onto another - field("suffix", 
String).bean("greeter", String) { } -
+        // parses as one statement and the left-hand declaration is silently 
dropped.
+        if (!baseCall.isImplicitThis() && baseCall.getObjectExpression() 
instanceof MethodCallExpression) {
+            addError(statement, source, baseCall.getMethodAsString() + "(...) 
cannot be chained onto " +
+                    ((MethodCallExpression) 
baseCall.getObjectExpression()).getMethodAsString() +
+                    "(...) - each bean(...), field(...) and method(...) 
declaration is its own statement");
+            return;
+        }
+
+        String rootName = baseCall.getMethodAsString();
+        boolean isBean = BEAN_CALL.equals(rootName);
+        Set<String> allowedQualifiers = isBean ? BEAN_QUALIFIER_CALL_NAMES :
+                FIELD_CALL.equals(rootName) ? FIELD_QUALIFIER_CALL_NAMES : 
METHOD_QUALIFIER_CALL_NAMES;
+        for (MethodCallExpression qualifierCall : qualifierCalls) {
+            if 
(!allowedQualifiers.contains(qualifierCall.getMethodAsString())) {
+                addError(qualifierCall, source, "." + 
qualifierCall.getMethodAsString() + "(...) cannot be " +
+                        "chained onto " + rootName + "(...)");
+                return;
+            }
+        }
+
+        // .annotate(...) is repeatable (once per distinct annotation type, 
enforced when the
+        // annotation is actually attached below); every other qualifier is 
single-use.
+        Set<String> seenQualifiers = new HashSet<>();
+        for (MethodCallExpression qualifierCall : qualifierCalls) {
+            String qualifierName = qualifierCall.getMethodAsString();
+            if (!ANNOTATE_CALL.equals(qualifierName) && 
!seenQualifiers.add(qualifierName)) {
+                addError(qualifierCall, source, "." + qualifierName + "(...) 
may only be chained once per " +
+                        rootName + "(...)");
+                return;
+            }
+        }
+
+        if (isBean) {
+            processBeanStatement(classNode, outerCall, baseCall, 
qualifierCalls, source, usedNames);
+        }
+        else if (FIELD_CALL.equals(rootName)) {
+            processFieldStatement(classNode, baseCall, qualifierCalls, source, 
usedNames);
+        }
+        else {
+            processMethodStatement(classNode, outerCall, baseCall, 
qualifierCalls, source, usedNames);
+        }
+    }
+
+    private boolean registerName(String name, ASTNode location, SourceUnit 
source, Set<String> usedNames, String errorSuffix) {
+        if (!usedNames.add(name)) {
+            addError(location, source, "\"" + name + "\" " + errorSuffix);
+            return false;
+        }
+        return true;
+    }
+
+    private void processBeanStatement(ClassNode classNode, 
MethodCallExpression outerCall, MethodCallExpression baseCall,
+            List<MethodCallExpression> qualifierCalls, SourceUnit source, 
Set<String> usedNames) {
+        // The factory closure is optional: bean(Type) with no body declares a 
bean that is just its
+        // own no-argument construction, which is by far the most common shape 
and reads as noise
+        // when spelled out as bean(Type) { new Type() }.
+        List<Expression> closureCallArgs = flatten(outerCall.getArguments());
+        ClosureExpression factory = !closureCallArgs.isEmpty() &&
+                closureCallArgs.get(closureCallArgs.size() - 1) instanceof 
ClosureExpression ?
+                (ClosureExpression) closureCallArgs.get(closureCallArgs.size() 
- 1) : null;
+
+        // When bean(...) is itself the outermost call (no qualifiers 
chained), it carries the
+        // trailing closure as its own last argument - exclude it before 
validating the [name, ] Type
+        // shape, since it was already validated above.
+        List<Expression> baseArgs = flatten(baseCall.getArguments());
+        if (factory != null && baseCall == outerCall && !baseArgs.isEmpty()) {
+            baseArgs = baseArgs.subList(0, baseArgs.size() - 1);
+        }
+
+        TypeAndName typeAndName = parseNameAndType(baseArgs, baseCall, source, 
BEAN_CALL, false);
+        if (typeAndName == null) {
+            return;
+        }
+
+        ClassNode beanType = typeAndName.type.getType();
+        // A closure whose body is empty declares construction too, from its 
own parameters: the
+        // parameters say what is injected, and the generated body is the 
constructor call the author
+        // would otherwise have written out. bean(Type) { } with no parameters 
is bean(Type).
+        boolean constructsDeclaredType = factory == null || 
isEmpty(factory.getCode());
+        if (constructsDeclaredType && (beanType.isInterface() || 
Modifier.isAbstract(beanType.getModifiers()))) {
+            addError(baseCall, source, "bean(" + 
beanType.getNameWithoutPackage() + ") with no factory closure body " +
+                    "constructs the declared type, which cannot be done for an 
interface or abstract class - " +
+                    "give it a body: bean(" + beanType.getNameWithoutPackage() 
+ ") { new SomeImplementation() }");
+            return;
+        }
+
+        // The method name is an implementation detail - Spring resolves the 
bean by its
+        // @Bean("name") value, never by the factory method's name - so a bean 
name that isn't a
+        // usable Java identifier, or is already taken by an existing member, 
synthesizes instead
+        // of erroring.
+        String javaMethodName = isValidJavaIdentifier(typeAndName.name) && 
!usedNames.contains(typeAndName.name) ?
+                typeAndName.name :
+                syntheticBeanMethodName(typeAndName.type.getType(), usedNames);
+        usedNames.add(javaMethodName);
+
+        Parameter[] beanParameters = factory == null || 
factory.getParameters() == null ?
+                Parameter.EMPTY_ARRAY : factory.getParameters();
+        Statement beanBody = constructsDeclaredType ?

Review Comment:
   The synthesized `ConstructorCallExpression` is the one generated node in 
this file with no `setSourcePosition(...)`, and it is the node most likely to 
be the subject of a compile error: the constructor is selected from the 
closure's parameter types, so a parameter list matching no constructor — after 
someone adds an argument to the type's constructor, say — is the ordinary 
failure mode of the empty-body form.
   
   Without a position the failure never reaches the error collector as a 
located message. Compiling the documented shape:
   
   ```groovy
   class NeedsString {
       NeedsString(String required) { }
   }
   
   @GrailsBeans
   @CompileStatic
   @AutoConfiguration
   class ProbeWrongParams {
       def beans = {
           bean(NeedsString) { Integer n ->
           }
       }
   }
   ```
   
   In a normal build (assertions off):
   
   ```
   org.codehaus.groovy.GroovyBugError: BUG! exception in phase 'class 
generation' in source unit '...' at line -1 column -1
   On receiver: NeedsString with message: <$constructor$> and arguments: (n)
   StaticTypesCallSiteWriter#makeCallSite should not have been called. Call 
site lacked method target for static compilation.
   Please try to create a simple example reproducing this error and file a bug 
report at https://issues.apache.org/jira/browse/GROOVY
   ```
   
   Under `-ea` — which is what Gradle's test workers run with, so it is what a 
plugin author sees from `./gradlew test` — it degrades further, to a bare 
`java.lang.AssertionError: null` out of 
`StaticCompilationVisitor.visitConstructorCallExpression`. The bodyless 
`bean(NeedsString)` spelling fails identically. Same failure class as the 
`getTypeClass()` one: an internal compiler error at line -1 in place of a 
located message, with an invitation to file a Groovy bug.
   
   The discrepancy is that the hand-written equivalent this is documented as 
compiling to is diagnosed properly:
   
   ```
   [Static type checking] - Cannot find matching constructor NeedsArgsHand(). 
Please check if the declared type is correct and if the method exists.
    @ line 15, column 43.
                  bean(NeedsArgsHand) { new NeedsArgsHand() }
                                        ^
   ```
   
   So "resolved from these argument types exactly as it would be for a 
hand-written `new Type(...)`" holds for resolution but not for what the author 
is told when it fails.
   
   Copying the position onto the construction closes it. Verified against the 
branch:
   
   ```java
   ConstructorCallExpression construction =
           new ConstructorCallExpression(beanType, 
constructorArguments(beanParameters));
   construction.setSourcePosition(baseCall);
   ```
   
   after which the same fixture reports
   
   ```
   [Static type checking] - Cannot find matching constructor 
NeedsString(java.lang.Integer). Please check if the declared type is correct 
and if the method exists.
    @ line 15, column 21.
                  bean(NeedsString) { Integer n ->
                  ^
   ```
   
   pointing at the `bean(...)` statement. A test would help: the spec has no 
case for a constructor mismatch in either the bodyless or the empty-body form, 
which is why this got through.



##########
grails-doc/src/en/guide/upgrading/upgrading80x.adoc:
##########
@@ -1600,7 +1600,19 @@ This was introduced during the Grails 7.1 plugin-loading 
changes and remains req
 
 | `grails-domain-class`
 | `ConstraintEvaluatorAdapter` and 
`GrailsDomainClassAutoConfiguration.constraintsEvaluator(...)`
-| Code that imported the auto-configuration method should use the current 
`validateableConstraintsEvaluator(...)` bean path and the datastore constraint 
evaluator APIs.
+| Code that imported the auto-configuration method should use the current 
`validateableConstraintsEvaluator(...)` bean path and the datastore constraint 
evaluator APIs. The auto-configuration itself is now named 
`org.grails.plugins.domain.DomainClassAutoConfiguration`, following the 
`*GrailsPlugin` -> `*AutoConfiguration` convention; update any 
`@EnableAutoConfiguration(exclude = ...)` or `spring.autoconfigure.exclude` 
entry that referenced the old name.
+
+| `grails-core`
+| `org.grails.plugins.core.CoreAutoConfiguration`
+| Now `org.grails.plugins.CoreAutoConfiguration`. The class is generated 
beside the plugin descriptor that declares its beans, and `CoreGrailsPlugin` 
lives in `org.grails.plugins`, so the simple name is unchanged but the package 
is not. Update any `@EnableAutoConfiguration(exclude = ...)`, 
`spring.autoconfigure.exclude`, or `@AutoConfigureAfter(name = ...)` entry 
naming the old package. Spring Boot only reports an invalid exclude for a class 
still on the classpath, so a stale entry is silently ignored rather than 
reported, and the beans it was meant to suppress are registered anyway.
+
+| `grails-cache`
+| `grails.plugin.cache.GrailsCacheAutoConfiguration`
+| Now `grails.plugin.cache.CacheAutoConfiguration`, following the 
`*GrailsPlugin` -> `*AutoConfiguration` convention. Update any 
`@EnableAutoConfiguration(exclude = ...)` or `spring.autoconfigure.exclude` 
entry that referenced the old name; the same silent-ignore applies as above.
+
+| `grails-databinding`
+| `org.grails.plugins.databinding.DataBindingConfiguration`
+| Now `org.grails.plugins.databinding.DataBindingAutoConfiguration`. It was 
registered as an auto-configuration while named only `*Configuration`; the name 
now says what it is. Update any `@EnableAutoConfiguration(exclude = ...)` or 
`spring.autoconfigure.exclude` entry that referenced the old name; the same 
silent-ignore applies as above.

Review Comment:
   These four rows cover every FQCN change, but not the one change in this PR 
that alters which beans a running application ends up with.
   
   The component-scan fix means an application whose working directory is not 
the project root — a systemd unit, a container with its own `WORKDIR`, an app 
server — starts registering `@Component`s under `grails.spring.bean.packages` 
that it silently did not register before. The PR description says so plainly 
("Applications that deploy this way will start getting beans they currently 
miss"); the guide says nothing.
   
   It arguably deserves a row here more than the renames do. A stale exclude 
entry leaves a name that no longer resolves, which someone can at least search 
for; this one changes the contents of a running context with nothing to grep 
for, and in the direction of *adding* beans — a duplicate registration or an 
unexpected `@Component` picking up a bean name is how it will present.
   
   Worth noting while you are here that `grails.spring.bean.packages` appears 
nowhere in `grails-doc` at all, so there is no existing page describing the 
property for this to cross-reference.



##########
grails-beans-dsl/src/main/java/grails/compiler/beans/GrailsBeans.java:
##########
@@ -0,0 +1,129 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package grails.compiler.beans;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.codehaus.groovy.transform.GroovyASTTransformationClass;
+
+/**
+ * Marks a class whose {@code beans} closure property is a bean-definition DSL 
that should be
+ * compiled into real {@code @Bean} factory methods, so the class can serve as 
a plain
+ * {@code @AutoConfiguration} with no closure DSL surviving into the compiled 
bytecode.
+ *
+ * <p>The annotated class must declare a {@code beans} property initialised to 
a closure whose
+ * statements are one of:
+ * <ul>
+ * <li>{@code bean(["name", ] Type) { ... }} - or {@code bean(["name", ] 
Type)} with no body at all,
+ * which declares a bean that is simply {@code new Type()}, the most common 
shape. A closure with
+ * parameters but an empty body means the same for a bean with dependencies: 
the parameters say what
+ * is injected and the constructor call is generated from them, in the order 
written, leaving the
+ * compiler to select the constructor from their types just as it would for a 
hand-written body. Both
+ * forms require a concrete type; an interface or abstract type needs a body 
naming the
+ * implementation. It chains with the qualifiers below exactly as the
+ * closure form does ({@code bean(Foo).lazy().conditionalOnMissingBean()}). 
The closure form may be
+ * chained with any combination of
+ * {@code .conditionalOnMissingBean(...)} (positional types, the annotation's 
own named
+ * attributes such as {@code name:}/{@code search:}, or no arguments at all to 
let Spring infer
+ * the back-off type from the return type), {@code 
.conditionalOnMissingBeanName(...)} (backs off
+ * by this bean's own name - set automatically, stated once - accepting the 
annotation's other
+ * attributes but rejecting {@code name:} and types), {@code .primary()}, 
{@code .lazy()},
+ * {@code .scope("name")}, {@code .staticMethod()} (a {@code static} factory 
method - Spring's
+ * recommended shape for {@code BeanFactoryPostProcessor}/{@code 
BeanPostProcessor} beans, which
+ * must be creatable without instantiating their declaring configuration 
class), and (repeatably)
+ * {@code .annotate(AnnotationType[, attr: value, ...])} - the last a generic 
escape hatch
+ * attaching any other single-valued annotation. The closure body becomes the 
generated method's
+ * body verbatim, and closure parameters become the generated method's 
parameters (for
+ * constructor-style bean injection). The generated method's name is an 
implementation detail:
+ * Spring resolves the bean by its {@code @Bean("name")} value, so a bean name 
that isn't a valid
+ * Java identifier (e.g. {@code "my-service"}) simply gets a synthesized 
{@code <type>$N} method
+ * name behind the scenes. The same bean name may even be declared by more 
than one
+ * {@code bean(...)} statement - the standard Spring Boot pattern for mutually 
exclusive variants
+ * of one bean - provided every declaration with the name carries its own 
discriminating
+ * condition (e.g. {@code .annotate(ConditionalOnProperty, ...)}) so that at 
most one of them
+ * registers at runtime.</li>
+ * <li>{@code field(["name", ] Type)}, optionally chained with {@code 
.value(...)} and/or
+ * (repeatably) {@code .annotate(AnnotationType[, attr: value, ...])}. 
Declares a private field on
+ * the generated class, for state shared across bean methods. The usual case 
is injected
+ * configuration: {@code field("encoding", 
String).value(Settings.GSP_VIEW_ENCODING, "UTF-8")}
+ * compiles to {@code @Value("${grails.views.gsp.encoding:UTF-8}")} - the 
two-argument form takes
+ * a config key (a literal or a bare constant reference) plus default. The 
one-argument form
+ * takes a bare config key with no default ({@code .value("app.encoding")} 
compiles to
+ * {@code @Value("${app.encoding}")}), while a string already containing a 
{@code ${...}}
+ * placeholder or {@code #{...}} SpEL expression passes through verbatim.</li>
+ * <li>{@code method(["name", ] Type) { ... }}, chainable with {@code 
.annotate(...)} only
+ * ({@code .value(...)} is field-specific).
+ * Declares a private helper method on the generated class, for logic shared 
across bean methods,
+ * lifted from the closure the same way {@code bean(...)} is.</li>
+ * </ul>
+ * When no name is given, one is derived from the type name following the 
JavaBeans convention
+ * ({@link java.beans.Introspector#decapitalize(String)}).
+ *
+ * <p>The generated methods work on any class Spring processes as a 
configuration source: a
+ * registered {@code @AutoConfiguration} or {@code @Configuration} class, or 
the Spring Boot
+ * application class itself (e.g. a Grails {@code Application} class) - Spring 
Boot reads
+ * {@code @Bean} methods directly off the class it is launched with, so no 
further registration
+ * is needed there.
+ *
+ * <p>May also be applied to a {@code grails.plugins.Plugin} subclass, letting 
bean definitions
+ * live in the familiar {@code *GrailsPlugin.groovy} file. In that case the 
generated methods land
+ * on a new sibling class instead, named by the plugin-descriptor convention - 
a {@code *GrailsPlugin}
+ * name swaps that suffix for {@code AutoConfiguration} ({@code 
I18nGrailsPlugin} ->
+ * {@code I18nAutoConfiguration}), any other name appends it - or {@link 
#autoConfigurationName}
+ * if given. A {@code Plugin} subclass is never processed by
+ * Spring as a bean, so {@code @AutoConfiguration} together with every 
annotation that gates or
+ * configures it (the {@code @Conditional*} family, {@code @Import}/{@code 
@ImportAutoConfiguration}/
+ * {@code @ImportResource}, {@code @ComponentScan}, {@code 
@EnableConfigurationProperties},
+ * {@code @PropertySource}/{@code @PropertySources},
+ * {@code @AutoConfigureOrder}/{@code Before}/{@code After} - including any 
composed annotation
+ * meta-annotated with one of these) found on the plugin class moves onto that 
sibling, since none
+ * of them has any effect where the author wrote them. Annotations outside 
that set can be moved
+ * explicitly via {@link #moveAnnotations}.
+ */
+@Retention(RetentionPolicy.RUNTIME)

Review Comment:
   Carrying this forward from the scope thread so it does not get lost with it, 
since that thread's actual question — one story for the nine declarations — is 
settled.
   
   Nothing reads `@GrailsBeans` at runtime. The transform consumes it at 
canonicalization, and it is not among the annotations moved to the generated 
sibling, so it stays on the source class where it is inert. 
`RetentionPolicy.CLASS` would leave every compile-time use working while 
keeping it out of the runtime image — which matters slightly more now that 
`grails-core` declares `grails-beans-dsl` as `api` and so puts it, and its own 
`spring-context`/`spring-boot-autoconfigure` `api` dependencies, on every 
application's runtime classpath.
   
   Not a blocker, and I am fine with either answer. If `RUNTIME` is deliberate 
— leaving room for a runtime consumer later, say — a line on the annotation 
saying so is enough to close it.



##########
grails-cache/src/main/groovy/grails/plugin/cache/CacheGrailsPlugin.groovy:
##########
@@ -22,16 +22,25 @@ package grails.plugin.cache
 import groovy.transform.CompileStatic
 import groovy.util.logging.Slf4j
 
-import org.springframework.beans.factory.BeanRegistrar
-import org.springframework.beans.factory.BeanRegistry
+import org.springframework.boot.autoconfigure.AutoConfiguration
+import 
org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty
 import org.springframework.cache.Cache
-import org.springframework.core.env.Environment
 
 import grails.plugins.Plugin
 import org.grails.plugin.cache.GrailsCacheManager
 
+/**
+ * Configures the cache plugin.
+ *
+ * <p>Every bean is contributed as auto-configuration so that one supplied by 
the application or
+ * another plugin — for example a cache-provider plugin's {@code 
grailsCacheManager} — makes the
+ * default back off cleanly instead of triggering a bean-definition override. 
The whole set is gated
+ * on {@code grails.cache.enabled}.</p>
+ */
 @Slf4j
 @CompileStatic
+@AutoConfiguration
+@ConditionalOnBooleanProperty(name = 'grails.cache.enabled', matchIfMissing = 
true)

Review Comment:
   `@ConditionalOnBean(CachePluginConfiguration)` came off in this conversion 
and nothing replaces it. The deleted `GrailsCacheAutoConfiguration` carried it 
with the reason spelled out:
   
   > Gated on the `CachePluginConfiguration` definition contributed by the 
cache plugin descriptor's registrar (which runs before auto-configuration 
conditions are evaluated), so the auto-configuration backs off entirely when 
the plugin is not active — e.g. the jar is on the classpath but the plugin is 
excluded — keeping it in lockstep with the descriptor.
   
   The gate could not survive verbatim: `grailsCacheConfiguration` is now 
declared in this same `beans` block rather than by the registrar, so it would 
be conditioning on a bean the same class contributes. But the condition it 
expressed has gone with it. With only `@ConditionalOnBooleanProperty(name = 
'grails.cache.enabled')` left, a build with `grails-cache` on the classpath but 
the plugin not loaded now gets `grailsCacheConfiguration`, 
`grailsCacheAdminService`, `customCacheKeyGenerator` and `grailsCacheManager` 
registered anyway — exactly the case the deleted comment named.
   
   Is that deliberate, i.e. is jar-on-the-classpath now sufficient for the 
cache beans, the way it is for an ordinary Boot starter? If so the class 
javadoc should say it: it explains the back-off design carefully but is silent 
on the lockstep that was dropped. If not, the descriptor no longer contributes 
anything for the generated configuration to gate on, so restoring it needs a 
different anchor — either way better decided explicitly than by omission.
   
   Two smaller notes on the same move. 
`environment.getProperty('grails.cache.enabled', Boolean, true)` and 
`@ConditionalOnBooleanProperty` do not agree on non-canonical values: Spring's 
`Boolean` conversion accepts `yes`/`on`/`1`, the condition matches only 
`true`/`false`, so `grails.cache.enabled=no` used to disable the plugin and now 
leaves it enabled. And the `log.warn('Cache plugin is disabled')` that 
accompanied the old gate is gone, so disabling it is now silent.



##########
grails-beans-dsl/src/main/java/org/grails/compiler/beans/GrailsBeansASTTransformation.java:
##########
@@ -0,0 +1,1354 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.compiler.beans;
+
+import java.beans.Introspector;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import javax.lang.model.SourceVersion;
+
+import groovy.transform.CompilationUnitAware;
+import groovy.transform.CompileStatic;
+import groovy.transform.TypeChecked;
+import org.apache.groovy.util.BeanUtils;
+import org.codehaus.groovy.GroovyBugError;
+import org.codehaus.groovy.ast.ASTNode;
+import org.codehaus.groovy.ast.AnnotatedNode;
+import org.codehaus.groovy.ast.AnnotationNode;
+import org.codehaus.groovy.ast.ClassHelper;
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.FieldNode;
+import org.codehaus.groovy.ast.MethodNode;
+import org.codehaus.groovy.ast.Parameter;
+import org.codehaus.groovy.ast.PropertyNode;
+import org.codehaus.groovy.ast.expr.ArgumentListExpression;
+import org.codehaus.groovy.ast.expr.BinaryExpression;
+import org.codehaus.groovy.ast.expr.ClassExpression;
+import org.codehaus.groovy.ast.expr.ClosureExpression;
+import org.codehaus.groovy.ast.expr.ConstantExpression;
+import org.codehaus.groovy.ast.expr.ConstructorCallExpression;
+import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.expr.ListExpression;
+import org.codehaus.groovy.ast.expr.MapEntryExpression;
+import org.codehaus.groovy.ast.expr.MapExpression;
+import org.codehaus.groovy.ast.expr.MethodCallExpression;
+import org.codehaus.groovy.ast.expr.PropertyExpression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
+import org.codehaus.groovy.ast.stmt.BlockStatement;
+import org.codehaus.groovy.ast.stmt.EmptyStatement;
+import org.codehaus.groovy.ast.stmt.ExpressionStatement;
+import org.codehaus.groovy.ast.stmt.ReturnStatement;
+import org.codehaus.groovy.ast.stmt.Statement;
+import org.codehaus.groovy.control.CompilationUnit;
+import org.codehaus.groovy.control.CompilePhase;
+import org.codehaus.groovy.control.SourceUnit;
+import org.codehaus.groovy.syntax.SyntaxException;
+import org.codehaus.groovy.syntax.Types;
+import org.codehaus.groovy.transform.ASTTransformation;
+import org.codehaus.groovy.transform.GroovyASTTransformation;
+import org.codehaus.groovy.transform.StaticTypesTransformation;
+import org.codehaus.groovy.transform.sc.StaticCompileTransformation;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfigureAfter;
+import org.springframework.boot.autoconfigure.AutoConfigureBefore;
+import org.springframework.boot.autoconfigure.AutoConfigureOrder;
+import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
+import 
org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import 
org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.ComponentScans;
+import org.springframework.context.annotation.Conditional;
+import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.ImportResource;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.context.annotation.Primary;
+import org.springframework.context.annotation.PropertySource;
+import org.springframework.context.annotation.PropertySources;
+import org.springframework.context.annotation.Scope;
+
+/**
+ * Rewrites the {@code beans} closure DSL on a {@link 
grails.compiler.beans.GrailsBeans}-annotated
+ * class into real {@code @Bean} factory methods, at compile time.
+ *
+ * <p>Recognises three kinds of top-level statement inside the {@code beans} 
closure:
+ * <ul>
+ * <li>{@code bean(["name", ] Type) { ... }}, optionally chained with any 
combination of
+ * {@code .conditionalOnMissingBean(...)} (positional types, named annotation 
attributes, or bare),
+ * {@code .conditionalOnMissingBeanName(...)} (backs off by this bean's own 
name, set
+ * automatically), {@code .primary()}, {@code .lazy()},
+ * {@code .scope("name")}, {@code .staticMethod()} (a static factory method, 
for
+ * {@code BeanFactoryPostProcessor}/{@code BeanPostProcessor} beans), and 
(repeatably)
+ * {@code .annotate(AnnotationType[, attr: value, ...])}. Synthesises a public 
method, returning
+ * the declared type, annotated {@code 
@org.springframework.context.annotation.Bean("name")} plus
+ * whichever qualifiers were chained. Its parameters are always the DSL 
closure's own, annotations
+ * included. Its body is the closure's, except where that body is empty - or 
the closure is omitted
+ * altogether - in which case a {@code new Type(...)} call over those same 
parameters is synthesised
+ * instead, leaving the compiler to select the constructor from their types 
exactly as it would for a
+ * body written out by hand. The generated method's name is an implementation 
detail: it matches the
+ * bean name when that is a usable Java identifier not already taken by an 
existing or generated
+ * member, and falls back to a synthesized {@code <type>$N} name otherwise (a 
non-identifier name
+ * like {@code "my-service"}, a reserved keyword, or a collision - a bean 
named {@code toString}
+ * never overrides {@code Object.toString()}) - Spring resolves the bean by 
its {@code @Bean("name")}
+ * value either way, never by the method name. One bean name may be declared 
by several
+ * {@code bean(...)} statements when every declaration carries its own 
discriminating condition
+ * (see {@link #validateSharedBeanNames}).</li>
+ * <li>{@code field(["name", ] Type)}, optionally chained with {@code 
.value(...)} (config
+ * injection: key + default, a bare key, or a verbatim placeholder/SpEL 
string) and/or (repeatably)
+ * {@code .annotate(AnnotationType[, attr: value, ...])}. Declares a private 
field on the
+ * generated class, for state shared across bean methods.</li>
+ * <li>{@code method(["name", ] Type) { ... }}, chainable with {@code 
.annotate(...)} only
+ * ({@code .value(...)} is field-specific).
+ * Declares a private helper method on the generated class, for logic shared 
across bean methods,
+ * lifted from the DSL closure the same way {@code bean(...)} is.</li>
+ * </ul>
+ *
+ * <p>Fields and helper methods declared this way are ordinary private members 
of the generated
+ * class - {@code bean(...)} closures reference them the same way a 
hand-written {@code @Bean}
+ * method would reference a sibling field or method on its {@code 
@Configuration} class. The
+ * {@code beans} property itself is removed so no closure survives into the 
compiled class.
+ *
+ * <p>When the annotated class extends {@code grails.plugins.Plugin}, the 
generated members land
+ * on a new sibling class in the same package instead of on the plugin class 
itself - named by
+ * swapping a {@code *GrailsPlugin} suffix for {@code AutoConfiguration}, or 
appending
+ * {@code AutoConfiguration} otherwise. A {@code Plugin} subclass is 
instantiated by
+ * {@code DefaultGrailsPlugin} via plain reflection, never as a Spring bean, 
so it cannot carry
+ * {@code @Bean} methods or a meaningful {@code @AutoConfiguration} annotation 
of its own.
+ * {@code @AutoConfiguration} and every annotation that gates or configures it 
- the
+ * {@code @Conditional*} family, {@code @Import}/{@code 
@ImportAutoConfiguration}/
+ * {@code @ImportResource}, {@code @ComponentScan}, {@code 
@EnableConfigurationProperties},
+ * {@code @PropertySource}/{@code @PropertySources}, and
+ * {@code @AutoConfigureOrder}/{@code Before}/{@code After} - found on the 
plugin class are moved
+ * onto the generated sibling, since that is the only place any of them has 
any effect; annotations
+ * outside that set can be named explicitly via {@code 
@GrailsBeans(moveAnnotations = ...)}. This lets a
+ * plugin author keep bean definitions in the familiar {@code 
*GrailsPlugin.groovy} file while
+ * everything else about the plugin class - {@code doWithApplicationContext}, 
{@code onChange},
+ * {@code watchedResources}, etc. - continues to work exactly as it does today.
+ *
+ * <p>{@code @CompileStatic}/{@code @GrailsCompileStatic} on the plugin class 
is propagated to the
+ * generated sibling. Since the sibling is created after Groovy schedules 
local annotation
+ * transforms, this transformation invokes Groovy's static-compilation 
transform directly after
+ * generating the sibling's members. This is the same approach used by other 
Grails AST transforms
+ * that generate code after local transform discovery.
+ */
+@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION)
+public class GrailsBeansASTTransformation implements ASTTransformation, 
CompilationUnitAware {
+
+    private static final String BEANS_PROPERTY = "beans";
+    private static final String BEAN_CALL = "bean";
+    private static final String FIELD_CALL = "field";
+    private static final String METHOD_CALL = "method";
+    private static final Set<String> ROOT_STATEMENT_CALL_NAMES = 
Set.of(BEAN_CALL, FIELD_CALL, METHOD_CALL);
+    private static final String CONDITIONAL_ON_MISSING_BEAN_CALL = 
"conditionalOnMissingBean";
+    private static final String CONDITIONAL_ON_MISSING_BEAN_NAME_CALL = 
"conditionalOnMissingBeanName";
+    private static final String PRIMARY_CALL = "primary";
+    private static final String LAZY_CALL = "lazy";
+    private static final String SCOPE_CALL = "scope";
+    private static final String STATIC_METHOD_CALL = "staticMethod";
+    private static final String ANNOTATE_CALL = "annotate";
+    private static final String VALUE_CALL = "value";
+    private static final Set<String> BEAN_QUALIFIER_CALL_NAMES = Set.of(
+            CONDITIONAL_ON_MISSING_BEAN_CALL, 
CONDITIONAL_ON_MISSING_BEAN_NAME_CALL,
+            PRIMARY_CALL, LAZY_CALL, SCOPE_CALL, STATIC_METHOD_CALL, 
ANNOTATE_CALL);
+    // field(...) and method(...) declare plain class members, not beans - 
bean-specific
+    // qualifiers don't apply; .value(...) (@Value config injection) is 
field-only.
+    private static final Set<String> FIELD_QUALIFIER_CALL_NAMES = 
Set.of(ANNOTATE_CALL, VALUE_CALL);
+    private static final Set<String> METHOD_QUALIFIER_CALL_NAMES = 
Set.of(ANNOTATE_CALL);
+    private static final Set<String> ALL_QUALIFIER_CALL_NAMES = Set.of(
+            CONDITIONAL_ON_MISSING_BEAN_CALL, 
CONDITIONAL_ON_MISSING_BEAN_NAME_CALL,
+            PRIMARY_CALL, LAZY_CALL, SCOPE_CALL, STATIC_METHOD_CALL, 
ANNOTATE_CALL, VALUE_CALL);
+    private static final String PLUGIN_SUPERCLASS_NAME = 
"grails.plugins.Plugin";
+    private static final String GRAILS_PLUGIN_SUFFIX = "GrailsPlugin";
+    private static final String AUTO_CONFIGURATION_SUFFIX = 
"AutoConfiguration";
+    private static final String AUTO_CONFIGURATION_NAME_MEMBER = 
"autoConfigurationName";
+    private static final String MOVE_ANNOTATIONS_MEMBER = "moveAnnotations";
+
+    private CompilationUnit compilationUnit;
+
+    @Override
+    public void setCompilationUnit(CompilationUnit compilationUnit) {
+        this.compilationUnit = compilationUnit;
+    }
+
+    @Override
+    public void visit(ASTNode[] nodes, SourceUnit source) {
+        AnnotationNode grailsBeansAnnotation = (AnnotationNode) nodes[0];
+        ClassNode classNode = (ClassNode) nodes[1];
+        PropertyNode beansProperty = classNode.getProperty(BEANS_PROPERTY);
+        if (beansProperty == null) {
+            addError(classNode, source, "@GrailsBeans requires a 'beans' 
property initialised to a closure");
+            return;
+        }
+
+        Expression initialExpression = beansProperty.getInitialExpression();
+        if (!(initialExpression instanceof ClosureExpression)) {
+            addError(beansProperty, source, "'beans' must be initialised to a 
closure, e.g. beans = { ... }");
+            return;
+        }
+
+        // An empty block is a no-op, not an error - an empty @Configuration 
class is legal in Spring
+        // and an empty resources.groovy is legal in Grails, so having nothing 
to declare should not
+        // fail the build. Returning before the sibling is created matters: 
generating it would leave
+        // a bean-less class holding the @AutoConfiguration and @Conditional* 
annotations moved off
+        // the plugin, which is worse than doing nothing. Only the DSL 
scaffolding is stripped.
+        List<Statement> statements = beanStatements((ClosureExpression) 
initialExpression);
+        if (statements.isEmpty()) {
+            removeBeansProperty(classNode, beansProperty);
+            return;
+        }
+
+        boolean isPlugin = extendsGrailsPlugin(classNode);
+        if (!isPlugin) {
+            for (String pluginOnlyMember : new String[] { 
AUTO_CONFIGURATION_NAME_MEMBER, MOVE_ANNOTATIONS_MEMBER }) {
+                if (grailsBeansAnnotation.getMember(pluginOnlyMember) != null) 
{
+                    addError(grailsBeansAnnotation, source, pluginOnlyMember + 
" has no effect here: it only applies " +
+                            "when @GrailsBeans is applied to a 
grails.plugins.Plugin subclass, where the compiled beans " +
+                            "land on a generated sibling class rather than on 
" + classNode.getNameWithoutPackage() + " itself");
+                }
+            }
+        }
+
+        ClassNode beanMethodHost = isPlugin ?
+                createAutoConfigurationSibling(classNode, 
grailsBeansAnnotation, source) : classNode;
+
+        Set<String> usedNames = existingMemberNames(beanMethodHost);
+        validateSharedBeanNames(statements, source);
+        // Two passes: field(...)/method(...) declare explicit member names, 
so they are processed
+        // first (along with anything malformed, so every statement is still 
processed exactly
+        // once) and bean(...) statements second. A bean's derived method name 
then adapts to every
+        // explicitly-named member wherever it appears in the block - 
reordering equivalent DSL
+        // statements must never change validity.
+        for (Statement statement : statements) {
+            if (!isBeanRootedStatement(statement)) {
+                processStatement(beanMethodHost, statement, source, usedNames);
+            }
+        }
+        for (Statement statement : statements) {
+            if (isBeanRootedStatement(statement)) {
+                processStatement(beanMethodHost, statement, source, usedNames);
+            }
+        }
+
+        if (beanMethodHost != classNode) {
+            applyStaticCompilation(classNode, beanMethodHost, source);
+        }
+
+        removeBeansProperty(classNode, beansProperty);
+    }
+
+    private void removeBeansProperty(ClassNode classNode, PropertyNode 
beansProperty) {
+        classNode.getProperties().remove(beansProperty);
+        // removeField, not getFields().remove: the latter leaves ClassNode's 
own fieldIndex entry
+        // behind, so another member still referring to 'beans' type-checks 
and compiles to a
+        // getfield against a field that is never emitted, failing with 
NoSuchFieldError at runtime.
+        classNode.removeField(BEANS_PROPERTY);
+    }
+
+    private boolean extendsGrailsPlugin(ClassNode classNode) {
+        for (ClassNode current = classNode.getSuperClass(); current != null; 
current = current.getSuperClass()) {
+            if (PLUGIN_SUPERCLASS_NAME.equals(current.getName())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    // Annotations that only make sense on whatever class Spring Boot actually 
evaluates as an
+    // auto-configuration - meaningless on a Plugin subclass, which is 
instantiated by
+    // DefaultGrailsPlugin via plain reflection and never processed by Spring 
as a bean. Matching
+    // is transitive through meta-annotations (see belongsOnSibling), so this 
list only needs the
+    // "root" annotations - a composed annotation built on top of any of these 
(e.g. a custom
+    // @ConditionalOnFeature meta-annotated with Spring Boot's own 
@ConditionalOnProperty, or a
+    // custom @EnableSomething meta-annotated with @Import) is found 
automatically.
+    private static final Set<String> SIBLING_ONLY_ANNOTATION_NAMES = Set.of(
+            AutoConfiguration.class.getName(), 
AutoConfigureOrder.class.getName(),
+            AutoConfigureBefore.class.getName(), 
AutoConfigureAfter.class.getName(),
+            Import.class.getName(), ImportAutoConfiguration.class.getName(), 
ImportResource.class.getName(),
+            ComponentScan.class.getName(), ComponentScans.class.getName(),
+            EnableConfigurationProperties.class.getName(),
+            PropertySource.class.getName(), PropertySources.class.getName(),
+            Conditional.class.getName());
+
+    private ClassNode createAutoConfigurationSibling(ClassNode pluginClass, 
AnnotationNode grailsBeansAnnotation, SourceUnit source) {
+        List<AnnotationNode> autoConfigurationAnnotations = 
pluginClass.getAnnotations(ClassHelper.make(AutoConfiguration.class));
+        if (autoConfigurationAnnotations.isEmpty()) {
+            addError(pluginClass, source, "A Plugin class using @GrailsBeans 
must also be annotated " +
+                    "@AutoConfiguration (even with no before=/after=) - 
otherwise the generated " +
+                    defaultSiblingSimpleName(pluginClass) +
+                    " class would never be processed by Spring Boot");
+        }
+
+        String siblingSimpleName = siblingSimpleName(pluginClass, 
grailsBeansAnnotation, source);
+        String packageName = pluginClass.getPackageName();
+        String siblingName = (packageName == null || packageName.isEmpty()) ?
+                siblingSimpleName : packageName + "." + siblingSimpleName;
+        ClassNode sibling = new ClassNode(siblingName, Modifier.PUBLIC, 
ClassHelper.OBJECT_TYPE);
+        // Without a position, anything Groovy later reports against a 
generated node - a sibling
+        // name clash, a typo in .annotate(...) - is reported at line -1, 
column -1.
+        sibling.setSourcePosition(pluginClass);
+        source.getAST().addClass(sibling);
+
+        // Matching annotations move entirely rather than being merely copied 
- they have no effect
+        // where the author wrote them (see SIBLING_ONLY_ANNOTATION_NAMES).
+        Set<String> moveAnnotationNames = 
parseMoveAnnotations(grailsBeansAnnotation, source);
+        List<AnnotationNode> siblingAnnotations = new ArrayList<>();
+        for (AnnotationNode annotation : pluginClass.getAnnotations()) {
+            if (belongsOnSibling(annotation.getClassNode(), 
moveAnnotationNames)) {
+                siblingAnnotations.add(annotation);
+            }
+        }
+        sibling.addAnnotations(siblingAnnotations);
+        pluginClass.getAnnotations().removeAll(siblingAnnotations);
+
+        return sibling;
+    }
+
+    private Set<String> parseMoveAnnotations(AnnotationNode 
grailsBeansAnnotation, SourceUnit source) {
+        Expression member = 
grailsBeansAnnotation.getMember(MOVE_ANNOTATIONS_MEMBER);
+        if (member == null) {
+            return Set.of();
+        }
+        List<Expression> entries = member instanceof ListExpression ?
+                ((ListExpression) member).getExpressions() : List.of(member);
+        Set<String> names = new HashSet<>();
+        for (Expression entry : entries) {
+            if (!(entry instanceof ClassExpression)) {
+                addError(entry, source, "moveAnnotations entries must be 
annotation class literals, " +
+                        "e.g. @GrailsBeans(moveAnnotations = 
[ComponentScan])");
+                continue;
+            }
+            ClassNode annotationType = ((ClassExpression) entry).getType();
+            if (!annotationType.isAnnotationDefinition()) {
+                addError(entry, source, "\"" + annotationType.getName() + "\" 
is not an annotation type");
+                continue;
+            }
+            names.add(annotationType.getName());
+        }
+        return names;
+    }
+
+    // A *GrailsPlugin name swaps that suffix for AutoConfiguration 
(I18nGrailsPlugin ->
+    // I18nAutoConfiguration - the name the hand-written class it replaces 
would have had);
+    // anything else appends AutoConfiguration.
+    private String defaultSiblingSimpleName(ClassNode pluginClass) {
+        String simpleName = pluginClass.getNameWithoutPackage();
+        if (simpleName.endsWith(GRAILS_PLUGIN_SUFFIX) && simpleName.length() > 
GRAILS_PLUGIN_SUFFIX.length()) {
+            return simpleName.substring(0, simpleName.length() - 
GRAILS_PLUGIN_SUFFIX.length()) + AUTO_CONFIGURATION_SUFFIX;
+        }
+        return simpleName + AUTO_CONFIGURATION_SUFFIX;
+    }
+
+    private String siblingSimpleName(ClassNode pluginClass, AnnotationNode 
grailsBeansAnnotation, SourceUnit source) {
+        String defaultName = defaultSiblingSimpleName(pluginClass);
+        Expression nameArg = 
grailsBeansAnnotation.getMember(AUTO_CONFIGURATION_NAME_MEMBER);
+        if (nameArg == null) {
+            return defaultName;
+        }
+        Object nameValue = nameArg instanceof ConstantExpression ? 
((ConstantExpression) nameArg).getValue() : null;
+        if (!(nameValue instanceof String)) {
+            addError(nameArg, source, "@GrailsBeans(autoConfigurationName = 
...) requires a String literal");
+            return defaultName;
+        }
+        String name = (String) nameValue;
+        if (name.isBlank()) {
+            addError(nameArg, source, "@GrailsBeans(autoConfigurationName = 
\"" + name + "\") must not be " +
+                    "blank - omit the attribute entirely to use the default " 
+ defaultName + " instead");
+            return defaultName;
+        }
+        if (!isValidJavaIdentifier(name)) {
+            addError(nameArg, source, "@GrailsBeans(autoConfigurationName = 
\"" + name + "\") is not a valid " +
+                    "name: it becomes the generated sibling's simple class 
name, so it must be a valid Java identifier");
+            return defaultName;
+        }
+        return name;
+    }
+
+    private boolean belongsOnSibling(ClassNode annotationType, Set<String> 
moveAnnotationNames) {
+        return belongsOnSibling(annotationType, moveAnnotationNames, new 
HashSet<>());
+    }
+
+    // Recurses through meta-annotations rather than checking only one level, 
since Spring's own
+    // composed-annotation convention is arbitrarily deep - e.g. a 
project-specific
+    // @ConditionalOnFeature is typically meta-annotated with an existing 
@ConditionalOnXxx (itself
+    // meta-annotated @Conditional), not with @Conditional directly. `visited` 
guards against cycles
+    // and, since every annotation type transitively reaches common JDK 
meta-annotations
+    // (@Retention, @Target, @Documented) from multiple paths, avoids 
redundant re-exploration.
+    private boolean belongsOnSibling(ClassNode annotationType, Set<String> 
moveAnnotationNames, Set<String> visited) {
+        if (!visited.add(annotationType.getName())) {
+            return false;
+        }
+        if (SIBLING_ONLY_ANNOTATION_NAMES.contains(annotationType.getName()) ||
+                moveAnnotationNames.contains(annotationType.getName())) {
+            return true;
+        }
+        for (AnnotationNode metaAnnotation : annotationType.getAnnotations()) {
+            if (belongsOnSibling(metaAnnotation.getClassNode(), 
moveAnnotationNames, visited)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    // The bean bodies are lifted onto a class the normal transform pipeline 
no longer visits, so
+    // whichever of these the author put on the plugin has to be re-applied 
here or the bodies are
+    // silently left unchecked. @GrailsCompileStatic and @GrailsTypeChecked 
need no handling of their
+    // own: @AnnotationCollector has already expanded them by canonicalization.
+    private void applyStaticCompilation(ClassNode pluginClass, ClassNode 
sibling, SourceUnit source) {
+        if (compilationUnit == null) {
+            return;
+        }
+        if (applyStaticTypesTransformation(pluginClass, sibling, source,
+                CompileStatic.class, new StaticCompileTransformation())) {
+            return;
+        }
+        applyStaticTypesTransformation(pluginClass, sibling, source,
+                TypeChecked.class, new StaticTypesTransformation());
+    }
+
+    private boolean applyStaticTypesTransformation(ClassNode pluginClass, 
ClassNode sibling, SourceUnit source,
+            Class<? extends java.lang.annotation.Annotation> annotationType, 
StaticTypesTransformation transformation) {
+        List<AnnotationNode> annotations = 
pluginClass.getAnnotations(ClassHelper.make(annotationType));
+        if (annotations.isEmpty()) {
+            return false;
+        }
+
+        AnnotationNode sourceAnnotation = annotations.get(0);
+        AnnotationNode siblingAnnotation = new 
AnnotationNode(ClassHelper.make(annotationType));
+        sourceAnnotation.getMembers().forEach(siblingAnnotation::setMember);
+        siblingAnnotation.setSourcePosition(sourceAnnotation);
+        sibling.addAnnotation(siblingAnnotation);
+
+        transformation.setCompilationUnit(compilationUnit);
+        transformation.visit(new ASTNode[] { siblingAnnotation, sibling }, 
source);
+        return true;
+    }
+
+    private List<Statement> beanStatements(ClosureExpression dsl) {
+        Statement code = dsl.getCode();
+        if (code instanceof BlockStatement) {
+            return ((BlockStatement) code).getStatements();
+        }
+        List<Statement> single = new ArrayList<>();
+        single.add(code);
+        return single;
+    }
+
+    // Generated names must not collide with anything the host class already 
has: its own fields
+    // and methods (in the standalone form the host is a real user-written 
class), every method
+    // inherited through its full type graph - superclasses and interfaces 
alike, since a bean
+    // named 'toString' or after an interface's default method must 
synthesize, not override -
+    // and the GroovyObject methods Groovy itself adds at class generation.
+    private Set<String> existingMemberNames(ClassNode host) {
+        Set<String> names = new HashSet<>();
+        for (FieldNode field : host.getFields()) {
+            names.add(field.getName());
+        }
+        Set<String> visited = new HashSet<>();
+        collectMethodNames(host, names, visited);
+        collectMethodNames(ClassHelper.GROOVY_OBJECT_TYPE, names, visited);
+        return names;
+    }
+
+    private void collectMethodNames(ClassNode type, Set<String> names, 
Set<String> visited) {
+        if (type == null || !visited.add(type.getName())) {
+            return;
+        }
+        for (MethodNode method : type.getMethods()) {
+            names.add(method.getName());
+        }
+        // A Groovy property's accessors are synthesized by the Verifier at 
class generation, AFTER
+        // this transform runs, so they are not in getMethods() yet - reserve 
the names they will
+        // occupy, or a same-named bean method would displace the real 
accessor.
+        for (PropertyNode property : type.getProperties()) {
+            String capitalized = BeanUtils.capitalize(property.getName());
+            names.add("get" + capitalized);
+            names.add("set" + capitalized);
+            if (ClassHelper.boolean_TYPE.equals(property.getType()) ||
+                    ClassHelper.Boolean_TYPE.equals(property.getType())) {
+                names.add("is" + capitalized);
+            }
+        }
+        collectMethodNames(type.getSuperClass(), names, visited);
+        for (ClassNode implemented : type.getInterfaces()) {
+            collectMethodNames(implemented, names, visited);
+        }
+    }
+
+    // Silent classification counterpart of processStatement's qualifier-chain 
walk: descends to
+    // the root call without reporting anything, so malformed statements are 
classified (not
+    // validated) here and still produce their usual errors when actually 
processed.
+    private boolean isBeanRootedStatement(Statement statement) {
+        if (!(statement instanceof ExpressionStatement) ||
+                !(((ExpressionStatement) statement).getExpression() instanceof 
MethodCallExpression)) {
+            return false;
+        }
+        MethodCallExpression call = (MethodCallExpression) 
((ExpressionStatement) statement).getExpression();
+        while (!ROOT_STATEMENT_CALL_NAMES.contains(call.getMethodAsString()) &&
+                call.getObjectExpression() instanceof MethodCallExpression) {
+            call = (MethodCallExpression) call.getObjectExpression();
+        }
+        return BEAN_CALL.equals(call.getMethodAsString());
+    }
+
+    // A Spring bean name may be declared by more than one bean(...) statement 
- the standard
+    // autoconfiguration pattern for mutually exclusive variants of one bean, 
e.g. Grails' two
+    // "grailsUrlConverter" beans selected by @ConditionalOnProperty - but 
only when every
+    // statement sharing the name carries a condition of its own that could 
discriminate between
+    // them at runtime. Without one, the duplicates can never all take effect 
(Spring keeps the
+    // first definition from a configuration class and silently skips the 
rest), so the likeliest
+    // explanation is a copy-paste accident - rejected at compile time 
instead. The shared-name
+    // back-off (.conditionalOnMissingBeanName(), or 
.conditionalOnMissingBean() with no
+    // arguments) does not count: it is identical on every duplicate by 
construction, so it can
+    // never tell them apart.
+    private void validateSharedBeanNames(List<Statement> statements, 
SourceUnit source) {
+        Map<String, List<BeanNameUse>> usesByName = new LinkedHashMap<>();
+        for (Statement statement : statements) {
+            if (!isBeanRootedStatement(statement)) {
+                continue;
+            }
+            BeanNameUse use = parseBeanNameUse(
+                    (MethodCallExpression) ((ExpressionStatement) 
statement).getExpression());
+            if (use != null) {
+                usesByName.computeIfAbsent(use.beanName, key -> new 
ArrayList<>()).add(use);
+            }
+        }
+        for (List<BeanNameUse> uses : usesByName.values()) {
+            if (uses.size() < 2) {
+                continue;
+            }
+            for (BeanNameUse use : uses) {
+                if (!use.conditioned) {
+                    addError(use.baseCall, source, "\"" + use.beanName + "\" 
is already used as the Spring " +
+                            "bean name of another bean(...) statement - 
declaring it more than once is only " +
+                            "allowed when every declaration with the name 
carries its own discriminating " +
+                            "condition (e.g. .annotate(ConditionalOnProperty, 
...)), so that at most one of " +
+                            "them registers at runtime");
+                }
+            }
+        }
+    }
+
+    private static final class BeanNameUse {
+        private final String beanName;
+        private final MethodCallExpression baseCall;
+        private final boolean conditioned;
+
+        BeanNameUse(String beanName, MethodCallExpression baseCall, boolean 
conditioned) {
+            this.beanName = beanName;
+            this.baseCall = baseCall;
+            this.conditioned = conditioned;
+        }
+    }
+
+    // Silent classification counterpart of processBeanStatement's parsing, in 
the same spirit as
+    // isBeanRootedStatement: extracts the bean name and whether the statement 
carries a
+    // discriminating condition, returning null for anything malformed - a 
malformed statement
+    // still produces its usual errors when actually processed.
+    private BeanNameUse parseBeanNameUse(MethodCallExpression outerCall) {
+        List<MethodCallExpression> qualifierCalls = new ArrayList<>();
+        MethodCallExpression baseCall = outerCall;
+        while 
(!ROOT_STATEMENT_CALL_NAMES.contains(baseCall.getMethodAsString())) {
+            if (!(baseCall.getObjectExpression() instanceof 
MethodCallExpression)) {
+                return null;
+            }
+            qualifierCalls.add(baseCall);
+            baseCall = (MethodCallExpression) baseCall.getObjectExpression();
+        }
+        if (!BEAN_CALL.equals(baseCall.getMethodAsString())) {
+            return null;
+        }
+
+        List<Expression> args = 
withoutTrailingClosure(flatten(baseCall.getArguments()), baseCall, outerCall);
+        if (args.isEmpty() || args.size() > 2 || !(args.get(args.size() - 1) 
instanceof ClassExpression)) {
+            return null;
+        }
+        String name;
+        if (args.size() == 1) {
+            name = decapitalize(((ClassExpression) 
args.get(0)).getType().getNameWithoutPackage());
+        }
+        else {
+            Object nameValue = args.get(0) instanceof ConstantExpression ?
+                    ((ConstantExpression) args.get(0)).getValue() : null;
+            if (!(nameValue instanceof String)) {
+                return null;
+            }
+            name = (String) nameValue;
+        }
+        return new BeanNameUse(name, baseCall, 
hasDiscriminatingCondition(qualifierCalls, outerCall));
+    }
+
+    private boolean hasDiscriminatingCondition(List<MethodCallExpression> 
qualifierCalls, MethodCallExpression outerCall) {
+        for (MethodCallExpression qualifierCall : qualifierCalls) {
+            String qualifierName = qualifierCall.getMethodAsString();
+            List<Expression> args = 
withoutTrailingClosure(flatten(qualifierCall.getArguments()), qualifierCall, 
outerCall);
+            if (CONDITIONAL_ON_MISSING_BEAN_CALL.equals(qualifierName) && 
discriminatesByType(args)) {
+                return true;
+            }
+            if (ANNOTATE_CALL.equals(qualifierName)) {
+                for (Expression arg : args) {
+                    if (arg instanceof ClassExpression && 
isConditionalAnnotation(((ClassExpression) arg).getType())) {
+                        return true;
+                    }
+                }
+            }
+        }
+        return false;
+    }
+
+    // @ConditionalOnMissingBean attributes that say nothing about a *type*. 
Where duplicates share
+    // one bean name - the only situation validateSharedBeanNames runs in - a 
name: or search: is
+    // identical on each of them and so can no more tell them apart than the 
bare form can.
+    private static final Set<String> 
NON_DISCRIMINATING_MISSING_BEAN_ATTRIBUTES = Set.of("name", "search");
+
+    private boolean discriminatesByType(List<Expression> args) {
+        boolean sawSomething = false;
+        for (Expression arg : args) {
+            if (arg instanceof ClassExpression) {
+                return true;
+            }
+            if (!(arg instanceof MapExpression)) {
+                // an argument shape this method does not model - stay lenient 
rather than reject
+                return true;
+            }
+            for (MapEntryExpression entry : ((MapExpression) 
arg).getMapEntryExpressions()) {
+                Object key = entry.getKeyExpression() instanceof 
ConstantExpression ?
+                        ((ConstantExpression) 
entry.getKeyExpression()).getValue() : null;
+                if (!(key instanceof String) || 
!NON_DISCRIMINATING_MISSING_BEAN_ATTRIBUTES.contains(key)) {
+                    return true;
+                }
+                sawSomething = true;
+            }
+        }
+        return !sawSomething && !args.isEmpty();
+    }
+
+    private List<Expression> withoutTrailingClosure(List<Expression> args, 
MethodCallExpression call,
+            MethodCallExpression outerCall) {
+        if (call == outerCall && !args.isEmpty() && args.get(args.size() - 1) 
instanceof ClosureExpression) {
+            return args.subList(0, args.size() - 1);
+        }
+        return args;
+    }
+
+    private boolean isConditionalAnnotation(ClassNode annotationType) {
+        return isConditionalAnnotation(annotationType, new HashSet<>());
+    }
+
+    // The same transitive meta-annotation walk belongsOnSibling does, against 
@Conditional alone:
+    // every @ConditionalOnXxx - Spring Boot's own and arbitrarily-deeply 
composed custom ones -
+    // eventually reaches @Conditional through its meta-annotations.
+    private boolean isConditionalAnnotation(ClassNode annotationType, 
Set<String> visited) {
+        if (!visited.add(annotationType.getName())) {
+            return false;
+        }
+        if (Conditional.class.getName().equals(annotationType.getName())) {
+            return true;
+        }
+        for (AnnotationNode metaAnnotation : annotationType.getAnnotations()) {
+            if (isConditionalAnnotation(metaAnnotation.getClassNode(), 
visited)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private void processStatement(ClassNode classNode, Statement statement, 
SourceUnit source, Set<String> usedNames) {
+        if (!(statement instanceof ExpressionStatement) ||
+                !(((ExpressionStatement) statement).getExpression() instanceof 
MethodCallExpression)) {
+            addError(statement, source, "Each 'beans' statement must be a 
bean(...), field(...), or method(...) call");
+            return;
+        }
+
+        MethodCallExpression outerCall = (MethodCallExpression) 
((ExpressionStatement) statement).getExpression();
+
+        // Walk from the outermost (last-written) call back to the 
bean(...)/field(...)/method(...)
+        // call at the root, collecting any chained qualifiers along the way.
+        List<MethodCallExpression> qualifierCalls = new ArrayList<>();
+        MethodCallExpression baseCall = outerCall;
+        while 
(!ROOT_STATEMENT_CALL_NAMES.contains(baseCall.getMethodAsString())) {
+            if 
(!ALL_QUALIFIER_CALL_NAMES.contains(baseCall.getMethodAsString()) ||
+                    !(baseCall.getObjectExpression() instanceof 
MethodCallExpression)) {
+                addError(statement, source, "Expected bean([\"name\", ] Type) 
{ ... }, field([\"name\", ] Type), " +
+                        "or method([\"name\", ] Type) { ... }, optionally 
chained with qualifiers");
+                return;
+            }
+            qualifierCalls.add(0, baseCall);
+            baseCall = (MethodCallExpression) baseCall.getObjectExpression();
+        }
+
+        // The walk above stops at the first root call name it meets, so 
without this a root
+        // statement chained onto another - field("suffix", 
String).bean("greeter", String) { } -
+        // parses as one statement and the left-hand declaration is silently 
dropped.
+        if (!baseCall.isImplicitThis() && baseCall.getObjectExpression() 
instanceof MethodCallExpression) {
+            addError(statement, source, baseCall.getMethodAsString() + "(...) 
cannot be chained onto " +
+                    ((MethodCallExpression) 
baseCall.getObjectExpression()).getMethodAsString() +
+                    "(...) - each bean(...), field(...) and method(...) 
declaration is its own statement");
+            return;
+        }
+
+        String rootName = baseCall.getMethodAsString();
+        boolean isBean = BEAN_CALL.equals(rootName);
+        Set<String> allowedQualifiers = isBean ? BEAN_QUALIFIER_CALL_NAMES :
+                FIELD_CALL.equals(rootName) ? FIELD_QUALIFIER_CALL_NAMES : 
METHOD_QUALIFIER_CALL_NAMES;
+        for (MethodCallExpression qualifierCall : qualifierCalls) {
+            if 
(!allowedQualifiers.contains(qualifierCall.getMethodAsString())) {
+                addError(qualifierCall, source, "." + 
qualifierCall.getMethodAsString() + "(...) cannot be " +
+                        "chained onto " + rootName + "(...)");
+                return;
+            }
+        }
+
+        // .annotate(...) is repeatable (once per distinct annotation type, 
enforced when the
+        // annotation is actually attached below); every other qualifier is 
single-use.
+        Set<String> seenQualifiers = new HashSet<>();
+        for (MethodCallExpression qualifierCall : qualifierCalls) {
+            String qualifierName = qualifierCall.getMethodAsString();
+            if (!ANNOTATE_CALL.equals(qualifierName) && 
!seenQualifiers.add(qualifierName)) {
+                addError(qualifierCall, source, "." + qualifierName + "(...) 
may only be chained once per " +
+                        rootName + "(...)");
+                return;
+            }
+        }
+
+        if (isBean) {
+            processBeanStatement(classNode, outerCall, baseCall, 
qualifierCalls, source, usedNames);
+        }
+        else if (FIELD_CALL.equals(rootName)) {
+            processFieldStatement(classNode, baseCall, qualifierCalls, source, 
usedNames);
+        }
+        else {
+            processMethodStatement(classNode, outerCall, baseCall, 
qualifierCalls, source, usedNames);
+        }
+    }
+
+    private boolean registerName(String name, ASTNode location, SourceUnit 
source, Set<String> usedNames, String errorSuffix) {
+        if (!usedNames.add(name)) {
+            addError(location, source, "\"" + name + "\" " + errorSuffix);
+            return false;
+        }
+        return true;
+    }
+
+    private void processBeanStatement(ClassNode classNode, 
MethodCallExpression outerCall, MethodCallExpression baseCall,
+            List<MethodCallExpression> qualifierCalls, SourceUnit source, 
Set<String> usedNames) {
+        // The factory closure is optional: bean(Type) with no body declares a 
bean that is just its
+        // own no-argument construction, which is by far the most common shape 
and reads as noise
+        // when spelled out as bean(Type) { new Type() }.
+        List<Expression> closureCallArgs = flatten(outerCall.getArguments());
+        ClosureExpression factory = !closureCallArgs.isEmpty() &&
+                closureCallArgs.get(closureCallArgs.size() - 1) instanceof 
ClosureExpression ?
+                (ClosureExpression) closureCallArgs.get(closureCallArgs.size() 
- 1) : null;
+
+        // When bean(...) is itself the outermost call (no qualifiers 
chained), it carries the
+        // trailing closure as its own last argument - exclude it before 
validating the [name, ] Type
+        // shape, since it was already validated above.
+        List<Expression> baseArgs = flatten(baseCall.getArguments());
+        if (factory != null && baseCall == outerCall && !baseArgs.isEmpty()) {
+            baseArgs = baseArgs.subList(0, baseArgs.size() - 1);
+        }
+
+        TypeAndName typeAndName = parseNameAndType(baseArgs, baseCall, source, 
BEAN_CALL, false);
+        if (typeAndName == null) {
+            return;
+        }
+
+        ClassNode beanType = typeAndName.type.getType();
+        // A closure whose body is empty declares construction too, from its 
own parameters: the
+        // parameters say what is injected, and the generated body is the 
constructor call the author
+        // would otherwise have written out. bean(Type) { } with no parameters 
is bean(Type).
+        boolean constructsDeclaredType = factory == null || 
isEmpty(factory.getCode());
+        if (constructsDeclaredType && (beanType.isInterface() || 
Modifier.isAbstract(beanType.getModifiers()))) {
+            addError(baseCall, source, "bean(" + 
beanType.getNameWithoutPackage() + ") with no factory closure body " +
+                    "constructs the declared type, which cannot be done for an 
interface or abstract class - " +
+                    "give it a body: bean(" + beanType.getNameWithoutPackage() 
+ ") { new SomeImplementation() }");
+            return;
+        }
+
+        // The method name is an implementation detail - Spring resolves the 
bean by its
+        // @Bean("name") value, never by the factory method's name - so a bean 
name that isn't a
+        // usable Java identifier, or is already taken by an existing member, 
synthesizes instead
+        // of erroring.
+        String javaMethodName = isValidJavaIdentifier(typeAndName.name) && 
!usedNames.contains(typeAndName.name) ?
+                typeAndName.name :
+                syntheticBeanMethodName(typeAndName.type.getType(), usedNames);
+        usedNames.add(javaMethodName);
+
+        Parameter[] beanParameters = factory == null || 
factory.getParameters() == null ?
+                Parameter.EMPTY_ARRAY : factory.getParameters();
+        Statement beanBody = constructsDeclaredType ?
+                new ReturnStatement(new ConstructorCallExpression(beanType, 
constructorArguments(beanParameters))) :
+                factory.getCode();
+
+        MethodNode beanMethod = new MethodNode(
+                javaMethodName,
+                Modifier.PUBLIC,
+                beanType,
+                beanParameters,
+                ClassNode.EMPTY_ARRAY,
+                beanBody);
+        beanMethod.setSourcePosition(baseCall);
+        
beanMethod.addAnnotation(withPosition(beanAnnotation(typeAndName.name), 
baseCall));
+
+        for (MethodCallExpression qualifierCall : qualifierCalls) {
+            List<Expression> qualifierArgs = 
flatten(qualifierCall.getArguments());
+            if (factory != null && qualifierCall == outerCall) {
+                // only the outermost call in the chain can carry the trailing 
factory closure
+                qualifierArgs = qualifierArgs.subList(0, qualifierArgs.size() 
- 1);
+            }
+            if (!applyQualifier(beanMethod, typeAndName.name, qualifierCall, 
qualifierArgs, source)) {
+                return;
+            }
+        }
+
+        classNode.addMethod(beanMethod);
+    }
+
+    private boolean isEmpty(Statement code) {
+        return code == null || code instanceof EmptyStatement ||
+                (code instanceof BlockStatement && ((BlockStatement) 
code).getStatements().isEmpty());
+    }
+
+    /**
+     * The closure's parameters, passed straight through as the constructor's 
arguments. Which
+     * constructor that selects is left to the compiler, resolved from these 
argument types exactly as
+     * it would be for a hand-written {@code new Type(...)} - nothing here 
reads the declared type's
+     * constructors, so adding one cannot change what this bean injects.
+     */
+    private ArgumentListExpression constructorArguments(Parameter[] 
parameters) {
+        ArgumentListExpression arguments = new ArgumentListExpression();
+        for (Parameter parameter : parameters) {
+            arguments.addExpression(new VariableExpression(parameter));
+        }
+        return arguments;
+    }
+
+    private String syntheticBeanMethodName(ClassNode beanType, Set<String> 
usedNames) {
+        String base = decapitalize(beanType.getNameWithoutPackage());
+        String candidate;
+        int index = 0;
+        do {
+            candidate = base + "$" + index;
+            index++;
+        }
+        while (usedNames.contains(candidate));
+        return candidate;
+    }
+
+    private void processFieldStatement(ClassNode classNode, 
MethodCallExpression baseCall,
+            List<MethodCallExpression> qualifierCalls, SourceUnit source, 
Set<String> usedNames) {
+        List<Expression> baseArgs = flatten(baseCall.getArguments());
+        TypeAndName typeAndName = parseNameAndType(baseArgs, baseCall, source, 
FIELD_CALL, true);
+        if (typeAndName == null) {
+            return;
+        }
+        if (!registerName(typeAndName.name, baseCall, source, usedNames,
+                "is already used by another member of the class (declared, 
inherited, or another field(...)/method(...) statement) - " +
+                        "generated member names must be unique")) {
+            return;
+        }
+
+        FieldNode field = classNode.addField(typeAndName.name, 
Modifier.PRIVATE, typeAndName.type.getType(), null);
+        field.setSourcePosition(baseCall);
+
+        for (MethodCallExpression qualifierCall : qualifierCalls) {
+            List<Expression> qualifierArgs = 
flatten(qualifierCall.getArguments());
+            if (VALUE_CALL.equals(qualifierCall.getMethodAsString())) {
+                AnnotationNode valueAnnotation = 
valueAnnotation(qualifierArgs, qualifierCall, source);
+                if (valueAnnotation == null || !addAnnotationIfAbsent(field, 
qualifierCall, valueAnnotation, source)) {
+                    return;
+                }
+            }
+            else if (!applyGenericAnnotation(field, qualifierCall, 
qualifierArgs, source)) {
+                return;
+            }
+        }
+    }
+
+    // .value(key, default) builds the '${key:default}' placeholder itself, as 
a concatenation the
+    // compiler folds into a constant - which is what lets the key be a bare 
static-final constant
+    // reference, the one shape a directly-written annotation value rejects. 
.value(single) is a
+    // bare config key with no default, auto-wrapped into '${key}' - injecting 
the key's literal
+    // text is never what .value(...) is for - unless the string already 
contains a '${'
+    // placeholder or '#{' SpEL expression, which passes through verbatim 
(including mixed
+    // literals like 'http://${app.host}/'). A genuine literal stays 
expressible via
+    // .annotate(Value, value: ...).
+    private AnnotationNode valueAnnotation(List<Expression> args, 
MethodCallExpression qualifierCall, SourceUnit source) {
+        if (args.isEmpty() || args.size() > 2) {
+            addError(qualifierCall, source, ".value(...) requires a config key 
and default - e.g. " +
+                    ".value(Settings.GSP_VIEW_ENCODING, \"UTF-8\") - or a 
single config key/placeholder/SpEL string");
+            return null;
+        }
+        // The pieces are resolved and folded HERE, into a plain constant, 
rather than being left
+        // as a concatenation for Groovy's own annotation folding: under 
@CompileStatic the static
+        // compiler rewrites '+' into .plus() calls before that folding runs, 
which would reject
+        // the member as a non-constant.
+        String memberValue;
+        if (args.size() == 1) {
+            String placeholder = resolveStringConstant(args.get(0));
+            if (placeholder == null) {
+                addError(args.get(0), source, ".value(...) arguments must be 
compile-time String constants " +
+                        "(a literal, a static final constant reference, or a 
concatenation of those)");
+                return null;
+            }
+            if (placeholder.isBlank()) {
+                addError(args.get(0), source, ".value(...) requires a 
non-blank config key - a blank one " +
+                        "would compile to the unresolvable placeholder ${}");
+                return null;
+            }
+            memberValue = placeholder.contains("${") || 
placeholder.contains("#{") ?
+                    placeholder : "${" + placeholder + "}";
+        }
+        else {
+            String key = resolveStringConstant(args.get(0));
+            String defaultValue = resolveStringConstant(args.get(1));
+            if (key == null || defaultValue == null) {
+                addError(key == null ? args.get(0) : args.get(1), source, 
".value(...) arguments must be " +
+                        "compile-time String constants (a literal, a static 
final constant reference, or a " +
+                        "concatenation of those)");
+                return null;
+            }
+            // Only the KEY must be non-blank: a deliberately blank default 
('${key:}') is legal
+            // and used (e.g. grails.i18n.default.locale falls back to the JVM 
default locale).
+            if (key.isBlank()) {
+                addError(args.get(0), source, ".value(key, default) requires a 
non-blank config key - " +
+                        "only the default may be blank");
+                return null;
+            }
+            memberValue = "${" + key + ":" + defaultValue + "}";
+        }
+        AnnotationNode annotation = new 
AnnotationNode(ClassHelper.make(Value.class));
+        annotation.setMember("value", new ConstantExpression(memberValue));
+        return annotation;
+    }
+
+    // Resolves an expression to its compile-time String value: literals 
directly; a static final
+    // constant reference either from its AST initial expression (a constant 
declared in the same
+    // compilation unit) or reflectively from the already-compiled class on 
the classpath; and
+    // concatenations of resolvable pieces recursively.
+    private String resolveStringConstant(Expression expression) {
+        if (expression instanceof ConstantExpression) {
+            Object value = ((ConstantExpression) expression).getValue();
+            return value instanceof String ? (String) value : null;
+        }
+        if (expression instanceof BinaryExpression) {
+            BinaryExpression binary = (BinaryExpression) expression;
+            if (binary.getOperation().getType() != Types.PLUS) {
+                return null;
+            }
+            String left = resolveStringConstant(binary.getLeftExpression());
+            String right = resolveStringConstant(binary.getRightExpression());
+            return left != null && right != null ? left + right : null;
+        }
+        if (expression instanceof PropertyExpression) {
+            PropertyExpression property = (PropertyExpression) expression;
+            if (!(property.getObjectExpression() instanceof ClassExpression)) {
+                return null;
+            }
+            ClassNode owner = property.getObjectExpression().getType();
+            String fieldName = property.getPropertyAsString();
+            if (fieldName == null) {
+                return null;
+            }
+            FieldNode field = findStaticFinalField(owner, fieldName, new 
HashSet<>());
+            if (field != null && field.getInitialExpression() instanceof 
ConstantExpression) {
+                Object value = ((ConstantExpression) 
field.getInitialExpression()).getValue();
+                return value instanceof String ? (String) value : null;
+            }
+            // The reflective fallback needs a loaded Class, which a ClassNode 
from this same
+            // compilation unit does not have - getTypeClass() would throw 
GroovyBugError, an
+            // AssertionError that no catch below would hold, aborting the 
compilation with an
+            // internal compiler error instead of the located message the 
caller reports.
+            try {
+                Object value = 
owner.getTypeClass().getField(fieldName).get(null);
+                return value instanceof String ? (String) value : null;
+            }
+            catch (ReflectiveOperationException | RuntimeException | 
LinkageError | GroovyBugError ignored) {
+                return null;
+            }
+        }
+        return null;
+    }
+
+    // ClassNode.getField walks superclasses but not interfaces, so a constant 
declared on an
+    // implemented interface - the shape every grails.config.Settings key has 
- is invisible to it
+    // while the owner is still being compiled.
+    private FieldNode findStaticFinalField(ClassNode owner, String fieldName, 
Set<String> visited) {
+        for (ClassNode type = owner; type != null; type = 
type.getSuperClass()) {
+            if (!visited.add(type.getName())) {
+                return null;
+            }
+            FieldNode declared = type.getDeclaredField(fieldName);
+            if (declared != null && declared.isStatic() && declared.isFinal()) 
{
+                return declared;
+            }
+            for (ClassNode implemented : type.getInterfaces()) {
+                FieldNode inherited = findStaticFinalField(implemented, 
fieldName, visited);
+                if (inherited != null) {
+                    return inherited;
+                }
+            }
+        }
+        return null;
+    }
+
+    private void processMethodStatement(ClassNode classNode, 
MethodCallExpression outerCall, MethodCallExpression baseCall,
+            List<MethodCallExpression> qualifierCalls, SourceUnit source, 
Set<String> usedNames) {
+        List<Expression> closureCallArgs = flatten(outerCall.getArguments());
+        if (closureCallArgs.isEmpty() || 
!(closureCallArgs.get(closureCallArgs.size() - 1) instanceof 
ClosureExpression)) {
+            addError(outerCall, source, "method(...) must end with a body 
closure: method(\"name\", Type) { ... }");
+            return;
+        }
+        ClosureExpression body = (ClosureExpression) 
closureCallArgs.get(closureCallArgs.size() - 1);
+
+        List<Expression> baseArgs = flatten(baseCall.getArguments());
+        if (baseCall == outerCall && !baseArgs.isEmpty()) {
+            baseArgs = baseArgs.subList(0, baseArgs.size() - 1);
+        }
+
+        TypeAndName typeAndName = parseNameAndType(baseArgs, baseCall, source, 
METHOD_CALL, true);
+        if (typeAndName == null) {
+            return;
+        }
+        if (!registerName(typeAndName.name, baseCall, source, usedNames,
+                "is already used by another member of the class (declared, 
inherited, or another field(...)/method(...) statement) - " +
+                        "generated member names must be unique")) {
+            return;
+        }
+
+        MethodNode helperMethod = new MethodNode(
+                typeAndName.name,
+                Modifier.PRIVATE,
+                typeAndName.type.getType(),
+                body.getParameters() == null ? Parameter.EMPTY_ARRAY : 
body.getParameters(),
+                ClassNode.EMPTY_ARRAY,
+                body.getCode());
+        helperMethod.setSourcePosition(baseCall);
+
+        for (MethodCallExpression qualifierCall : qualifierCalls) {
+            List<Expression> qualifierArgs = 
flatten(qualifierCall.getArguments());
+            if (qualifierCall == outerCall) {
+                qualifierArgs = qualifierArgs.subList(0, qualifierArgs.size() 
- 1);
+            }
+            if (!applyGenericAnnotation(helperMethod, qualifierCall, 
qualifierArgs, source)) {
+                return;
+            }
+        }
+
+        classNode.addMethod(helperMethod);
+    }
+
+    private static final class TypeAndName {
+        private final ClassExpression type;
+        private final String name;
+
+        TypeAndName(ClassExpression type, String name) {
+            this.type = type;
+            this.name = name;
+        }
+    }
+
+    private TypeAndName parseNameAndType(List<Expression> args, 
MethodCallExpression call, SourceUnit source,
+            String callName, boolean requireValidIdentifier) {
+        if (args.isEmpty() || args.size() > 2 || !(args.get(args.size() - 1) 
instanceof ClassExpression)) {
+            if (args.size() == 2 && args.get(0) instanceof ClassExpression) {
+                addError(call, source, callName + "(...) takes the name before 
the type: " +

Review Comment:
   This branch fires for a shape that has nothing to do with argument order, 
and the message then sends the reader somewhere unrelated. Chaining a qualifier 
*after* the factory closure rather than before it:
   
   ```groovy
   bean(String) { 'hi' }.lazy()
   ```
   
   ```
   bean(...) takes the name before the type: bean("myGreeter", Greeter), not 
bean(Greeter, "myGreeter")
    @ line 9, column 34.
                  bean(String) { 'hi' }.lazy()
                               ^
   ```
   
   The named spelling avoids the wrong advice but is no more useful:
   
   ```groovy
   bean('greeting', String) { 'hi' }.lazy()
   ```
   
   ```
   bean(...) requires a type, optionally preceded by a name, e.g. bean(Greeter) 
or bean("myGreeter", Greeter)
   ```
   
   It arrives here because `factory` is read off `outerCall`'s arguments, so a 
closure sitting on `baseCall` is never stripped and is still in `baseArgs` at 
this point. Both spellings are a plausible first guess: the closure reads as 
belonging to `bean(...)`, and it is where a body goes in every other DSL a 
Grails author writes. `method('name', Type) { ... }.annotate(X)` lands the same 
way, via "method(...) must end with a body closure".
   
   Detecting a trailing `ClosureExpression` on the base call when qualifiers 
are present, and saying that qualifiers come before the body, is a one-line 
diagnostic — and a row in the malformed-statement table.



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

To unsubscribe, e-mail: [email protected]

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

Reply via email to