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]
