jamesfredley commented on code in PR #15948:
URL: https://github.com/apache/grails-core/pull/15948#discussion_r3605219263


##########
grails-core/src/main/groovy/org/grails/compiler/injection/FactoriesFileWriter.groovy:
##########
@@ -0,0 +1,136 @@
+/*
+ *  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.injection
+
+import java.lang.reflect.Modifier
+
+import groovy.transform.CompileStatic
+import org.codehaus.groovy.ast.ClassNode
+
+import org.springframework.core.CollectionFactory
+
+import org.apache.grails.gradle.common.PropertyFileUtils
+
+/**
+ * Writes factory registrations for compiled classes into a factories file in 
the compilation
+ * target directory, merging any existing entries from previous compilation 
runs and from
+ * hand-authored source registrations. The factories file location is supplied 
by the caller,
+ * so the writer is shared by transformations targeting different registration 
files
+ * (e.g. {@code META-INF/grails.factories} and {@code 
META-INF/grails-cli.factories}).
+ *
+ * @since 8.0
+ */
+@CompileStatic
+class FactoriesFileWriter {
+
+    /**
+     * Registers the class as an implementation of the given super type in the 
factories file
+     * when the class is a non-abstract subtype.
+     *
+     * @param classNode the compiled class
+     * @param superType the factory type to register the class under
+     * @param compilationTargetDirectory the compilation output directory
+     * @param factoriesLocation the factories file path relative to the target 
directory
+     * @param sourceFactoriesLocations project-relative paths of hand-authored 
factories files to merge
+     * @return {@code true} when the class was a subtype of the factory type
+     */
+    static boolean updateFactoriesWithType(ClassNode classNode, ClassNode 
superType, File compilationTargetDirectory,
+                                           String factoriesLocation, 
List<String> sourceFactoriesLocations) {
+        if (GrailsASTUtils.isSubclassOfOrImplementsInterface(classNode, 
superType)) {
+            if (Modifier.isAbstract(classNode.getModifiers())) {
+                return false
+            }
+
+            def classNodeName = classNode.name
+            // Use SortedProperties to ensure a consistent order of entries 
for reproducible builds
+            def props = CollectionFactory.createSortedProperties(false)
+            def superTypeName = superType.getName()
+
+            File factoriesFile = new File(compilationTargetDirectory, 
factoriesLocation)
+            if (!factoriesFile.parentFile.exists()) {
+                factoriesFile.parentFile.mkdirs()
+            }
+            loadFromFile(props, factoriesFile)

Review Comment:
   **Incremental compilation never removes stale entries.** This always loads 
the previously generated file and merges into it, but nothing ever prunes. When 
a command class is deleted or renamed, its old entry survives in 
`grails-cli.factories`, so an incremental build produces a *different* 
(superset) result than a clean build. At runtime that ghost entry becomes a 
`ClassNotFoundException` / failed instantiation for a command that no longer 
exists.
   
   Fix: regenerate the machine-written registrations from the current 
compilation's class set each run, merging in only the hand-authored 
`sourceFactoriesLocations` entries - don't treat the prior generated output as 
an input to merge.



##########
grails-core/src/main/groovy/org/grails/compiler/injection/FactoriesFileWriter.groovy:
##########
@@ -0,0 +1,136 @@
+/*
+ *  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.injection
+
+import java.lang.reflect.Modifier
+
+import groovy.transform.CompileStatic
+import org.codehaus.groovy.ast.ClassNode
+
+import org.springframework.core.CollectionFactory
+
+import org.apache.grails.gradle.common.PropertyFileUtils
+
+/**
+ * Writes factory registrations for compiled classes into a factories file in 
the compilation
+ * target directory, merging any existing entries from previous compilation 
runs and from
+ * hand-authored source registrations. The factories file location is supplied 
by the caller,
+ * so the writer is shared by transformations targeting different registration 
files
+ * (e.g. {@code META-INF/grails.factories} and {@code 
META-INF/grails-cli.factories}).
+ *
+ * @since 8.0
+ */
+@CompileStatic
+class FactoriesFileWriter {
+
+    /**
+     * Registers the class as an implementation of the given super type in the 
factories file
+     * when the class is a non-abstract subtype.
+     *
+     * @param classNode the compiled class
+     * @param superType the factory type to register the class under
+     * @param compilationTargetDirectory the compilation output directory
+     * @param factoriesLocation the factories file path relative to the target 
directory
+     * @param sourceFactoriesLocations project-relative paths of hand-authored 
factories files to merge
+     * @return {@code true} when the class was a subtype of the factory type
+     */
+    static boolean updateFactoriesWithType(ClassNode classNode, ClassNode 
superType, File compilationTargetDirectory,
+                                           String factoriesLocation, 
List<String> sourceFactoriesLocations) {
+        if (GrailsASTUtils.isSubclassOfOrImplementsInterface(classNode, 
superType)) {
+            if (Modifier.isAbstract(classNode.getModifiers())) {
+                return false
+            }
+
+            def classNodeName = classNode.name
+            // Use SortedProperties to ensure a consistent order of entries 
for reproducible builds
+            def props = CollectionFactory.createSortedProperties(false)
+            def superTypeName = superType.getName()
+
+            File factoriesFile = new File(compilationTargetDirectory, 
factoriesLocation)
+            if (!factoriesFile.parentFile.exists()) {
+                factoriesFile.parentFile.mkdirs()
+            }
+            loadFromFile(props, factoriesFile)
+
+            File sourceDirectory = 
findSourceDirectory(compilationTargetDirectory)
+            if (sourceDirectory != null) {
+                for (String sourceFactoriesLocation : 
sourceFactoriesLocations) {
+                    File sourceFactoriesFile = new File(sourceDirectory, 
sourceFactoriesLocation)
+                    loadFromFile(props, sourceFactoriesFile)
+                }
+            }
+
+            addToProps(props, superTypeName, classNodeName)
+
+            factoriesFile.withWriter { Writer writer ->
+                props.store(writer, 'Grails Factories File')
+            }
+
+            PropertyFileUtils.makePropertiesFileReproducible(factoriesFile)
+
+            return true
+        }
+        return false
+    }
+
+    private static void loadFromFile(Properties props, File factoriesFile) {
+        if (factoriesFile.exists()) {
+            Properties fileProps = new Properties()
+            factoriesFile.withInputStream { InputStream input ->
+                fileProps.load(input)
+                fileProps.each { Map.Entry prop ->
+                    addToProps(props, (String) prop.key, (String) prop.value)
+                }
+            }
+        }
+    }
+
+    private static Properties addToProps(Properties props, String 
superTypeName, String classNodeNames) {
+        final List<String> classNodesNameList = classNodeNames.tokenize(',')
+        classNodesNameList.forEach(classNodeName -> {
+            String existing = props.getProperty(superTypeName)
+            if (!existing) {
+                props.put(superTypeName, classNodeName)
+            } else if (existing && !existing.contains(classNodeName)) {

Review Comment:
   **Dedup uses substring matching, which drops distinct registrations.** 
`existing.contains(classNodeName)` is a substring test, not an exact-membership 
test. If `com.example.FooCommand` is already registered and `com.example.Foo` 
is added (or vice-versa - e.g. `MyCommand` vs `MyCommand2`), `contains` returns 
true and the second, distinct command is **silently not registered**. Any two 
commands whose FQCNs are prefixes of one another collide.
   
   Fix: split the existing value on `,`, trim into a `Set`, and test exact 
membership before joining:
   ```groovy
   private static void addToProps(Properties props, String superTypeName, 
String classNodeNames) {
       def names = new LinkedHashSet<String>()
       def existing = props.getProperty(superTypeName)
       if (existing) names.addAll(existing.tokenize(',')*.trim())
       classNodeNames.tokenize(',')*.trim().each { names.add(it) }
       props.put(superTypeName, names.join(','))
   }
   ```



##########
grails-core/src/main/groovy/org/grails/compiler/injection/FactoriesFileWriter.groovy:
##########
@@ -0,0 +1,136 @@
+/*
+ *  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.injection
+
+import java.lang.reflect.Modifier
+
+import groovy.transform.CompileStatic
+import org.codehaus.groovy.ast.ClassNode
+
+import org.springframework.core.CollectionFactory
+
+import org.apache.grails.gradle.common.PropertyFileUtils
+
+/**
+ * Writes factory registrations for compiled classes into a factories file in 
the compilation
+ * target directory, merging any existing entries from previous compilation 
runs and from
+ * hand-authored source registrations. The factories file location is supplied 
by the caller,
+ * so the writer is shared by transformations targeting different registration 
files
+ * (e.g. {@code META-INF/grails.factories} and {@code 
META-INF/grails-cli.factories}).
+ *
+ * @since 8.0
+ */
+@CompileStatic
+class FactoriesFileWriter {
+
+    /**
+     * Registers the class as an implementation of the given super type in the 
factories file
+     * when the class is a non-abstract subtype.
+     *
+     * @param classNode the compiled class
+     * @param superType the factory type to register the class under
+     * @param compilationTargetDirectory the compilation output directory
+     * @param factoriesLocation the factories file path relative to the target 
directory
+     * @param sourceFactoriesLocations project-relative paths of hand-authored 
factories files to merge
+     * @return {@code true} when the class was a subtype of the factory type
+     */
+    static boolean updateFactoriesWithType(ClassNode classNode, ClassNode 
superType, File compilationTargetDirectory,
+                                           String factoriesLocation, 
List<String> sourceFactoriesLocations) {
+        if (GrailsASTUtils.isSubclassOfOrImplementsInterface(classNode, 
superType)) {
+            if (Modifier.isAbstract(classNode.getModifiers())) {
+                return false
+            }
+
+            def classNodeName = classNode.name
+            // Use SortedProperties to ensure a consistent order of entries 
for reproducible builds
+            def props = CollectionFactory.createSortedProperties(false)
+            def superTypeName = superType.getName()
+
+            File factoriesFile = new File(compilationTargetDirectory, 
factoriesLocation)
+            if (!factoriesFile.parentFile.exists()) {
+                factoriesFile.parentFile.mkdirs()
+            }
+            loadFromFile(props, factoriesFile)
+
+            File sourceDirectory = 
findSourceDirectory(compilationTargetDirectory)
+            if (sourceDirectory != null) {
+                for (String sourceFactoriesLocation : 
sourceFactoriesLocations) {
+                    File sourceFactoriesFile = new File(sourceDirectory, 
sourceFactoriesLocation)
+                    loadFromFile(props, sourceFactoriesFile)
+                }
+            }
+
+            addToProps(props, superTypeName, classNodeName)
+
+            factoriesFile.withWriter { Writer writer ->

Review Comment:
   **Read-modify-write is neither synchronized nor atomic.** Each invocation 
reads the file, mutates, and rewrites it in place (L65-85). Under parallel 
compilation (multiple classes / source units, or forked compile processes) 
targeting the same output directory, two writers can interleave and lose 
registrations or leave a partially written factories file. Also note the 
`withWriter` here uses the platform-default charset while reads go through 
byte-oriented `Properties.load`.
   
   Fix: serialize updates per canonical output file, and write to a temp file 
followed by an atomic rename (`Files.move(..., ATOMIC_MOVE)`); pin the charset 
to UTF-8 on both read and write.



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/commands/GrailsCliGradlePlugin.groovy:
##########
@@ -0,0 +1,435 @@
+/*
+ *  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.gradle.plugin.commands
+
+import java.util.jar.JarFile
+
+import groovy.transform.CompileDynamic
+import groovy.transform.CompileStatic
+
+import org.gradle.api.NamedDomainObjectProvider
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.artifacts.Configuration
+import org.gradle.api.artifacts.ConfigurationContainer
+import org.gradle.api.artifacts.Dependency
+import org.gradle.api.artifacts.DependencySet
+import org.gradle.api.artifacts.component.ProjectComponentIdentifier
+import org.gradle.api.artifacts.result.ResolvedArtifactResult
+import org.gradle.api.file.FileCollection
+import org.gradle.api.tasks.JavaExec
+import org.gradle.api.tasks.SourceSet
+import org.gradle.api.tasks.TaskContainer
+import org.gradle.api.tasks.TaskProvider
+
+import grails.util.Environment
+import grails.util.GrailsNameUtils
+import org.grails.gradle.plugin.core.GrailsExtension
+import org.grails.gradle.plugin.core.GrailsGradlePlugin
+import org.grails.gradle.plugin.util.ClasspathUtils
+import org.grails.gradle.plugin.util.SourceSets
+import org.grails.io.support.FactoriesLoaderSupport
+import org.grails.build.parsing.CommandLineParser
+
+/**
+ * Configures the CLI tier of a Grails application or plugin project: the 
{@code grailsCli}
+ * configurations (with automatic discovery of companion {@code -cli} 
artifacts advertised by the
+ * dependency graph), the per-command tasks, the generic {@code 
runCommand}/{@code runScript}
+ * tasks, and the interactive {@code console}/{@code shell} tasks. Applied 
automatically by the
+ * Grails Gradle plugin.
+ *
+ * @since 8.0
+ */
+@CompileStatic
+class GrailsCliGradlePlugin implements Plugin<Project> {
+
+    public static final String APPLICATION_CONTEXT_COMMAND_CLASS = 
'org.apache.grails.core.cli.ApplicationCommand'
+
+    /**
+     * The dependency bucket carrying CLI-only dependencies (Grails commands 
and their libraries):
+     * compile-visible so `grails-app/commands` sources compile against the 
cli-only contract, on
+     * the command-runner classpath, but never on `runtimeClasspath`, 
`bootRun`, or packaged
+     * artifacts.
+     */
+    public static final String GRAILS_CLI_CONFIGURATION = 'grailsCli'
+
+    /** The resolvable view of {@link #GRAILS_CLI_CONFIGURATION} used by the 
command-runner tasks */
+    public static final String GRAILS_CLI_CLASSPATH_CONFIGURATION = 
'grailsCliClasspath'
+
+    /**
+     * Set this project property to {@code false} to stop the plugin from 
auto-provisioning the
+     * CLI tier onto {@link #GRAILS_CLI_CONFIGURATION}; equivalent to
+     * {@code grails { cliAutoProvision = false }}.
+     */
+    public static final String GRAILS_CLI_AUTO_PROVISION_PROPERTY = 
'grailsCliAutoProvision'
+
+    /** The internal probe configuration used to detect companion `-cli` 
modules */
+    public static final String GRAILS_CLI_DETECT_CONFIGURATION = 
'grailsCliDetect'
+
+    @Override
+    void apply(Project project) {
+        // self-sufficient when applied standalone: the auto-provisioning 
behavior is configured
+        // through the `grails` extension, normally registered by the Grails 
Gradle plugin
+        if (project.extensions.findByName('grails') == null) {
+            project.extensions.create('grails', GrailsExtension, project)
+        }
+
+        configureGrailsCliConfiguration(project)
+
+        configureConsoleTask(project)
+
+        configureApplicationCommands(project)
+
+        configureRunScript(project)
+
+        configureRunCommand(project)
+    }
+
+    /**
+     * Registers the `grailsCli` dependency bucket and its resolvable 
`grailsCliClasspath` view.
+     * `grailsCli` carries the CLI tier — command companion artifacts 
(`<artifactId>-cli`) and the
+     * libraries they need. It extends the compile classpaths (the same wiring 
`compileOnly` uses)
+     * so `grails-app/commands` sources compile against the cli-only contract, 
while staying off
+     * `runtimeClasspath` — and therefore out of `bootRun`, `bootJar`, and 
`bootWar`.
+     */
+    protected void configureGrailsCliConfiguration(Project project) {
+        ConfigurationContainer configurations = project.configurations
+        if (configurations.names.contains(GRAILS_CLI_CONFIGURATION)) {
+            return
+        }
+
+        Configuration grailsCli = 
configurations.create(GRAILS_CLI_CONFIGURATION)
+        grailsCli.canBeResolved = false
+        grailsCli.canBeConsumed = false
+        grailsCli.description = 'CLI-only dependencies (Grails commands and 
the libraries they need); compile-visible and on the command-runner classpath, 
but never on runtimeClasspath, bootRun, or packaged artifacts.'
+
+        // compile visibility for grails-app/commands sources plus the TEST 
classpaths (tests
+        // exercise commands inside the test JVM), while the main 
runtimeClasspath — and therefore
+        // bootRun, bootJar, and bootWar — never sees the cli tier; matching 
configurations that
+        // appear later (e.g. the integrationTest pair) are included as they 
are created
+        configurations.matching { Configuration it ->
+            it.name in ['compileClasspath', 'testCompileClasspath', 
'testRuntimeClasspath',
+                        'integrationTestCompileClasspath', 
'integrationTestRuntimeClasspath']
+        }.configureEach { Configuration it ->
+            it.extendsFrom(grailsCli)
+        }
+
+        Configuration grailsCliClasspath = 
configurations.create(GRAILS_CLI_CLASSPATH_CONFIGURATION)
+        grailsCliClasspath.extendsFrom(grailsCli)
+        grailsCliClasspath.canBeResolved = true
+        grailsCliClasspath.canBeConsumed = false
+        grailsCliClasspath.description = 'Resolvable view of grailsCli used by 
the command-runner tasks.'
+
+        Configuration grailsCliDetect = 
configurations.create(GRAILS_CLI_DETECT_CONFIGURATION)
+        grailsCliDetect.canBeResolved = true
+        grailsCliDetect.canBeConsumed = false
+        grailsCliDetect.visible = false
+        grailsCliDetect.description = 'Internal probe used to discover 
companion -cli artifacts advertised by dependencies.'
+        for (String bucket : ['api', 'implementation', 'runtimeOnly']) {
+            configurations.matching { Configuration it -> it.name == bucket 
}.configureEach { Configuration it ->
+                grailsCliDetect.extendsFrom(it)
+            }
+        }
+
+        // computed when the configuration is first resolved, so every 
dependency (and the
+        // extension configuration) declared by the build script is visible
+        grailsCli.withDependencies { DependencySet dependencies ->
+            autoProvisionCliDependencies(project, dependencies)
+        }
+    }
+
+    /**
+     * Auto-provisions the CLI tier onto {@code grailsCli}: the command 
contract and runner, plus
+     * every companion {@code -cli} artifact advertised by a dependency of the 
application. A
+     * module advertises its companion through the {@code Grails-Cli-Artifact} 
manifest attribute
+     * of its runtime jar (stamped by the framework's cli-artifact build 
convention; third-party
+     * plugins set it on their jar task). Discovery walks the full dependency 
graph (including
+     * transitive plugins) through a lenient resolution of an internal probe 
configuration.
+     * Disable with {@code grails { cliAutoProvision = false }}.
+     */
+    @CompileDynamic
+    protected void autoProvisionCliDependencies(Project project, DependencySet 
dependencies) {
+        GrailsExtension grails = project.extensions.getByType(GrailsExtension)
+        if (!grails.cliAutoProvision.get()) {
+            return
+        }
+
+        // command authoring and execution work out of the box: the command 
contract + the runner
+        
dependencies.add(project.dependencies.create('org.apache.grails:grails-core-cli'))
+        
dependencies.add(project.dependencies.create('org.apache.grails:grails-console'))
+
+        Configuration probe = 
project.configurations.getByName(GRAILS_CLI_DETECT_CONFIGURATION)
+        Set<String> companions = [] as Set
+        def lenientArtifacts = probe.incoming.artifactView { it.lenient(true) 
}.artifacts

Review Comment:
   **Eager dependency resolution at configuration time / configuration-cache 
hostility.** The `grailsCli.withDependencies { 
autoProvisionCliDependencies(...) }` callback (L152-154) resolves the 
`grailsCliDetect` probe graph and opens every dependency jar's manifest here, 
and `configureApplicationCommands` (L235-247) additionally realizes 
`grailsCliClasspath` artifact files inside `afterEvaluate`. This means 
**every** build - even `./gradlew help` or a task that never touches the CLI - 
pays full dependency resolution + jar I/O, defeats task-configuration 
avoidance, and is fragile under the Gradle 9.6 configuration cache 
(project/artifact access at the wrong time).
   
   Fix: model command discovery as lazy task inputs (a `Provider<Set<String>>` 
fed by an `ArtifactView`/`FileCollection`) resolved at execution time, and 
avoid resolving configurations while registering tasks. Recommend adding a 
`--configuration-cache` functional test to lock this down.



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/commands/GrailsCliGradlePlugin.groovy:
##########
@@ -0,0 +1,435 @@
+/*
+ *  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.gradle.plugin.commands
+
+import java.util.jar.JarFile
+
+import groovy.transform.CompileDynamic
+import groovy.transform.CompileStatic
+
+import org.gradle.api.NamedDomainObjectProvider
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.artifacts.Configuration
+import org.gradle.api.artifacts.ConfigurationContainer
+import org.gradle.api.artifacts.Dependency
+import org.gradle.api.artifacts.DependencySet
+import org.gradle.api.artifacts.component.ProjectComponentIdentifier
+import org.gradle.api.artifacts.result.ResolvedArtifactResult
+import org.gradle.api.file.FileCollection
+import org.gradle.api.tasks.JavaExec
+import org.gradle.api.tasks.SourceSet
+import org.gradle.api.tasks.TaskContainer
+import org.gradle.api.tasks.TaskProvider
+
+import grails.util.Environment
+import grails.util.GrailsNameUtils
+import org.grails.gradle.plugin.core.GrailsExtension
+import org.grails.gradle.plugin.core.GrailsGradlePlugin
+import org.grails.gradle.plugin.util.ClasspathUtils
+import org.grails.gradle.plugin.util.SourceSets
+import org.grails.io.support.FactoriesLoaderSupport
+import org.grails.build.parsing.CommandLineParser
+
+/**
+ * Configures the CLI tier of a Grails application or plugin project: the 
{@code grailsCli}
+ * configurations (with automatic discovery of companion {@code -cli} 
artifacts advertised by the
+ * dependency graph), the per-command tasks, the generic {@code 
runCommand}/{@code runScript}
+ * tasks, and the interactive {@code console}/{@code shell} tasks. Applied 
automatically by the
+ * Grails Gradle plugin.
+ *
+ * @since 8.0
+ */
+@CompileStatic
+class GrailsCliGradlePlugin implements Plugin<Project> {
+
+    public static final String APPLICATION_CONTEXT_COMMAND_CLASS = 
'org.apache.grails.core.cli.ApplicationCommand'
+
+    /**
+     * The dependency bucket carrying CLI-only dependencies (Grails commands 
and their libraries):
+     * compile-visible so `grails-app/commands` sources compile against the 
cli-only contract, on
+     * the command-runner classpath, but never on `runtimeClasspath`, 
`bootRun`, or packaged
+     * artifacts.
+     */
+    public static final String GRAILS_CLI_CONFIGURATION = 'grailsCli'
+
+    /** The resolvable view of {@link #GRAILS_CLI_CONFIGURATION} used by the 
command-runner tasks */
+    public static final String GRAILS_CLI_CLASSPATH_CONFIGURATION = 
'grailsCliClasspath'
+
+    /**
+     * Set this project property to {@code false} to stop the plugin from 
auto-provisioning the
+     * CLI tier onto {@link #GRAILS_CLI_CONFIGURATION}; equivalent to
+     * {@code grails { cliAutoProvision = false }}.
+     */
+    public static final String GRAILS_CLI_AUTO_PROVISION_PROPERTY = 
'grailsCliAutoProvision'
+
+    /** The internal probe configuration used to detect companion `-cli` 
modules */
+    public static final String GRAILS_CLI_DETECT_CONFIGURATION = 
'grailsCliDetect'
+
+    @Override
+    void apply(Project project) {
+        // self-sufficient when applied standalone: the auto-provisioning 
behavior is configured
+        // through the `grails` extension, normally registered by the Grails 
Gradle plugin
+        if (project.extensions.findByName('grails') == null) {
+            project.extensions.create('grails', GrailsExtension, project)
+        }
+
+        configureGrailsCliConfiguration(project)
+
+        configureConsoleTask(project)
+
+        configureApplicationCommands(project)
+
+        configureRunScript(project)
+
+        configureRunCommand(project)
+    }
+
+    /**
+     * Registers the `grailsCli` dependency bucket and its resolvable 
`grailsCliClasspath` view.
+     * `grailsCli` carries the CLI tier — command companion artifacts 
(`<artifactId>-cli`) and the
+     * libraries they need. It extends the compile classpaths (the same wiring 
`compileOnly` uses)
+     * so `grails-app/commands` sources compile against the cli-only contract, 
while staying off
+     * `runtimeClasspath` — and therefore out of `bootRun`, `bootJar`, and 
`bootWar`.
+     */
+    protected void configureGrailsCliConfiguration(Project project) {
+        ConfigurationContainer configurations = project.configurations
+        if (configurations.names.contains(GRAILS_CLI_CONFIGURATION)) {
+            return
+        }
+
+        Configuration grailsCli = 
configurations.create(GRAILS_CLI_CONFIGURATION)
+        grailsCli.canBeResolved = false
+        grailsCli.canBeConsumed = false
+        grailsCli.description = 'CLI-only dependencies (Grails commands and 
the libraries they need); compile-visible and on the command-runner classpath, 
but never on runtimeClasspath, bootRun, or packaged artifacts.'
+
+        // compile visibility for grails-app/commands sources plus the TEST 
classpaths (tests
+        // exercise commands inside the test JVM), while the main 
runtimeClasspath — and therefore
+        // bootRun, bootJar, and bootWar — never sees the cli tier; matching 
configurations that
+        // appear later (e.g. the integrationTest pair) are included as they 
are created
+        configurations.matching { Configuration it ->
+            it.name in ['compileClasspath', 'testCompileClasspath', 
'testRuntimeClasspath',
+                        'integrationTestCompileClasspath', 
'integrationTestRuntimeClasspath']
+        }.configureEach { Configuration it ->
+            it.extendsFrom(grailsCli)
+        }
+
+        Configuration grailsCliClasspath = 
configurations.create(GRAILS_CLI_CLASSPATH_CONFIGURATION)
+        grailsCliClasspath.extendsFrom(grailsCli)
+        grailsCliClasspath.canBeResolved = true
+        grailsCliClasspath.canBeConsumed = false
+        grailsCliClasspath.description = 'Resolvable view of grailsCli used by 
the command-runner tasks.'
+
+        Configuration grailsCliDetect = 
configurations.create(GRAILS_CLI_DETECT_CONFIGURATION)
+        grailsCliDetect.canBeResolved = true
+        grailsCliDetect.canBeConsumed = false
+        grailsCliDetect.visible = false
+        grailsCliDetect.description = 'Internal probe used to discover 
companion -cli artifacts advertised by dependencies.'
+        for (String bucket : ['api', 'implementation', 'runtimeOnly']) {
+            configurations.matching { Configuration it -> it.name == bucket 
}.configureEach { Configuration it ->
+                grailsCliDetect.extendsFrom(it)
+            }
+        }
+
+        // computed when the configuration is first resolved, so every 
dependency (and the
+        // extension configuration) declared by the build script is visible
+        grailsCli.withDependencies { DependencySet dependencies ->
+            autoProvisionCliDependencies(project, dependencies)
+        }
+    }
+
+    /**
+     * Auto-provisions the CLI tier onto {@code grailsCli}: the command 
contract and runner, plus
+     * every companion {@code -cli} artifact advertised by a dependency of the 
application. A
+     * module advertises its companion through the {@code Grails-Cli-Artifact} 
manifest attribute
+     * of its runtime jar (stamped by the framework's cli-artifact build 
convention; third-party
+     * plugins set it on their jar task). Discovery walks the full dependency 
graph (including
+     * transitive plugins) through a lenient resolution of an internal probe 
configuration.
+     * Disable with {@code grails { cliAutoProvision = false }}.
+     */
+    @CompileDynamic
+    protected void autoProvisionCliDependencies(Project project, DependencySet 
dependencies) {
+        GrailsExtension grails = project.extensions.getByType(GrailsExtension)
+        if (!grails.cliAutoProvision.get()) {
+            return
+        }
+
+        // command authoring and execution work out of the box: the command 
contract + the runner
+        
dependencies.add(project.dependencies.create('org.apache.grails:grails-core-cli'))
+        
dependencies.add(project.dependencies.create('org.apache.grails:grails-console'))
+
+        Configuration probe = 
project.configurations.getByName(GRAILS_CLI_DETECT_CONFIGURATION)
+        Set<String> companions = [] as Set
+        def lenientArtifacts = probe.incoming.artifactView { it.lenient(true) 
}.artifacts
+        for (ResolvedArtifactResult artifact : lenientArtifacts.artifacts) {
+            String companion = findAdvertisedCliArtifact(project, artifact)
+            if (companion) {
+                companions.add(companion)
+            }
+        }
+
+        for (String companion : companions) {
+            List<String> coordinate = companion.tokenize(':')
+            if (coordinate.size() != 2) {
+                project.logger.warn('Ignoring malformed Grails-Cli-Artifact 
value [{}] found in the dependencies of project {}', companion, project.name)
+                continue
+            }
+            boolean alreadyDeclared = dependencies.any { Dependency existing ->
+                existing.group == coordinate[0] && existing.name == 
coordinate[1]
+            }
+            if (!alreadyDeclared) {
+                project.logger.info('Detected cli companion artifact {}, 
adding it to the {} configuration of project {}',
+                        companion, GRAILS_CLI_CONFIGURATION, project.name)
+                dependencies.add(project.dependencies.create(companion))
+            }
+        }
+    }
+
+    /**
+     * Returns the companion cli coordinate ({@code group:artifactId}) 
advertised by the given
+     * resolved artifact, or {@code null}. For artifacts produced by a project 
of the same build
+     * the (possibly not yet built) jar is not read — the coordinate comes 
from the project's
+     * {@code cliArtifactId} property exported by the cli-artifact convention.
+     */
+    @CompileDynamic
+    protected String findAdvertisedCliArtifact(Project project, 
ResolvedArtifactResult artifact) {
+        def componentIdentifier = artifact.id.componentIdentifier
+        if (componentIdentifier instanceof ProjectComponentIdentifier) {
+            Project target = 
project.rootProject.findProject(((ProjectComponentIdentifier) 
componentIdentifier).projectPath)
+            def cliArtifactId = target?.findProperty('cliArtifactId')

Review Comment:
   **Project-dependency companion discovery has an evaluation-order race.** For 
a same-build project dependency this reads the sibling project's 
`cliArtifactId` extra property - but `GrailsCliArtifactGradlePlugin` only sets 
that property inside *its own* `afterEvaluate`. A consumer project (e.g. the 
application, or an aggregating module) that is evaluated before the producing 
subproject will observe `cliArtifactId == null`, this branch returns `null` 
without falling back to reading the jar manifest, and the companion (plus its 
per-command tasks) is **silently omitted**. This bites composite / 
multi-project builds - including this repo's own modules - even though the 
published-jar path works.
   
   Fix: defer the project-companion lookup until all projects are evaluated 
(`gradle.projectsEvaluated`), or expose the companion coordinate as a lazy 
`Provider` / published variant that can be queried without depending on 
`afterEvaluate` ordering.



##########
grails-bom/base/build.gradle:
##########
@@ -97,6 +97,23 @@ dependencies {
     }
 }
 
+// Companion cli artifacts (published by the cli-artifact convention plugin) 
are additional
+// publications of existing projects, so the subproject enumeration above 
cannot see them. Each
+// applying project exports its companion coordinate via the `cliArtifactId` 
extra property, which
+// only exists once that project has been evaluated — so the constraints are 
computed lazily, in
+// the mutation window Gradle provides right before the configuration is first 
observed.
+configurations.named('api').configure { apiConfiguration ->

Review Comment:
   **Companion `-cli` constraints are missing for `enforcedPlatform` consumers 
of the derived BOMs.** This block adds the companion constraints dynamically 
only to the `api` configuration of `grails-bom/base`. The derived BOMs 
(`grails-bom/default`, `grails-bom/hibernate5`, ...) that consumers typically 
import via `enforcedPlatform(...)` re-declare only the *static* base 
constraints, so they do not carry these companion constraints in their own 
direct `constraints` block. Because `autoProvisionCliDependencies` adds 
`org.apache.grails:grails-core-cli` / `...-cli` with **no version**, an app 
that pins versions through `enforcedPlatform(grails-bom)` can end up with an 
unversioned `*-cli` dependency and a resolution failure - a consumer-facing 
break, contrary to AGENTS.md rule 14.
   
   (Verified separately: the generated `grails-base-bom` POM does contain all 9 
companions at `8.0.0-SNAPSHOT`; the gap is specifically the derived BOMs used 
through `enforcedPlatform`.)
   
   Fix: share/reapply the companion-constraint enumeration in every derived 
BOM's direct `constraints` block, and add a resolution test using 
`enforcedPlatform(grails-bom)` that asserts each `*-cli` resolves to a version.



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