You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by ja...@apache.org on 2026/07/24 00:31:45 UTC

(grails-core) 01/01: Generate metadata for Groovy configuration DSLs

This is an automated email from the ASF dual-hosted git repository.

jamesfredley pushed a commit to branch feature/groovy-dsl-configuration-metadata
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit d12545ae42611bf5c7e222a1581b0dc36a684ccb
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Thu Jul 23 20:30:30 2026 -0400

    Generate metadata for Groovy configuration DSLs
    
    Parse registered Groovy DSL sources without executing them and merge all remaining curated metadata through canonical generated resources.
    
    Assisted-by: opencode:gpt-5.6-sol codegraph
---
 .../buildsrc/ConfigurationMetadataPlugin.groovy    |  54 ++-
 .../GroovyDslConfigurationMetadataParser.groovy    | 207 +++++++++
 .../ConfigurationMetadataPluginSpec.groovy         | 233 +++++++++-
 grails-core/build.gradle                           |   1 +
 .../additional-spring-configuration-metadata.json  | 484 ++++++++++++++++++---
 .../META-INF/spring-configuration-metadata.json    | 346 ---------------
 grails-data-hibernate5/core/build.gradle           |   1 +
 ... additional-spring-configuration-metadata.json} |   0
 grails-data-hibernate5/dbmigration/build.gradle    |   1 +
 ... additional-spring-configuration-metadata.json} |   0
 grails-data-hibernate7/core/build.gradle           |   1 +
 ... additional-spring-configuration-metadata.json} |   0
 grails-data-hibernate7/dbmigration/build.gradle    |   1 +
 ... additional-spring-configuration-metadata.json} |   0
 grails-data-mongodb/grails-plugin/build.gradle     |   1 +
 ... additional-spring-configuration-metadata.json} |   0
 grails-doc/src/en/guide/conf/config.adoc           |  10 +-
 grails-gsp/plugin/build.gradle                     |   1 +
 ... additional-spring-configuration-metadata.json} |   0
 grails-spring-security/plugin/build.gradle         |   6 +
 ... additional-spring-configuration-metadata.json} |   0
 .../ConfigurationMetadataSpec.groovy               |  62 +++
 grails-web-core/build.gradle                       |   3 +-
 ... additional-spring-configuration-metadata.json} |   0
 24 files changed, 982 insertions(+), 430 deletions(-)

diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/ConfigurationMetadataPlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/ConfigurationMetadataPlugin.groovy
index 1dc5d9d460..eb42e611c8 100644
--- a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/ConfigurationMetadataPlugin.groovy
+++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/ConfigurationMetadataPlugin.groovy
@@ -28,7 +28,9 @@ import org.gradle.api.file.DirectoryProperty
 import org.gradle.api.file.FileSystemOperations
 import org.gradle.api.file.RegularFileProperty
 import org.gradle.api.plugins.JavaPluginExtension
+import org.gradle.api.provider.MapProperty
 import org.gradle.api.tasks.CacheableTask
+import org.gradle.api.tasks.Input
 import org.gradle.api.tasks.InputFile
 import org.gradle.api.tasks.InputFiles
 import org.gradle.api.tasks.Optional
@@ -106,6 +108,13 @@ abstract class GenerateConfigurationMetadataTask extends DefaultTask {
     @PathSensitive(PathSensitivity.RELATIVE)
     abstract ConfigurableFileCollection getClassesDirs()
 
+    @InputFiles
+    @PathSensitive(PathSensitivity.RELATIVE)
+    abstract ConfigurableFileCollection getDslConfigurationFiles()
+
+    @Input
+    abstract MapProperty<String, String> getDslRootPrefixes()
+
     @InputFile
     @Optional
     @PathSensitive(PathSensitivity.RELATIVE)
@@ -117,9 +126,15 @@ abstract class GenerateConfigurationMetadataTask extends DefaultTask {
     @Inject
     abstract FileSystemOperations getFileSystemOperations()
 
+    GenerateConfigurationMetadataTask() {
+        dslRootPrefixes.convention([:])
+    }
+
     @TaskAction
     void generate() {
         Map<String, ClassModel> models = readModels()
+        List<Map<String, Object>> dslProperties = GroovyDslConfigurationMetadataParser.parse(
+                dslConfigurationFiles.files, dslRootPrefixes.get())
         List<Map<String, Object>> groups = []
         List<Map<String, Object>> properties = []
         models.values().findAll { ClassModel model -> model.prefix != null }.sort { ClassModel model -> model.name }.each {
@@ -144,7 +159,7 @@ abstract class GenerateConfigurationMetadataTask extends DefaultTask {
             }
         }
 
-        Map<String, Object> metadata = merge(groups, properties, readOverlay())
+        Map<String, Object> metadata = merge([], dslProperties, groups, properties, readOverlay())
         File output = outputDirectory.get().asFile
         fileSystemOperations.delete { it.delete(output) }
         File target = new File(output, 'META-INF/spring-configuration-metadata.json')
@@ -348,13 +363,17 @@ abstract class GenerateConfigurationMetadataTask extends DefaultTask {
         file?.isFile() ? new JsonSlurper().parse(file, StandardCharsets.UTF_8.name()) as Map : [:]
     }
 
-    private static Map<String, Object> merge(List<Map<String, Object>> groups,
-                                             List<Map<String, Object>> properties, Map overlay) {
+    private static Map<String, Object> merge(List<Map<String, Object>> dslGroups,
+                                              List<Map<String, Object>> dslProperties,
+                                              List<Map<String, Object>> typedGroups,
+                                              List<Map<String, Object>> typedProperties,
+                                              Map overlay) {
         Map<String, Object> result = [:]
-        result['groups'] = mergeNamed(groups, (overlay.get('groups') ?: []) as List, 'groups')
-        result['properties'] = mergeNamed(properties, (overlay.get('properties') ?: []) as List, 'properties')
+        result['groups'] = mergeNamed(dslGroups, typedGroups, (overlay.get('groups') ?: []) as List, 'groups')
+        result['properties'] = mergeNamed(dslProperties, typedProperties,
+                (overlay.get('properties') ?: []) as List, 'properties')
         if (overlay.containsKey('hints')) {
-            result['hints'] = mergeNamed([], overlay.get('hints') as List, 'hints')
+            result['hints'] = mergeNamed([], [], overlay.get('hints') as List, 'hints')
         }
         overlay.each { Object keyValue, Object value ->
             String key = keyValue.toString()
@@ -365,27 +384,32 @@ abstract class GenerateConfigurationMetadataTask extends DefaultTask {
         if (overlay.containsKey('ignored')) {
             Map ignored = new LinkedHashMap((overlay.get('ignored') ?: [:]) as Map)
             if (ignored.containsKey('properties')) {
-                ignored['properties'] = mergeNamed([], ignored.get('properties') as List, 'ignored.properties')
+                ignored['properties'] = mergeNamed([], [], ignored.get('properties') as List, 'ignored.properties')
             }
             result['ignored'] = ignored
         }
         result
     }
 
-    private static List<Object> mergeNamed(List generated, List overlay, String category) {
-        Map<String, Object> generatedByName = indexByName(generated, category, 'generated')
+    private static List<Object> mergeNamed(List dsl, List typed, List overlay, String category) {
+        Map<String, Object> dslByName = indexByName(dsl, category, 'DSL')
+        Map<String, Object> typedByName = indexByName(typed, category, 'generated')
         Map<String, Object> overlayByName = indexByName(overlay, category, 'overlay')
-        Map<String, Object> merged = new LinkedHashMap<>(generatedByName)
+        Map<String, Object> merged = new LinkedHashMap<>(dslByName)
+        typedByName.each { String name, Object value ->
+            merged[name] = mergeFields(merged[name], value)
+        }
         overlayByName.each { String name, Object value ->
-            if (merged[name] instanceof Map && value instanceof Map) {
-                merged[name] = new LinkedHashMap((Map) merged[name]) + (Map) value
-            } else {
-                merged[name] = value
-            }
+            merged[name] = mergeFields(merged[name], value)
         }
         merged.keySet().sort().collect { String name -> merged[name] }
     }
 
+    private static Object mergeFields(Object lowerPrecedence, Object higherPrecedence) {
+        lowerPrecedence instanceof Map && higherPrecedence instanceof Map ?
+                new LinkedHashMap((Map) lowerPrecedence) + (Map) higherPrecedence : higherPrecedence
+    }
+
     private static Map<String, Object> indexByName(List source, String category, String sourceName) {
         Map<String, Object> indexed = [:]
         source.each { Object entry ->
diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovyDslConfigurationMetadataParser.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovyDslConfigurationMetadataParser.groovy
new file mode 100644
index 0000000000..76b160574d
--- /dev/null
+++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovyDslConfigurationMetadataParser.groovy
@@ -0,0 +1,207 @@
+/*
+ *  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.apache.grails.buildsrc
+
+import org.codehaus.groovy.ast.ModuleNode
+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.Expression
+import org.codehaus.groovy.ast.expr.ListExpression
+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.TernaryExpression
+import org.codehaus.groovy.ast.expr.VariableExpression
+import org.codehaus.groovy.ast.stmt.BlockStatement
+import org.codehaus.groovy.ast.stmt.ExpressionStatement
+import org.codehaus.groovy.ast.stmt.IfStatement
+import org.codehaus.groovy.ast.stmt.Statement
+import org.codehaus.groovy.control.CompilationFailedException
+import org.codehaus.groovy.control.SourceUnit
+import org.codehaus.groovy.syntax.Types
+
+import java.nio.charset.StandardCharsets
+
+/** Extracts configuration metadata from Groovy DSL source without evaluating source code. */
+final class GroovyDslConfigurationMetadataParser {
+
+    private GroovyDslConfigurationMetadataParser() {
+    }
+
+    static List<Map<String, Object>> parse(Collection<File> files, Map<String, String> rootPrefixes) {
+        List<Map<String, Object>> properties = []
+        files.findAll { File file -> file.isFile() }.sort { File file -> file.absolutePath }.each { File file ->
+            parseFile(file, rootPrefixes, properties)
+        }
+        properties.sort { Map<String, Object> property -> property.name as String }
+    }
+
+    private static void parseFile(File file, Map<String, String> rootPrefixes,
+                                  List<Map<String, Object>> properties) {
+        ModuleNode module = parseSource(file)
+        module.statementBlock.statements.each { Statement statement ->
+            MethodCallExpression call = methodCall(statement)
+            String root = call?.methodAsString
+            ClosureExpression closure = call == null ? null : closureArgument(call)
+            String prefix = root == null ? null : rootPrefixes[root]
+            if (prefix != null && closure != null) {
+                parseStatements(closure.code, prefix, false, properties)
+            }
+        }
+    }
+
+    private static ModuleNode parseSource(File file) {
+        try {
+            SourceUnit source = SourceUnit.create(file.absolutePath, file.getText(StandardCharsets.UTF_8.name()))
+            source.parse()
+            source.completePhase()
+            source.nextPhase()
+            source.convert()
+            source.errorCollector.failIfErrors()
+            source.AST
+        } catch (CompilationFailedException exception) {
+            throw new IllegalArgumentException("Failed to parse Groovy DSL source '${file.absolutePath}'", exception)
+        }
+    }
+
+    private static void parseStatements(Statement statement, String prefix, boolean conditional,
+                                        List<Map<String, Object>> properties) {
+        if (statement instanceof BlockStatement) {
+            statement.statements.each { Statement child -> parseStatements(child, prefix, conditional, properties) }
+        } else if (statement instanceof IfStatement) {
+            parseStatements(statement.ifBlock, prefix, true, properties)
+            parseStatements(statement.elseBlock, prefix, true, properties)
+        } else if (statement instanceof ExpressionStatement) {
+            Expression expression = statement.expression
+            if (expression instanceof BinaryExpression && expression.operation.type == Types.ASSIGN) {
+                addAssignment(expression, prefix, conditional, properties)
+            } else if (expression instanceof MethodCallExpression) {
+                ClosureExpression closure = closureArgument(expression)
+                String nestedName = expression.methodAsString
+                if (closure != null && nestedName != null) {
+                    parseStatements(closure.code, "${prefix}.${nestedName}", conditional, properties)
+                }
+            }
+        }
+    }
+
+    private static void addAssignment(BinaryExpression assignment, String prefix, boolean conditional,
+                                      List<Map<String, Object>> properties) {
+        List<String> segments = leftHandPath(assignment.leftExpression)
+        if (segments == null) {
+            return
+        }
+        String name = "${prefix}.${segments.join('.')}"
+        Inference inference = infer(assignment.rightExpression)
+        Map<String, Object> property = [name: name]
+        if (inference.type != null) {
+            property.type = inference.type
+        }
+        if (!conditional && inference.literal) {
+            property.defaultValue = inference.value
+        }
+        properties << property
+    }
+
+    private static List<String> leftHandPath(Expression expression) {
+        if (expression instanceof VariableExpression) {
+            return [expression.name]
+        }
+        if (expression instanceof PropertyExpression && expression.property instanceof ConstantExpression &&
+                expression.property.value instanceof String) {
+            List<String> owner = leftHandPath(expression.objectExpression)
+            return owner == null ? null : owner + expression.property.value
+        }
+        null
+    }
+
+    private static MethodCallExpression methodCall(Statement statement) {
+        statement instanceof ExpressionStatement && statement.expression instanceof MethodCallExpression ?
+                statement.expression as MethodCallExpression : null
+    }
+
+    private static ClosureExpression closureArgument(MethodCallExpression call) {
+        call.arguments instanceof ArgumentListExpression ?
+                (call.arguments.expressions.find { Expression expression -> expression instanceof ClosureExpression } as ClosureExpression) : null
+    }
+
+    private static Inference infer(Expression expression) {
+        if (expression instanceof ConstantExpression) {
+            return new Inference(type: expression.value?.class?.name, literal: true, value: expression.value)
+        }
+        if (expression instanceof ListExpression) {
+            return listInference(expression)
+        }
+        if (expression instanceof MapExpression) {
+            return mapInference(expression)
+        }
+        if (expression instanceof TernaryExpression) {
+            return sharedType(infer(expression.trueExpression), infer(expression.falseExpression))
+        }
+        if (expression instanceof MethodCallExpression && isSystemCall(expression) &&
+                expression.methodAsString in ['getProperty', 'getenv']) {
+            return new Inference(type: 'java.lang.String')
+        }
+        new Inference()
+    }
+
+    private static boolean isSystemCall(MethodCallExpression expression) {
+        Expression receiver = expression.objectExpression
+        receiver instanceof ClassExpression && receiver.type.name == 'java.lang.System' ||
+                receiver instanceof VariableExpression && receiver.name == 'System'
+    }
+
+    private static Inference sharedType(Inference left, Inference right) {
+        left.type != null && left.type == right.type ? new Inference(type: left.type) : new Inference()
+    }
+
+    private static Inference listInference(ListExpression expression) {
+        List<Object> values = []
+        for (Expression element : expression.expressions) {
+            Inference inference = infer(element)
+            if (!inference.literal) {
+                return new Inference(type: 'java.util.List')
+            }
+            values << inference.value
+        }
+        new Inference(type: 'java.util.List', literal: true, value: values)
+    }
+
+    private static Inference mapInference(MapExpression expression) {
+        Map<String, Object> values = new LinkedHashMap<>()
+        for (def entry : expression.mapEntryExpressions) {
+            Inference key = infer(entry.keyExpression)
+            Inference value = infer(entry.valueExpression)
+            if (!key.literal || !(key.value instanceof String) || !value.literal) {
+                return new Inference(type: 'java.util.Map')
+            }
+            values[key.value] = value.value
+        }
+        new Inference(type: 'java.util.Map', literal: true, value: values)
+    }
+
+    private static final class Inference {
+        String type
+        boolean literal
+        Object value
+    }
+}
diff --git a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/ConfigurationMetadataPluginSpec.groovy b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/ConfigurationMetadataPluginSpec.groovy
index f56816bd30..dc4fbed166 100644
--- a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/ConfigurationMetadataPluginSpec.groovy
+++ b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/ConfigurationMetadataPluginSpec.groovy
@@ -207,6 +207,181 @@ class ConfigurationMetadataPluginSpec extends Specification {
         metadataEntryCount() == 1
     }
 
+    def "generates static Groovy DSL metadata without executing the DSL source"() {
+        given: 'a mapped DSL source and curated metadata for one of its properties'
+        writeGroovyDslConfiguration(false)
+        configureDslMetadata('src/dsl/fixture/SecurityConfig.groovy')
+        write('src/main/resources/META-INF/additional-spring-configuration-metadata.json', '''
+            {
+              "properties": [
+                {
+                  "name": "grails.plugin.springsecurity.userLookup.userDomainClassName",
+                  "type": "java.lang.String",
+                  "defaultValue": "overlay.User",
+                  "description": "Curated user domain class"
+                }
+              ]
+            }
+        '''.stripIndent())
+
+        when: 'the jar is built without executing the DSL script'
+        BuildResult clean = run('clean', 'jar')
+        Map metadata = readMetadata()
+
+        then: 'nested closures use the mapped root prefix and literal defaults retain their types'
+        clean.task(':generateConfigurationMetadata').outcome == TaskOutcome.SUCCESS
+        property(metadata, 'grails.plugin.springsecurity.userLookup.enabled').type == 'java.lang.Boolean'
+        property(metadata, 'grails.plugin.springsecurity.userLookup.enabled').defaultValue == true
+        property(metadata, 'grails.plugin.springsecurity.oauth.client.clientId').type == 'java.lang.String'
+        property(metadata, 'grails.plugin.springsecurity.oauth.client.clientId').defaultValue == 'client'
+        property(metadata, 'grails.plugin.springsecurity.authentication.maxAttempts').type == 'java.lang.Integer'
+        property(metadata, 'grails.plugin.springsecurity.authentication.maxAttempts').defaultValue == 3
+        property(metadata, 'grails.plugin.springsecurity.authentication.roles').type == 'java.util.List'
+        property(metadata, 'grails.plugin.springsecurity.authentication.roles').defaultValue == ['ROLE_USER', 'ROLE_ADMIN']
+        property(metadata, 'grails.plugin.springsecurity.authentication.options').type == 'java.util.Map'
+        property(metadata, 'grails.plugin.springsecurity.authentication.options').defaultValue == [maxSessions: 1, rememberMe: true]
+        property(metadata, 'grails.plugin.springsecurity.authentication.session.timeout').type == 'java.lang.Integer'
+        property(metadata, 'grails.plugin.springsecurity.authentication.session.timeout').defaultValue == 30
+
+        and: 'dynamic and environment-conditional expressions have no static default'
+        property(metadata, 'grails.plugin.springsecurity.authentication.dynamicDefault').type == 'java.lang.String'
+        !property(metadata, 'grails.plugin.springsecurity.authentication.dynamicDefault').containsKey('defaultValue')
+        property(metadata, 'grails.plugin.springsecurity.authentication.environmentDefault').type == 'java.lang.String'
+        !property(metadata, 'grails.plugin.springsecurity.authentication.environmentDefault').containsKey('defaultValue')
+
+        and: 'curated metadata remains the final overlay'
+        property(metadata, 'grails.plugin.springsecurity.userLookup.userDomainClassName').defaultValue == 'overlay.User'
+        property(metadata, 'grails.plugin.springsecurity.userLookup.userDomainClassName').description == 'Curated user domain class'
+
+        and: 'the generated jar has one canonical metadata entry'
+        metadataEntryCount() == 1
+
+        when: 'nothing changes'
+        BuildResult unchanged = run('jar')
+
+        then: 'the task is up to date'
+        unchanged.task(':generateConfigurationMetadata').outcome == TaskOutcome.UP_TO_DATE
+
+        when: 'the DSL source is edited without cleaning'
+        writeGroovyDslConfiguration(true)
+        BuildResult edited = run('jar')
+        Map editedMetadata = readMetadata()
+
+        then: 'metadata is regenerated from the edited DSL source'
+        edited.task(':generateConfigurationMetadata').outcome == TaskOutcome.SUCCESS
+        property(editedMetadata, 'grails.plugin.springsecurity.authentication.sessionTimeout').type == 'java.lang.Integer'
+        property(editedMetadata, 'grails.plugin.springsecurity.authentication.sessionTimeout').defaultValue == 30
+        metadataEntryCount() == 1
+
+        when: 'the DSL source is deleted without cleaning'
+        projectDir.resolve('src/dsl/fixture/SecurityConfig.groovy').toFile().delete()
+        BuildResult deleted = run('jar')
+        Map deletedMetadata = readMetadata()
+
+        then: 'DSL-derived metadata is removed while the curated overlay remains'
+        deleted.task(':generateConfigurationMetadata').outcome == TaskOutcome.SUCCESS
+        property(deletedMetadata, 'grails.plugin.springsecurity.oauth.client.clientId') == null
+        property(deletedMetadata, 'grails.plugin.springsecurity.authentication.sessionTimeout') == null
+        property(deletedMetadata, 'grails.plugin.springsecurity.userLookup.userDomainClassName').defaultValue == 'overlay.User'
+        metadataEntryCount() == 1
+    }
+
+    def "merges same-name DSL typed and curated metadata fields by precedence"() {
+        given: 'a DSL property also declared by a typed configuration class and curated overlay'
+        writeGroovyDslConfiguration(false)
+        writeTypedSecurityConfiguration()
+        configureDslMetadata('src/dsl/fixture/SecurityConfig.groovy')
+        write('src/main/resources/META-INF/additional-spring-configuration-metadata.json', '''
+            {
+              "properties": [
+                {
+                  "name": "grails.plugin.springsecurity.authentication.maxAttempts",
+                  "description": "Curated maximum attempts"
+                }
+              ]
+            }
+        '''.stripIndent())
+
+        when:
+        BuildResult result = run('generateConfigurationMetadata')
+        Map metadata = readMetadata()
+
+        then: 'typed fields override the DSL while the DSL default and curated fields remain'
+        result.task(':generateConfigurationMetadata').outcome == TaskOutcome.SUCCESS
+        property(metadata, 'grails.plugin.springsecurity.authentication.maxAttempts').type == 'java.lang.Long'
+        property(metadata, 'grails.plugin.springsecurity.authentication.maxAttempts').sourceType == 'fixture.SecurityConfiguration'
+        property(metadata, 'grails.plugin.springsecurity.authentication.maxAttempts').defaultValue == 3
+        property(metadata, 'grails.plugin.springsecurity.authentication.maxAttempts').description == 'Curated maximum attempts'
+    }
+
+    def "parses DSL sources without executing transforms or dynamic nested calls"() {
+        given: 'a DSL source outside the compiled source set with an impossible dependency transform'
+        write('src/dsl/fixture/TransformConfig.groovy', '''
+            @GrabResolver(name = 'missing', root = 'file:///not-a-repository')
+            @Grab('missing.group:missing-artifact:1.0')
+            import missing.Dependency
+
+            security {
+                enabled = true
+                this."${System.getProperty('fixture.dynamic.section')}" {
+                    ignored = true
+                }
+            }
+        '''.stripIndent())
+        configureDslMetadata('src/dsl/fixture/TransformConfig.groovy')
+
+        when:
+        BuildResult result = run('generateConfigurationMetadata')
+        Map metadata = readMetadata()
+
+        then: 'the transform is not discovered or executed and dynamic nested names are ignored'
+        result.task(':generateConfigurationMetadata').outcome == TaskOutcome.SUCCESS
+        property(metadata, 'grails.plugin.springsecurity.enabled').defaultValue == true
+        property(metadata, 'grails.plugin.springsecurity.null.ignored') == null
+        !metadata.properties*.name.any { String name -> name.contains('.null.') }
+    }
+
+    def "preserves null literals and omits map defaults with non-string keys"() {
+        given:
+        write('src/dsl/fixture/LiteralConfig.groovy', '''
+            security {
+                literals {
+                    nullList = [null, 'present']
+                    nullMap = [first: null, second: 'present']
+                    unsupportedMap = [(1): 'one']
+                }
+            }
+        '''.stripIndent())
+        configureDslMetadata('src/dsl/fixture/LiteralConfig.groovy')
+
+        when:
+        BuildResult result = run('generateConfigurationMetadata')
+        Map metadata = readMetadata()
+
+        then:
+        result.task(':generateConfigurationMetadata').outcome == TaskOutcome.SUCCESS
+        property(metadata, 'grails.plugin.springsecurity.literals.nullList').defaultValue == [null, 'present']
+        property(metadata, 'grails.plugin.springsecurity.literals.nullMap').defaultValue == [first: null, second: 'present']
+        property(metadata, 'grails.plugin.springsecurity.literals.unsupportedMap').type == 'java.util.Map'
+        !property(metadata, 'grails.plugin.springsecurity.literals.unsupportedMap').containsKey('defaultValue')
+    }
+
+    def "reports the actual source file for malformed DSL"() {
+        given:
+        File malformedDsl = write('src/dsl/fixture/BrokenConfig.groovy', '''
+            security {
+                enabled =
+            }
+        '''.stripIndent())
+        configureDslMetadata('src/dsl/fixture/BrokenConfig.groovy')
+
+        when:
+        BuildResult result = runner('generateConfigurationMetadata').buildAndFail()
+
+        then:
+        result.output.contains(malformedDsl.absolutePath)
+    }
+
     def "fails on conflicting duplicate identities in an overlay source"() {
         given:
         write('src/main/resources/META-INF/additional-spring-configuration-metadata.json', '''
@@ -293,6 +468,52 @@ class ConfigurationMetadataPluginSpec extends Specification {
         """.stripIndent())
     }
 
+    private void writeGroovyDslConfiguration(boolean includeSessionTimeout) {
+        String sessionTimeout = includeSessionTimeout ? 'sessionTimeout = 30' : ''
+        write('src/dsl/fixture/SecurityConfig.groovy', """
+            package fixture
+
+            throw new AssertionError('The configuration metadata task must not execute DSL sources')
+
+            security {
+                userLookup {
+                    userDomainClassName = 'fixture.User'
+                    enabled = true
+                }
+                oauth {
+                    client {
+                        clientId = 'client'
+                    }
+                }
+                authentication {
+                    maxAttempts = 3
+                    session.timeout = 30
+                    roles = ['ROLE_USER', 'ROLE_ADMIN']
+                    options = [maxSessions: 1, rememberMe: true]
+                    dynamicDefault = System.getProperty('fixture.security.dynamic')
+                    environmentDefault = System.getenv('FIXTURE_SECURITY_ENABLED') ? 'enabled' : 'disabled'
+                    ${sessionTimeout}
+                }
+            }
+        """.stripIndent())
+    }
+
+    private void writeTypedSecurityConfiguration() {
+        write('src/main/java/fixture/SecurityConfiguration.java', '''
+            package fixture;
+
+            import org.springframework.boot.context.properties.ConfigurationProperties;
+
+            @ConfigurationProperties("grails.plugin.springsecurity.authentication")
+            public class SecurityConfiguration {
+                private Long maxAttempts;
+
+                public Long getMaxAttempts() { return maxAttempts; }
+                public void setMaxAttempts(Long maxAttempts) { this.maxAttempts = maxAttempts; }
+            }
+        '''.stripIndent())
+    }
+
     private BuildResult run(String... arguments) {
         runner(arguments).build()
     }
@@ -304,10 +525,20 @@ class ConfigurationMetadataPluginSpec extends Specification {
                 .withPluginClasspath()
     }
 
-    private void write(String relativePath, String contents) {
+    private void configureDslMetadata(String relativePath) {
+        projectDir.resolve('build.gradle').toFile() << """
+            tasks.named('generateConfigurationMetadata') {
+                dslConfigurationFiles.from(file('${relativePath}'))
+                dslRootPrefixes.put('security', 'grails.plugin.springsecurity')
+            }
+        """.stripIndent()
+    }
+
+    private File write(String relativePath, String contents) {
         File file = projectDir.resolve(relativePath).toFile()
         file.parentFile.mkdirs()
         file.text = contents
+        file
     }
 
     private File metadataFile() {
diff --git a/grails-core/build.gradle b/grails-core/build.gradle
index 96ba46f4e0..2bef623b5f 100644
--- a/grails-core/build.gradle
+++ b/grails-core/build.gradle
@@ -25,6 +25,7 @@ plugins {
     id 'org.apache.grails.buildsrc.dependency-validator'
     id 'org.apache.grails.gradle.grails-plugin-cli'
     id 'org.apache.grails.buildsrc.compile'
+    id 'org.apache.grails.buildsrc.configuration-metadata'
     id 'org.apache.grails.buildsrc.publish'
     id 'org.apache.grails.buildsrc.sbom'
     id 'org.apache.grails.buildsrc.vulnerability-scan'
diff --git a/grails-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/grails-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json
index ec339dce9a..054640fdbf 100644
--- a/grails-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json
+++ b/grails-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json
@@ -1,66 +1,420 @@
 {
-  "properties": [
-    {
-      "name": "grails.banner.art.display",
-        "description": "Whether to display the Grails banner art.",
-      "type": "java.lang.Boolean",
-      "defaultValue": true
-    },
-    {
-        "name": "grails.banner.art.file",
-        "description": "The file path on the classpath to the Grails banner art to display.",
-        "type": "java.lang.String",
-        "defaultValue": "grails-banner.txt"
-    },
-    {
-      "name": "grails.banner.versions.display",
-      "description": "Whether to display version information in the Grails banner.",
-      "type": "java.lang.Boolean",
-      "defaultValue": true
-    },
-    {
-      "name": "grails.banner.versions.include",
-      "description": "A list of optional versions to include in the banner.",
-      "type": "java.util.List<grails.boot.GrailsBanner.OptionalVersionOption>"
-    },
-    {
-      "name": "grails.banner.versions.exclude",
-      "description": "A list of versions to exclude from the banner.",
-      "type": "java.util.List<grails.boot.GrailsBanner.DefaultVersionOption>"
-    },
-    {
-      "name": "grails.banner.versions.order",
-      "description": "A sorted list of versions to put first in the banner versions.",
-      "type": "java.util.List<grails.boot.GrailsBanner.VersionOption>"
-    },
-    {
-      "name": "grails.web.removeDefaultViewResolverBean",
-      "description": "Whether to remove Spring Boot's defaultViewResolver bean so Grails' own view resolution is used.",
-      "type": "java.lang.Boolean",
-      "defaultValue": true
-    },
-    {
-      "name": "grails.web.removeWelcomePageMapping",
-      "description": "Whether to remove Spring Boot's welcome-page handler mapping so Grails' URL mappings own the root path ('/') rather than serving a static index.html for it.",
-      "type": "java.lang.Boolean",
-      "defaultValue": true
-    },
-    {
-      "name": "grails.i18n.localeResolver",
-      "description": "The locale resolution strategy. 'session' and 'cookie' are mutable (the ?lang= switch works); 'acceptHeader' and 'fixed' are read-only (the ?lang= switch is ignored).",
-      "type": "java.lang.String",
-      "defaultValue": "session"
-    }
-  ],
-  "hints": [
-    {
-      "name": "grails.i18n.localeResolver",
-      "values": [
-        { "value": "session", "description": "Resolve the locale from the HTTP session (mutable; ?lang= switching works). The default." },
-        { "value": "cookie", "description": "Resolve the locale from a cookie (mutable; ?lang= switching works)." },
-        { "value": "acceptHeader", "description": "Resolve the locale from the Accept-Language header (read-only; ?lang= is ignored)." },
-        { "value": "fixed", "description": "Use a fixed locale from grails.i18n.default.locale, falling back to the JVM default (read-only; ?lang= is ignored)." }
-      ]
-    }
-  ]
-}
\ No newline at end of file
+    "groups": [
+        {
+            "name": "grails",
+            "description": "Core Properties"
+        },
+        {
+            "name": "grails.codegen",
+            "description": "Core Properties"
+        },
+        {
+            "name": "grails.bootstrap",
+            "description": "Core Properties"
+        },
+        {
+            "name": "grails.spring",
+            "description": "Core Properties"
+        },
+        {
+            "name": "grails.plugin",
+            "description": "Core Properties"
+        },
+        {
+            "name": "grails.reload",
+            "description": "Development & Reloading"
+        },
+        {
+            "name": "grails.events",
+            "description": "Events"
+        },
+        {
+            "name": "grails.json",
+            "description": "JSON & Converters"
+        },
+        {
+            "name": "grails.converters.json",
+            "description": "JSON & Converters"
+        },
+        {
+            "name": "grails.converters.xml",
+            "description": "JSON & Converters"
+        },
+        {
+            "name": "grails.i18n",
+            "description": "Internationalization"
+        },
+        {
+            "name": "grails.gorm",
+            "description": "GORM"
+        },
+        {
+            "name": "grails.gorm.reactor",
+            "description": "GORM"
+        },
+        {
+            "name": "grails.gorm.events",
+            "description": "GORM"
+        },
+        {
+            "name": "grails.gorm.multiTenancy",
+            "description": "GORM"
+        },
+        {
+            "name": "dataSource",
+            "description": "DataSource"
+        }
+    ],
+    "properties": [
+        {
+            "name": "grails.profile",
+            "type": "java.lang.String",
+            "description": "The active Grails application profile (e.g., web, rest-api, plugin).",
+            "defaultValue": "Set by project template"
+        },
+        {
+            "name": "grails.codegen.defaultPackage",
+            "type": "java.lang.String",
+            "description": "The default package used when generating artefacts with grails create-* commands.",
+            "defaultValue": "Set by project template"
+        },
+        {
+            "name": "grails.serverURL",
+            "type": "java.lang.String",
+            "description": "The server URL used to generate absolute links (e.g., https://my.app.com) and used by redirects.",
+            "defaultValue": "derived from request"
+        },
+        {
+            "name": "grails.enable.native2ascii",
+            "type": "java.lang.Boolean",
+            "description": "Whether to perform native2ascii conversion of i18n properties files.",
+            "defaultValue": true
+        },
+        {
+            "name": "grails.bootstrap.skip",
+            "type": "java.lang.Boolean",
+            "description": "Whether to skip execution of BootStrap.groovy classes on startup.",
+            "defaultValue": false
+        },
+        {
+            "name": "grails.spring.bean.packages",
+            "type": "java.util.List<java.lang.String>",
+            "description": "List of packages to scan for Spring beans.",
+            "defaultValue": []
+        },
+        {
+            "name": "grails.spring.disable.aspectj.autoweaving",
+            "type": "java.lang.Boolean",
+            "description": "Whether to disable AspectJ auto-weaving.",
+            "defaultValue": false
+        },
+        {
+            "name": "grails.spring.placeholder.prefix",
+            "type": "java.lang.String",
+            "description": "The prefix for property placeholder resolution.",
+            "defaultValue": "${"
+        },
+        {
+            "name": "grails.spring.transactionManagement.proxies",
+            "type": "java.lang.Boolean",
+            "description": "Whether to enable Spring proxy-based transaction management since @Transactional uses an AST transform and proxies are typically redundant.",
+            "defaultValue": false
+        },
+        {
+            "name": "grails.plugin.includes",
+            "type": "java.util.List<java.lang.String>",
+            "description": "List of plugin names to include in the plugin manager (all others excluded).",
+            "defaultValue": []
+        },
+        {
+            "name": "grails.plugin.excludes",
+            "type": "java.util.List<java.lang.String>",
+            "description": "List of plugin names to exclude from the plugin manager.",
+            "defaultValue": []
+        },
+        {
+            "name": "grails.reload.includes",
+            "type": "java.util.List<java.lang.String>",
+            "description": "List of fully qualified class names to include in development reloading, when set only these classes are reloaded.",
+            "defaultValue": []
+        },
+        {
+            "name": "grails.reload.excludes",
+            "type": "java.util.List<java.lang.String>",
+            "description": "List of fully qualified class names to exclude from development reloading.",
+            "defaultValue": []
+        },
+        {
+            "name": "grails.events.spring",
+            "type": "java.lang.Boolean",
+            "description": "Whether to bridge GORM/Grails events to the Spring ApplicationEventPublisher, allowing EventListener methods to receive domain events.",
+            "defaultValue": true
+        },
+        {
+            "name": "grails.json.legacy.builder",
+            "type": "java.lang.Boolean",
+            "description": "Whether to use the legacy JSON builder.",
+            "defaultValue": false
+        },
+        {
+            "name": "grails.converters.json.domain.include.class",
+            "type": "java.lang.Boolean",
+            "description": "Whether to include the class property when marshalling domain objects to JSON.",
+            "defaultValue": false
+        },
+        {
+            "name": "grails.converters.xml.domain.include.class",
+            "type": "java.lang.Boolean",
+            "description": "Whether to include the class attribute when marshalling domain objects to XML.",
+            "defaultValue": false
+        },
+        {
+            "name": "grails.i18n.cache.seconds",
+            "type": "java.lang.Integer",
+            "description": "How long (in seconds) to cache resolved message bundles with -1 to cache indefinitely and 0 to disable caching.",
+            "defaultValue": -1
+        },
+        {
+            "name": "grails.i18n.filecache.seconds",
+            "type": "java.lang.Integer",
+            "description": "How long (in seconds) to cache the message bundle file lookup with -1 to cache indefinitely.",
+            "defaultValue": -1
+        },
+        {
+            "name": "grails.gorm.failOnError",
+            "type": "java.lang.Boolean",
+            "description": "When true, save() throws ValidationException on validation failure instead of returning null and can also be a list of package names to apply selectively.",
+            "defaultValue": false
+        },
+        {
+            "name": "grails.gorm.autoFlush",
+            "type": "java.lang.Boolean",
+            "description": "Whether to automatically flush the Hibernate session between queries.",
+            "defaultValue": false
+        },
+        {
+            "name": "grails.gorm.flushMode",
+            "type": "java.lang.String",
+            "description": "The default Hibernate flush mode (AUTO, COMMIT, MANUAL).",
+            "defaultValue": "AUTO"
+        },
+        {
+            "name": "grails.gorm.markDirty",
+            "type": "java.lang.Boolean",
+            "description": "Whether to mark a domain instance as dirty on an explicit save() call.",
+            "defaultValue": true
+        },
+        {
+            "name": "grails.gorm.autowire",
+            "type": "java.lang.Boolean",
+            "description": "Whether to autowire Spring beans into domain class instances.",
+            "defaultValue": true
+        },
+        {
+            "name": "grails.gorm.default.mapping",
+            "type": "java.util.Map<java.lang.String,java.lang.Object>",
+            "description": "A closure applied as the default mapping block for all domain classes.",
+            "defaultValue": {}
+        },
+        {
+            "name": "grails.gorm.default.constraints",
+            "type": "java.util.Map<java.lang.String,java.lang.Object>",
+            "description": "A closure applied as the default constraints for all domain classes.",
+            "defaultValue": {}
+        },
+        {
+            "name": "grails.gorm.default.nullable",
+            "type": "java.lang.Boolean",
+            "description": "Whether an unconstrained persistent property is nullable by default (Grails 8). Set to false to restore the legacy required-by-default behaviour.",
+            "defaultValue": true
+        },
+        {
+            "name": "grails.gorm.custom.types",
+            "type": "java.util.Map<java.lang.String,java.lang.Object>",
+            "description": "Map of custom GORM types.",
+            "defaultValue": {}
+        },
+        {
+            "name": "grails.gorm.reactor.events",
+            "type": "java.lang.Boolean",
+            "description": "Whether to translate GORM events into Reactor events, which is disabled by default for performance.",
+            "defaultValue": false
+        },
+        {
+            "name": "grails.gorm.events.autoTimestampInsertOverwrite",
+            "type": "java.lang.Boolean",
+            "description": "Whether auto-timestamp (dateCreated) overwrites a user-provided value on insert.",
+            "defaultValue": true
+        },
+        {
+            "name": "grails.gorm.multiTenancy.mode",
+            "type": "java.lang.String",
+            "description": "The multi-tenancy mode: DISCRIMINATOR, DATABASE, SCHEMA, or NONE.",
+            "defaultValue": "NONE"
+        },
+        {
+            "name": "grails.gorm.multiTenancy.tenantResolverClass",
+            "type": "java.lang.String",
+            "description": "Fully qualified class name of the TenantResolver implementation.",
+            "defaultValue": "required when mode is not NONE"
+        },
+        {
+            "name": "dataSource.driverClassName",
+            "type": "java.lang.String",
+            "description": "The JDBC driver class name.",
+            "defaultValue": "org.h2.Driver"
+        },
+        {
+            "name": "dataSource.username",
+            "type": "java.lang.String",
+            "description": "The database username.",
+            "defaultValue": "sa"
+        },
+        {
+            "name": "dataSource.password",
+            "type": "java.lang.String",
+            "description": "The database password.",
+            "defaultValue": ""
+        },
+        {
+            "name": "dataSource.url",
+            "type": "java.lang.String",
+            "description": "The JDBC connection URL.",
+            "defaultValue": "jdbc:h2:mem:devDb"
+        },
+        {
+            "name": "dataSource.dbCreate",
+            "type": "java.lang.String",
+            "description": "The schema generation strategy: create-drop, create, update, validate, or none, use none in production with a migration tool.",
+            "defaultValue": "create-drop (dev), none (prod)"
+        },
+        {
+            "name": "dataSource.pooled",
+            "type": "java.lang.Boolean",
+            "description": "Whether to use a connection pool.",
+            "defaultValue": true
+        },
+        {
+            "name": "dataSource.logSql",
+            "type": "java.lang.Boolean",
+            "description": "Whether to log SQL statements to stdout.",
+            "defaultValue": false
+        },
+        {
+            "name": "dataSource.formatSql",
+            "type": "java.lang.Boolean",
+            "description": "Whether to format logged SQL for readability.",
+            "defaultValue": false
+        },
+        {
+            "name": "dataSource.dialect",
+            "type": "java.lang.String",
+            "description": "The Hibernate dialect class name or class.",
+            "defaultValue": "auto-detected from driver"
+        },
+        {
+            "name": "dataSource.readOnly",
+            "type": "java.lang.Boolean",
+            "description": "Whether the DataSource is read-only (calls setReadOnly(true) on connections).",
+            "defaultValue": false
+        },
+        {
+            "name": "dataSource.transactional",
+            "type": "java.lang.Boolean",
+            "description": "For additional datasources, whether to include in the chained transaction manager.",
+            "defaultValue": true
+        },
+        {
+            "name": "dataSource.persistenceInterceptor",
+            "type": "java.lang.Boolean",
+            "description": "For additional datasources, whether to wire up the persistence interceptor (the default datasource is always wired).",
+            "defaultValue": false
+        },
+        {
+            "name": "dataSource.jmxExport",
+            "type": "java.lang.Boolean",
+            "description": "Whether to register JMX MBeans for the DataSource.",
+            "defaultValue": true
+        },
+        {
+            "name": "dataSource.type",
+            "type": "java.lang.String",
+            "description": "The connection pool implementation class when multiple are on the classpath.",
+            "defaultValue": "com.zaxxer.hikari.HikariDataSource"
+        },
+        {
+            "name": "grails.banner.art.display",
+            "description": "Whether to display the Grails banner art.",
+            "type": "java.lang.Boolean",
+            "defaultValue": true
+        },
+        {
+            "name": "grails.banner.art.file",
+            "description": "The file path on the classpath to the Grails banner art to display.",
+            "type": "java.lang.String",
+            "defaultValue": "grails-banner.txt"
+        },
+        {
+            "name": "grails.banner.versions.display",
+            "description": "Whether to display version information in the Grails banner.",
+            "type": "java.lang.Boolean",
+            "defaultValue": true
+        },
+        {
+            "name": "grails.banner.versions.include",
+            "description": "A list of optional versions to include in the banner.",
+            "type": "java.util.List<grails.boot.GrailsBanner.OptionalVersionOption>"
+        },
+        {
+            "name": "grails.banner.versions.exclude",
+            "description": "A list of versions to exclude from the banner.",
+            "type": "java.util.List<grails.boot.GrailsBanner.DefaultVersionOption>"
+        },
+        {
+            "name": "grails.banner.versions.order",
+            "description": "A sorted list of versions to put first in the banner versions.",
+            "type": "java.util.List<grails.boot.GrailsBanner.VersionOption>"
+        },
+        {
+            "name": "grails.web.removeDefaultViewResolverBean",
+            "description": "Whether to remove Spring Boot's defaultViewResolver bean so Grails' own view resolution is used.",
+            "type": "java.lang.Boolean",
+            "defaultValue": true
+        },
+        {
+            "name": "grails.web.removeWelcomePageMapping",
+            "description": "Whether to remove Spring Boot's welcome-page handler mapping so Grails' URL mappings own the root path ('/') rather than serving a static index.html for it.",
+            "type": "java.lang.Boolean",
+            "defaultValue": true
+        },
+        {
+            "name": "grails.i18n.localeResolver",
+            "description": "The locale resolution strategy. 'session' and 'cookie' are mutable (the ?lang= switch works); 'acceptHeader' and 'fixed' are read-only (the ?lang= switch is ignored).",
+            "type": "java.lang.String",
+            "defaultValue": "session"
+        }
+    ],
+    "hints": [
+        {
+            "name": "grails.i18n.localeResolver",
+            "values": [
+                {
+                    "value": "session",
+                    "description": "Resolve the locale from the HTTP session (mutable; ?lang= switching works). The default."
+                },
+                {
+                    "value": "cookie",
+                    "description": "Resolve the locale from a cookie (mutable; ?lang= switching works)."
+                },
+                {
+                    "value": "acceptHeader",
+                    "description": "Resolve the locale from the Accept-Language header (read-only; ?lang= is ignored)."
+                },
+                {
+                    "value": "fixed",
+                    "description": "Use a fixed locale from grails.i18n.default.locale, falling back to the JVM default (read-only; ?lang= is ignored)."
+                }
+            ]
+        }
+    ]
+}
diff --git a/grails-core/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-core/src/main/resources/META-INF/spring-configuration-metadata.json
deleted file mode 100644
index 50ba004db1..0000000000
--- a/grails-core/src/main/resources/META-INF/spring-configuration-metadata.json
+++ /dev/null
@@ -1,346 +0,0 @@
-{
-    "groups": [
-        {
-            "name": "grails",
-            "description": "Core Properties"
-        },
-        {
-            "name": "grails.codegen",
-            "description": "Core Properties"
-        },
-        {
-            "name": "grails.bootstrap",
-            "description": "Core Properties"
-        },
-        {
-            "name": "grails.spring",
-            "description": "Core Properties"
-        },
-        {
-            "name": "grails.plugin",
-            "description": "Core Properties"
-        },
-        {
-            "name": "grails.reload",
-            "description": "Development & Reloading"
-        },
-        {
-            "name": "grails.events",
-            "description": "Events"
-        },
-        {
-            "name": "grails.json",
-            "description": "JSON & Converters"
-        },
-        {
-            "name": "grails.converters.json",
-            "description": "JSON & Converters"
-        },
-        {
-            "name": "grails.converters.xml",
-            "description": "JSON & Converters"
-        },
-        {
-            "name": "grails.i18n",
-            "description": "Internationalization"
-        },
-        {
-            "name": "grails.gorm",
-            "description": "GORM"
-        },
-        {
-            "name": "grails.gorm.reactor",
-            "description": "GORM"
-        },
-        {
-            "name": "grails.gorm.events",
-            "description": "GORM"
-        },
-        {
-            "name": "grails.gorm.multiTenancy",
-            "description": "GORM"
-        },
-        {
-            "name": "dataSource",
-            "description": "DataSource"
-        }
-    ],
-    "properties": [
-        {
-            "name": "grails.profile",
-            "type": "java.lang.String",
-            "description": "The active Grails application profile (e.g., web, rest-api, plugin).",
-            "defaultValue": "Set by project template"
-        },
-        {
-            "name": "grails.codegen.defaultPackage",
-            "type": "java.lang.String",
-            "description": "The default package used when generating artefacts with grails create-* commands.",
-            "defaultValue": "Set by project template"
-        },
-        {
-            "name": "grails.serverURL",
-            "type": "java.lang.String",
-            "description": "The server URL used to generate absolute links (e.g., https://my.app.com) and used by redirects.",
-            "defaultValue": "derived from request"
-        },
-        {
-            "name": "grails.enable.native2ascii",
-            "type": "java.lang.Boolean",
-            "description": "Whether to perform native2ascii conversion of i18n properties files.",
-            "defaultValue": true
-        },
-        {
-            "name": "grails.bootstrap.skip",
-            "type": "java.lang.Boolean",
-            "description": "Whether to skip execution of BootStrap.groovy classes on startup.",
-            "defaultValue": false
-        },
-        {
-            "name": "grails.spring.bean.packages",
-            "type": "java.util.List<java.lang.String>",
-            "description": "List of packages to scan for Spring beans.",
-            "defaultValue": []
-        },
-        {
-            "name": "grails.spring.disable.aspectj.autoweaving",
-            "type": "java.lang.Boolean",
-            "description": "Whether to disable AspectJ auto-weaving.",
-            "defaultValue": false
-        },
-        {
-            "name": "grails.spring.placeholder.prefix",
-            "type": "java.lang.String",
-            "description": "The prefix for property placeholder resolution.",
-            "defaultValue": "${"
-        },
-        {
-            "name": "grails.spring.transactionManagement.proxies",
-            "type": "java.lang.Boolean",
-            "description": "Whether to enable Spring proxy-based transaction management since @Transactional uses an AST transform and proxies are typically redundant.",
-            "defaultValue": false
-        },
-        {
-            "name": "grails.plugin.includes",
-            "type": "java.util.List<java.lang.String>",
-            "description": "List of plugin names to include in the plugin manager (all others excluded).",
-            "defaultValue": []
-        },
-        {
-            "name": "grails.plugin.excludes",
-            "type": "java.util.List<java.lang.String>",
-            "description": "List of plugin names to exclude from the plugin manager.",
-            "defaultValue": []
-        },
-        {
-            "name": "grails.reload.includes",
-            "type": "java.util.List<java.lang.String>",
-            "description": "List of fully qualified class names to include in development reloading, when set only these classes are reloaded.",
-            "defaultValue": []
-        },
-        {
-            "name": "grails.reload.excludes",
-            "type": "java.util.List<java.lang.String>",
-            "description": "List of fully qualified class names to exclude from development reloading.",
-            "defaultValue": []
-        },
-        {
-            "name": "grails.events.spring",
-            "type": "java.lang.Boolean",
-            "description": "Whether to bridge GORM/Grails events to the Spring ApplicationEventPublisher, allowing EventListener methods to receive domain events.",
-            "defaultValue": true
-        },
-        {
-            "name": "grails.json.legacy.builder",
-            "type": "java.lang.Boolean",
-            "description": "Whether to use the legacy JSON builder.",
-            "defaultValue": false
-        },
-        {
-            "name": "grails.converters.json.domain.include.class",
-            "type": "java.lang.Boolean",
-            "description": "Whether to include the class property when marshalling domain objects to JSON.",
-            "defaultValue": false
-        },
-        {
-            "name": "grails.converters.xml.domain.include.class",
-            "type": "java.lang.Boolean",
-            "description": "Whether to include the class attribute when marshalling domain objects to XML.",
-            "defaultValue": false
-        },
-        {
-            "name": "grails.i18n.cache.seconds",
-            "type": "java.lang.Integer",
-            "description": "How long (in seconds) to cache resolved message bundles with -1 to cache indefinitely and 0 to disable caching.",
-            "defaultValue": -1
-        },
-        {
-            "name": "grails.i18n.filecache.seconds",
-            "type": "java.lang.Integer",
-            "description": "How long (in seconds) to cache the message bundle file lookup with -1 to cache indefinitely.",
-            "defaultValue": -1
-        },
-        {
-            "name": "grails.gorm.failOnError",
-            "type": "java.lang.Boolean",
-            "description": "When true, save() throws ValidationException on validation failure instead of returning null and can also be a list of package names to apply selectively.",
-            "defaultValue": false
-        },
-        {
-            "name": "grails.gorm.autoFlush",
-            "type": "java.lang.Boolean",
-            "description": "Whether to automatically flush the Hibernate session between queries.",
-            "defaultValue": false
-        },
-        {
-            "name": "grails.gorm.flushMode",
-            "type": "java.lang.String",
-            "description": "The default Hibernate flush mode (AUTO, COMMIT, MANUAL).",
-            "defaultValue": "AUTO"
-        },
-        {
-            "name": "grails.gorm.markDirty",
-            "type": "java.lang.Boolean",
-            "description": "Whether to mark a domain instance as dirty on an explicit save() call.",
-            "defaultValue": true
-        },
-        {
-            "name": "grails.gorm.autowire",
-            "type": "java.lang.Boolean",
-            "description": "Whether to autowire Spring beans into domain class instances.",
-            "defaultValue": true
-        },
-        {
-            "name": "grails.gorm.default.mapping",
-            "type": "java.util.Map<java.lang.String,java.lang.Object>",
-            "description": "A closure applied as the default mapping block for all domain classes.",
-            "defaultValue": {}
-        },
-        {
-            "name": "grails.gorm.default.constraints",
-            "type": "java.util.Map<java.lang.String,java.lang.Object>",
-            "description": "A closure applied as the default constraints for all domain classes.",
-            "defaultValue": {}
-        },
-        {
-            "name": "grails.gorm.default.nullable",
-            "type": "java.lang.Boolean",
-            "description": "Whether an unconstrained persistent property is nullable by default (Grails 8). Set to false to restore the legacy required-by-default behaviour.",
-            "defaultValue": true
-        },
-        {
-            "name": "grails.gorm.custom.types",
-            "type": "java.util.Map<java.lang.String,java.lang.Object>",
-            "description": "Map of custom GORM types.",
-            "defaultValue": {}
-        },
-        {
-            "name": "grails.gorm.reactor.events",
-            "type": "java.lang.Boolean",
-            "description": "Whether to translate GORM events into Reactor events, which is disabled by default for performance.",
-            "defaultValue": false
-        },
-        {
-            "name": "grails.gorm.events.autoTimestampInsertOverwrite",
-            "type": "java.lang.Boolean",
-            "description": "Whether auto-timestamp (dateCreated) overwrites a user-provided value on insert.",
-            "defaultValue": true
-        },
-        {
-            "name": "grails.gorm.multiTenancy.mode",
-            "type": "java.lang.String",
-            "description": "The multi-tenancy mode: DISCRIMINATOR, DATABASE, SCHEMA, or NONE.",
-            "defaultValue": "NONE"
-        },
-        {
-            "name": "grails.gorm.multiTenancy.tenantResolverClass",
-            "type": "java.lang.String",
-            "description": "Fully qualified class name of the TenantResolver implementation.",
-            "defaultValue": "required when mode is not NONE"
-        },
-        {
-            "name": "dataSource.driverClassName",
-            "type": "java.lang.String",
-            "description": "The JDBC driver class name.",
-            "defaultValue": "org.h2.Driver"
-        },
-        {
-            "name": "dataSource.username",
-            "type": "java.lang.String",
-            "description": "The database username.",
-            "defaultValue": "sa"
-        },
-        {
-            "name": "dataSource.password",
-            "type": "java.lang.String",
-            "description": "The database password.",
-            "defaultValue": ""
-        },
-        {
-            "name": "dataSource.url",
-            "type": "java.lang.String",
-            "description": "The JDBC connection URL.",
-            "defaultValue": "jdbc:h2:mem:devDb"
-        },
-        {
-            "name": "dataSource.dbCreate",
-            "type": "java.lang.String",
-            "description": "The schema generation strategy: create-drop, create, update, validate, or none, use none in production with a migration tool.",
-            "defaultValue": "create-drop (dev), none (prod)"
-        },
-        {
-            "name": "dataSource.pooled",
-            "type": "java.lang.Boolean",
-            "description": "Whether to use a connection pool.",
-            "defaultValue": true
-        },
-        {
-            "name": "dataSource.logSql",
-            "type": "java.lang.Boolean",
-            "description": "Whether to log SQL statements to stdout.",
-            "defaultValue": false
-        },
-        {
-            "name": "dataSource.formatSql",
-            "type": "java.lang.Boolean",
-            "description": "Whether to format logged SQL for readability.",
-            "defaultValue": false
-        },
-        {
-            "name": "dataSource.dialect",
-            "type": "java.lang.String",
-            "description": "The Hibernate dialect class name or class.",
-            "defaultValue": "auto-detected from driver"
-        },
-        {
-            "name": "dataSource.readOnly",
-            "type": "java.lang.Boolean",
-            "description": "Whether the DataSource is read-only (calls setReadOnly(true) on connections).",
-            "defaultValue": false
-        },
-        {
-            "name": "dataSource.transactional",
-            "type": "java.lang.Boolean",
-            "description": "For additional datasources, whether to include in the chained transaction manager.",
-            "defaultValue": true
-        },
-        {
-            "name": "dataSource.persistenceInterceptor",
-            "type": "java.lang.Boolean",
-            "description": "For additional datasources, whether to wire up the persistence interceptor (the default datasource is always wired).",
-            "defaultValue": false
-        },
-        {
-            "name": "dataSource.jmxExport",
-            "type": "java.lang.Boolean",
-            "description": "Whether to register JMX MBeans for the DataSource.",
-            "defaultValue": true
-        },
-        {
-            "name": "dataSource.type",
-            "type": "java.lang.String",
-            "description": "The connection pool implementation class when multiple are on the classpath.",
-            "defaultValue": "com.zaxxer.hikari.HikariDataSource"
-        }
-    ]
-}
diff --git a/grails-data-hibernate5/core/build.gradle b/grails-data-hibernate5/core/build.gradle
index 28dab355f5..c8823ed745 100644
--- a/grails-data-hibernate5/core/build.gradle
+++ b/grails-data-hibernate5/core/build.gradle
@@ -23,6 +23,7 @@ plugins {
     id 'org.apache.grails.buildsrc.properties'
     id 'org.apache.grails.buildsrc.dependency-validator'
     id 'org.apache.grails.buildsrc.compile'
+    id 'org.apache.grails.buildsrc.configuration-metadata'
     id 'org.apache.grails.buildsrc.publish'
     id 'org.apache.grails.buildsrc.sbom'
     id 'org.apache.grails.buildsrc.vulnerability-scan'
diff --git a/grails-data-hibernate5/core/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-data-hibernate5/core/src/main/resources/META-INF/additional-spring-configuration-metadata.json
similarity index 100%
rename from grails-data-hibernate5/core/src/main/resources/META-INF/spring-configuration-metadata.json
rename to grails-data-hibernate5/core/src/main/resources/META-INF/additional-spring-configuration-metadata.json
diff --git a/grails-data-hibernate5/dbmigration/build.gradle b/grails-data-hibernate5/dbmigration/build.gradle
index 952ed3ffd6..777c29e091 100644
--- a/grails-data-hibernate5/dbmigration/build.gradle
+++ b/grails-data-hibernate5/dbmigration/build.gradle
@@ -23,6 +23,7 @@ plugins {
     id 'org.apache.grails.gradle.grails-plugin'
     id 'org.apache.grails.gradle.grails-plugin-cli'
     id 'org.apache.grails.buildsrc.compile'
+    id 'org.apache.grails.buildsrc.configuration-metadata'
     id 'org.apache.grails.buildsrc.publish'
     id 'org.apache.grails.buildsrc.sbom'
     id 'org.apache.grails.buildsrc.vulnerability-scan'
diff --git a/grails-data-hibernate5/dbmigration/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-data-hibernate5/dbmigration/src/main/resources/META-INF/additional-spring-configuration-metadata.json
similarity index 100%
rename from grails-data-hibernate5/dbmigration/src/main/resources/META-INF/spring-configuration-metadata.json
rename to grails-data-hibernate5/dbmigration/src/main/resources/META-INF/additional-spring-configuration-metadata.json
diff --git a/grails-data-hibernate7/core/build.gradle b/grails-data-hibernate7/core/build.gradle
index a9d7a481ac..258b13cb1e 100644
--- a/grails-data-hibernate7/core/build.gradle
+++ b/grails-data-hibernate7/core/build.gradle
@@ -23,6 +23,7 @@ plugins {
     id 'org.apache.grails.buildsrc.properties'
     id 'org.apache.grails.buildsrc.dependency-validator'
     id 'org.apache.grails.buildsrc.compile'
+    id 'org.apache.grails.buildsrc.configuration-metadata'
     id 'org.apache.grails.buildsrc.publish'
     id 'org.apache.grails.buildsrc.sbom'
     id 'org.apache.grails.gradle.grails-code-style'
diff --git a/grails-data-hibernate7/core/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-data-hibernate7/core/src/main/resources/META-INF/additional-spring-configuration-metadata.json
similarity index 100%
rename from grails-data-hibernate7/core/src/main/resources/META-INF/spring-configuration-metadata.json
rename to grails-data-hibernate7/core/src/main/resources/META-INF/additional-spring-configuration-metadata.json
diff --git a/grails-data-hibernate7/dbmigration/build.gradle b/grails-data-hibernate7/dbmigration/build.gradle
index b5f31beada..147475fce0 100644
--- a/grails-data-hibernate7/dbmigration/build.gradle
+++ b/grails-data-hibernate7/dbmigration/build.gradle
@@ -23,6 +23,7 @@ plugins {
     id 'org.apache.grails.gradle.grails-plugin'
     id 'org.apache.grails.gradle.grails-plugin-cli'
     id 'org.apache.grails.buildsrc.compile'
+    id 'org.apache.grails.buildsrc.configuration-metadata'
     id 'org.apache.grails.buildsrc.publish'
     id 'org.apache.grails.buildsrc.sbom'
     id 'org.apache.grails.gradle.grails-code-style'
diff --git a/grails-data-hibernate7/dbmigration/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-data-hibernate7/dbmigration/src/main/resources/META-INF/additional-spring-configuration-metadata.json
similarity index 100%
rename from grails-data-hibernate7/dbmigration/src/main/resources/META-INF/spring-configuration-metadata.json
rename to grails-data-hibernate7/dbmigration/src/main/resources/META-INF/additional-spring-configuration-metadata.json
diff --git a/grails-data-mongodb/grails-plugin/build.gradle b/grails-data-mongodb/grails-plugin/build.gradle
index 047d52bacf..230c73bc3c 100644
--- a/grails-data-mongodb/grails-plugin/build.gradle
+++ b/grails-data-mongodb/grails-plugin/build.gradle
@@ -23,6 +23,7 @@ plugins {
     id 'org.apache.grails.buildsrc.dependency-validator'
     id 'org.apache.grails.gradle.grails-plugin'
     id 'org.apache.grails.buildsrc.compile'
+    id 'org.apache.grails.buildsrc.configuration-metadata'
     id 'org.apache.grails.buildsrc.publish'
     id 'org.apache.grails.buildsrc.sbom'
     id 'org.apache.grails.buildsrc.vulnerability-scan'
diff --git a/grails-data-mongodb/grails-plugin/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-data-mongodb/grails-plugin/src/main/resources/META-INF/additional-spring-configuration-metadata.json
similarity index 100%
rename from grails-data-mongodb/grails-plugin/src/main/resources/META-INF/spring-configuration-metadata.json
rename to grails-data-mongodb/grails-plugin/src/main/resources/META-INF/additional-spring-configuration-metadata.json
diff --git a/grails-doc/src/en/guide/conf/config.adoc b/grails-doc/src/en/guide/conf/config.adoc
index d5da23da10..1d4bee7742 100644
--- a/grails-doc/src/en/guide/conf/config.adoc
+++ b/grails-doc/src/en/guide/conf/config.adoc
@@ -125,10 +125,16 @@ transformation stores metadata independently in each compiled `@ConfigurationPro
 those class payloads after compilation. Java configuration classes are inspected from their compiled bytecode. This
 avoids Java annotation processing on Groovy sources, which would interfere with incremental Groovy compilation.
 
+Framework modules can also register legacy Groovy configuration DSL sources for static metadata discovery. Grails
+inspects the source syntax without executing the configuration script. Literal values can provide generated defaults,
+while values from method calls, environment branches, and other dynamic expressions are discovered without treating a
+build-specific result as the default.
+
 The generated metadata describes bindable fields and bean properties. A module can also provide
 `META-INF/additional-spring-configuration-metadata.json` for descriptions, hints, legacy or indirect properties, and
-other details that cannot be derived safely from bytecode. Curated values override generated values for the same
-property.
+other details that cannot be derived safely from bytecode or static DSL syntax. Curated values override generated values
+for the same property. The module build merges all generated and curated entries into the single standard
+`META-INF/spring-configuration-metadata.json` resource published in the module JAR.
 
 Only compile-time constant Groovy initializers are emitted as generated defaults. Defaults calculated by constructors,
 method calls, environment checks, system properties, or other dynamic expressions are intentionally omitted unless a
diff --git a/grails-gsp/plugin/build.gradle b/grails-gsp/plugin/build.gradle
index bdcd3205c2..99bb2f35f6 100644
--- a/grails-gsp/plugin/build.gradle
+++ b/grails-gsp/plugin/build.gradle
@@ -23,6 +23,7 @@ plugins {
     id 'org.apache.grails.buildsrc.dependency-validator'
     id 'org.apache.grails.gradle.grails-plugin'
     id 'org.apache.grails.buildsrc.compile'
+    id 'org.apache.grails.buildsrc.configuration-metadata'
     id 'org.apache.grails.buildsrc.publish'
     id 'org.apache.grails.buildsrc.sbom'
     id 'org.apache.grails.buildsrc.vulnerability-scan'
diff --git a/grails-gsp/plugin/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-gsp/plugin/src/main/resources/META-INF/additional-spring-configuration-metadata.json
similarity index 100%
rename from grails-gsp/plugin/src/main/resources/META-INF/spring-configuration-metadata.json
rename to grails-gsp/plugin/src/main/resources/META-INF/additional-spring-configuration-metadata.json
diff --git a/grails-spring-security/plugin/build.gradle b/grails-spring-security/plugin/build.gradle
index 14453792e6..1ba8e085f7 100644
--- a/grails-spring-security/plugin/build.gradle
+++ b/grails-spring-security/plugin/build.gradle
@@ -25,6 +25,7 @@ plugins {
 	id 'org.apache.grails.buildsrc.properties'
 	id 'org.apache.grails.buildsrc.dependency-validator'
 	id 'org.apache.grails.buildsrc.compile'
+	id 'org.apache.grails.buildsrc.configuration-metadata'
 	id 'org.apache.grails.buildsrc.publish'
 	id 'org.apache.grails.buildsrc.sbom'
 	id 'org.apache.grails.gradle.grails-code-style'
@@ -119,3 +120,8 @@ apply {
 	from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle')
 	from rootProject.layout.projectDirectory.file('gradle/spring-security-test-config.gradle')
 }
+
+tasks.named('generateConfigurationMetadata') {
+    dslConfigurationFiles.from(file('grails-app/conf/DefaultSecurityConfig.groovy'))
+    dslRootPrefixes.put('security', 'grails.plugin.springsecurity')
+}
diff --git a/grails-spring-security/plugin/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-spring-security/plugin/src/main/resources/META-INF/additional-spring-configuration-metadata.json
similarity index 100%
rename from grails-spring-security/plugin/src/main/resources/META-INF/spring-configuration-metadata.json
rename to grails-spring-security/plugin/src/main/resources/META-INF/additional-spring-configuration-metadata.json
diff --git a/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/ConfigurationMetadataSpec.groovy b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/ConfigurationMetadataSpec.groovy
new file mode 100644
index 0000000000..17323588c0
--- /dev/null
+++ b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/ConfigurationMetadataSpec.groovy
@@ -0,0 +1,62 @@
+/*
+ *  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.plugin.springsecurity
+
+import groovy.json.JsonSlurper
+import spock.lang.Specification
+
+class ConfigurationMetadataSpec extends Specification {
+
+    void 'generates merged configuration metadata from DefaultSecurityConfig'() {
+        given:
+        Map metadata = configurationMetadata()
+        Map<String, Map> properties = metadata.get('properties').collectEntries { Map property -> [(property.get('name')): property] }
+
+        expect: 'the complete curated metadata contract is retained'
+        metadata.get('groups').size() == 14
+        properties.size() == 145
+
+        and: 'literal DSL defaults are present'
+        properties['grails.plugin.springsecurity.active'].type == 'java.lang.Boolean'
+        properties['grails.plugin.springsecurity.active'].defaultValue == true
+
+        and: 'conditional DSL properties retain curated defaults'
+        properties['grails.plugin.springsecurity.password.bcrypt.logrounds'].type == 'java.lang.Integer'
+        properties['grails.plugin.springsecurity.password.bcrypt.logrounds'].defaultValue == 10
+
+        and: 'nested DSL paths are mapped beneath the Spring Security prefix'
+        properties['grails.plugin.springsecurity.userLookup.usernamePropertyName'].type == 'java.lang.String'
+        properties['grails.plugin.springsecurity.userLookup.usernamePropertyName'].defaultValue == 'username'
+
+        and: 'curated metadata augments statically discovered values'
+        properties['grails.plugin.springsecurity.beanTypeResolverClass'].type == 'java.lang.String'
+        properties['grails.plugin.springsecurity.beanTypeResolverClass'].description == 'Bean type resolver class name.'
+    }
+
+    private static Map configurationMetadata() {
+        List<URL> resources = ConfigurationMetadataSpec.classLoader
+                .getResources('META-INF/spring-configuration-metadata.json')
+                .findAll { URL candidate ->
+                    Map metadata = new JsonSlurper().parse(candidate) as Map
+                    (metadata.get('groups') ?: []).any { Map group -> group.get('name') == 'grails.plugin.springsecurity' }
+                }
+        assert resources.size() == 1
+        new JsonSlurper().parse(resources.first()) as Map
+    }
+}
diff --git a/grails-web-core/build.gradle b/grails-web-core/build.gradle
index cc3619fbf9..77fbb473ef 100644
--- a/grails-web-core/build.gradle
+++ b/grails-web-core/build.gradle
@@ -24,6 +24,7 @@ plugins {
     id 'org.apache.grails.buildsrc.properties'
     id 'org.apache.grails.buildsrc.dependency-validator'
     id 'org.apache.grails.buildsrc.compile'
+    id 'org.apache.grails.buildsrc.configuration-metadata'
     id 'org.apache.grails.buildsrc.publish'
     id 'org.apache.grails.buildsrc.sbom'
     id 'org.apache.grails.buildsrc.vulnerability-scan'
@@ -78,4 +79,4 @@ dependencies {
 apply {
     from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle')
     from rootProject.layout.projectDirectory.file('gradle/test-config.gradle')
-}
\ No newline at end of file
+}
diff --git a/grails-web-core/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-web-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json
similarity index 100%
rename from grails-web-core/src/main/resources/META-INF/spring-configuration-metadata.json
rename to grails-web-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json