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

jamesfredley pushed a commit to branch refactor/runtime-logging-slf4j
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 250380367ff9e096953924ffc73be27ad71ac59a
Author: James Fredley <[email protected]>
AuthorDate: Fri Jul 17 06:15:11 2026 -0400

    Use SLF4J for runtime diagnostics
    
    Assisted-by: opencode:gpt-5.6-sol
---
 .../groovy/grails/build/logging/GrailsConsole.java |  10 +-
 .../build/logging/GrailsConsoleLoggingSpec.groovy  | 115 ++++++++++++
 .../plugins/ProfilingGrailsPluginManager.java      |  39 ++---
 .../ProfilingGrailsPluginManagerSpec.groovy        |  84 +++++++++
 grails-data-hibernate5/grails-plugin/build.gradle  |   3 +-
 .../hibernate/commands/SchemaExportCommand.groovy  |  23 ++-
 .../commands/SchemaExportCommandSpec.groovy        | 139 +++++++++++++++
 grails-data-hibernate7/grails-plugin/build.gradle  |   1 +
 .../hibernate/commands/SchemaExportCommand.groovy  |  23 ++-
 .../commands/SchemaExportCommandSpec.groovy        | 139 +++++++++++++++
 grails-forge/grails-forge-core/build.gradle        |   3 +-
 .../dependencies/PomDependencyVersionResolver.java |   6 +-
 .../PomDependencyVersionResolverSpec.groovy        |  39 ++++-
 grails-shell-cli/build.gradle                      |   1 +
 .../main/groovy/org/grails/cli/GrailsCli.groovy    |   6 +-
 .../cli/command/run/SpringApplicationRunner.java   |  25 ++-
 .../org/grails/cli/GrailsCliLoggingVerifier.groovy |  63 +++++++
 .../groovy/org/grails/cli/GrailsCliSpec.groovy     |  94 ++++++++++
 .../cli/command/run/LifecycleVerifier.groovy       | 195 +++++++++++++++++++++
 .../command/run/SpringApplicationRunnerSpec.groovy | 114 ++++++++++++
 .../spring-boot-dependencies-effective-bom.xml     |   9 +
 21 files changed, 1097 insertions(+), 34 deletions(-)

diff --git 
a/grails-bootstrap/src/main/groovy/grails/build/logging/GrailsConsole.java 
b/grails-bootstrap/src/main/groovy/grails/build/logging/GrailsConsole.java
index 2641e487ff..59bb0aad0e 100644
--- a/grails-bootstrap/src/main/groovy/grails/build/logging/GrailsConsole.java
+++ b/grails-bootstrap/src/main/groovy/grails/build/logging/GrailsConsole.java
@@ -44,6 +44,8 @@ import org.jline.reader.impl.completer.AggregateCompleter;
 import org.jline.reader.impl.history.DefaultHistory;
 import org.jline.terminal.Terminal;
 import org.jline.terminal.TerminalBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import grails.util.Environment;
 import org.grails.build.logging.GrailsConsoleErrorPrintStream;
@@ -63,6 +65,8 @@ import static org.fusesource.jansi.Ansi.ansi;
  */
 public class GrailsConsole implements ConsoleLogger {
 
+    private static final Logger LOG = 
LoggerFactory.getLogger(GrailsConsole.class);
+
     private static GrailsConsole instance;
 
     public static final String ENABLE_TERMINAL = 
"grails.console.enable.terminal";
@@ -410,7 +414,11 @@ public class GrailsConsole implements ConsoleLogger {
                 Class<? extends GrailsConsole> klass = (Class<? extends 
GrailsConsole>) Class.forName(className);
                 return klass.getDeclaredConstructor().newInstance();
             } catch (Exception e) {
-                e.printStackTrace();
+                if (LOG.isErrorEnabled()) {
+                    LOG.error("Unable to create configured Grails console " + 
className, e);
+                } else {
+                    e.printStackTrace();
+                }
             }
         }
         return new GrailsConsole();
diff --git 
a/grails-bootstrap/src/test/groovy/grails/build/logging/GrailsConsoleLoggingSpec.groovy
 
b/grails-bootstrap/src/test/groovy/grails/build/logging/GrailsConsoleLoggingSpec.groovy
new file mode 100644
index 0000000000..fde3950b3e
--- /dev/null
+++ 
b/grails-bootstrap/src/test/groovy/grails/build/logging/GrailsConsoleLoggingSpec.groovy
@@ -0,0 +1,115 @@
+/*
+ *  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.build.logging
+
+import java.util.concurrent.TimeUnit
+
+import org.slf4j.LoggerFactory
+import spock.lang.Specification
+import spock.lang.TempDir
+
+class GrailsConsoleLoggingSpec extends Specification {
+
+    @TempDir
+    File tempDir
+
+    void "invalid configured console failure is visible with 
#loggingDescription"() {
+        given:
+        String output = runFixture(apiOnly)
+
+        expect:
+        apiOnly ? output.contains('ClassNotFoundException: 
invalid.console.ClassName') :
+            output.count('Unable to create configured Grails console 
invalid.console.ClassName') == 1
+        output.readLines().last() == 'verified'
+
+        where:
+        apiOnly | loggingDescription
+        true    | 'an API-only SLF4J classpath'
+        false   | 'an ERROR-capable SLF4J provider'
+    }
+
+    private String runFixture(boolean apiOnly) {
+        File argumentsFile = new File(tempDir, 
"grails-console-${apiOnly}.args")
+        File outputFile = new File(tempDir, "grails-console-${apiOnly}.output")
+        argumentsFile.text = """\
+            -cp
+            ${testRuntimeClasspath(apiOnly)}
+            ${GrailsConsoleLoggingSpec.name}
+            ${apiOnly}
+            """.stripIndent().trim()
+        Process process = new ProcessBuilder(
+            new File(System.getProperty('java.home'), 'bin/java').absolutePath,
+            "@${argumentsFile.absolutePath}"
+        ).redirectErrorStream(true).redirectOutput(outputFile).start()
+        process.outputStream.close()
+        try {
+            awaitProcess(process, 'GrailsConsole logging fixture')
+            String output = outputFile.getText('UTF-8').trim()
+            assert process.exitValue() == 0 : output
+            output
+        } finally {
+            if (process.isAlive()) {
+                process.destroyForcibly()
+                process.waitFor(5, TimeUnit.SECONDS)
+            }
+        }
+    }
+
+    private static void awaitProcess(Process process, String fixtureName) {
+        if (process.waitFor(30, TimeUnit.SECONDS)) {
+            return
+        }
+        process.destroyForcibly()
+        if (!process.waitFor(5, TimeUnit.SECONDS)) {
+            throw new AssertionError("${fixtureName} did not terminate after 
timing out")
+        }
+        throw new AssertionError("${fixtureName} timed out after 30 seconds")
+    }
+
+    private static String testRuntimeClasspath(boolean apiOnly) {
+        String[] entries = 
System.getProperty('java.class.path').split(File.pathSeparator)
+        if (!apiOnly) {
+            return entries.join(File.pathSeparator)
+        }
+        entries.findAll { !it.contains('slf4j-simple') && 
!it.contains('logback-classic') }.join(File.pathSeparator)
+    }
+
+    static void main(String[] args) {
+        boolean apiOnly = Boolean.parseBoolean(args[0])
+        System.setProperty('grails.console.class', 'invalid.console.ClassName')
+        System.setProperty(GrailsConsole.ENABLE_INTERACTIVE, 'false')
+        System.setProperty(GrailsConsole.ENABLE_TERMINAL, 'false')
+        GrailsConsole console
+        Throwable failure
+        try {
+            assert LoggerFactory.getLogger(GrailsConsole).errorEnabled == 
!apiOnly
+            console = GrailsConsole.createInstance()
+            assert console.class == GrailsConsole
+        } catch (Throwable throwable) {
+            failure = throwable
+        } finally {
+            console?.restoreOriginalSystemOutAndErr()
+        }
+        if (failure != null) {
+            failure.printStackTrace()
+            System.exit(1)
+        }
+        println 'verified'
+    }
+}
diff --git 
a/grails-core/src/main/groovy/org/grails/plugins/ProfilingGrailsPluginManager.java
 
b/grails-core/src/main/groovy/org/grails/plugins/ProfilingGrailsPluginManager.java
index 39f2a032d1..7506de88a1 100644
--- 
a/grails-core/src/main/groovy/org/grails/plugins/ProfilingGrailsPluginManager.java
+++ 
b/grails-core/src/main/groovy/org/grails/plugins/ProfilingGrailsPluginManager.java
@@ -21,11 +21,10 @@ package org.grails.plugins;
 import groovy.lang.GroovySystem;
 import groovy.lang.MetaClassRegistry;
 
-import org.apache.commons.logging.Log;
-import org.apache.commons.logging.LogFactory;
-
 import org.springframework.context.ApplicationContext;
 import org.springframework.core.io.Resource;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import grails.core.GrailsApplication;
 import grails.plugins.DefaultGrailsPluginManager;
@@ -43,7 +42,7 @@ import org.grails.spring.RuntimeSpringConfiguration;
  */
 public class ProfilingGrailsPluginManager extends DefaultGrailsPluginManager {
 
-    private static final Log LOG = 
LogFactory.getLog(DefaultGrailsPluginManager.class);
+    private static final Logger LOG = 
LoggerFactory.getLogger(DefaultGrailsPluginManager.class);
 
     public ProfilingGrailsPluginManager(GrailsApplication application, 
PluginDiscovery pluginDiscovery) {
         super(application, pluginDiscovery);
@@ -110,15 +109,15 @@ public class ProfilingGrailsPluginManager extends 
DefaultGrailsPluginManager {
     @Override
     public void loadPlugins() throws PluginException {
         long time = System.currentTimeMillis();
-        System.out.println("Loading plugins started");
+        LOG.info("Loading plugins started");
         super.loadPlugins();
-        System.out.println("Loading plugins took " + 
(System.currentTimeMillis() - time));
+        LOG.info("Loading plugins took {}", System.currentTimeMillis() - time);
     }
 
     @Override
     public void doDynamicMethods() {
         long time = System.currentTimeMillis();
-        System.out.println("doWithDynamicMethods started");
+        LOG.info("doWithDynamicMethods started");
         checkInitialised();
         // remove common meta classes just to be sure
         MetaClassRegistry registry = GroovySystem.getMetaClassRegistry();
@@ -129,57 +128,57 @@ public class ProfilingGrailsPluginManager extends 
DefaultGrailsPluginManager {
             if (plugin.supportsCurrentScopeAndEnvironment()) {
                 try {
                     long pluginTime = System.currentTimeMillis();
-                    System.out.println("doWithDynamicMethods for plugin [" + 
plugin.getName() + "] started");
+                    LOG.info("doWithDynamicMethods for plugin [{}] started", 
plugin.getName());
 
                     plugin.doWithDynamicMethods(applicationContext);
 
-                    System.out.println("doWithDynamicMethods for plugin [" + 
plugin.getName() + "] took " + (System.currentTimeMillis() - pluginTime));
+                    LOG.info("doWithDynamicMethods for plugin [{}] took {}", 
plugin.getName(), System.currentTimeMillis() - pluginTime);
                 } catch (Throwable t) {
                     throw new GrailsConfigurationException("Error configuring 
dynamic methods for plugin " + plugin + ": " + t.getMessage(), t);
                 }
             }
         }
-        System.out.println("doWithDynamicMethods took " + 
(System.currentTimeMillis() - time));
+        LOG.info("doWithDynamicMethods took {}", System.currentTimeMillis() - 
time);
     }
 
     @Override
     public void doRuntimeConfiguration(RuntimeSpringConfiguration 
springConfig) {
         long time = System.currentTimeMillis();
 
-        System.out.println("doWithSpring started");
+        LOG.info("doWithSpring started");
         checkInitialised();
         for (GrailsPlugin plugin : getAllPlugins()) {
             if (plugin.supportsCurrentScopeAndEnvironment()) {
                 long pluginTime = System.currentTimeMillis();
-                System.out.println("doWithSpring for plugin [" + 
plugin.getName() + "] started");
+                LOG.info("doWithSpring for plugin [{}] started", 
plugin.getName());
                 plugin.doWithRuntimeConfiguration(springConfig);
-                System.out.println("doWithSpring for plugin [" + 
plugin.getName() + "] took " + (System.currentTimeMillis() - pluginTime));
+                LOG.info("doWithSpring for plugin [{}] took {}", 
plugin.getName(), System.currentTimeMillis() - pluginTime);
             }
         }
-        System.out.println("doWithSpring took " + (System.currentTimeMillis() 
- time));
+        LOG.info("doWithSpring took {}", System.currentTimeMillis() - time);
     }
 
     @Override
     public void doPostProcessing(ApplicationContext ctx) {
         long time = System.currentTimeMillis();
-        System.out.println("doWithApplicationContext started");
+        LOG.info("doWithApplicationContext started");
         checkInitialised();
         for (GrailsPlugin plugin : getAllPlugins()) {
             if (plugin.supportsCurrentScopeAndEnvironment()) {
                 long pluginTime = System.currentTimeMillis();
-                System.out.println("doWithApplicationContext for plugin [" + 
plugin.getName() + "] started");
+                LOG.info("doWithApplicationContext for plugin [{}] started", 
plugin.getName());
                 plugin.doWithApplicationContext(ctx);
-                System.out.println("doWithApplicationContext for plugin [" + 
plugin.getName() + "] took " + (System.currentTimeMillis() - pluginTime));
+                LOG.info("doWithApplicationContext for plugin [{}] took {}", 
plugin.getName(), System.currentTimeMillis() - pluginTime);
             }
         }
-        System.out.println("doWithApplicationContext took " + 
(System.currentTimeMillis() - time));
+        LOG.info("doWithApplicationContext took {}", 
System.currentTimeMillis() - time);
     }
 
     @Override
     public void doArtefactConfiguration() {
         long time = System.currentTimeMillis();
-        System.out.println("doArtefactConfiguration started");
+        LOG.info("doArtefactConfiguration started");
         super.doArtefactConfiguration();
-        System.out.println("doArtefactConfiguration took " + 
(System.currentTimeMillis() - time));
+        LOG.info("doArtefactConfiguration took {}", System.currentTimeMillis() 
- time);
     }
 }
diff --git 
a/grails-core/src/test/groovy/org/grails/plugins/ProfilingGrailsPluginManagerSpec.groovy
 
b/grails-core/src/test/groovy/org/grails/plugins/ProfilingGrailsPluginManagerSpec.groovy
new file mode 100644
index 0000000000..8577b0f884
--- /dev/null
+++ 
b/grails-core/src/test/groovy/org/grails/plugins/ProfilingGrailsPluginManagerSpec.groovy
@@ -0,0 +1,84 @@
+/*
+ *  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.plugins
+
+import grails.core.DefaultGrailsApplication
+import org.apache.grails.core.plugins.DefaultPluginDiscovery
+import org.apache.grails.core.plugins.PluginDiscovery
+import org.springframework.context.support.GenericApplicationContext
+import org.springframework.core.env.StandardEnvironment
+import spock.lang.Specification
+
+class ProfilingGrailsPluginManagerSpec extends Specification {
+
+    void "plugin loading emits INFO profiling messages instead of standard 
output"() {
+        given:
+        def application = new DefaultGrailsApplication()
+        application.mainContext = new GenericApplicationContext()
+        def discovery = new DefaultPluginDiscovery(new Class<?>[0])
+        discovery.loadPluginsFromClasspath = false
+        discovery.init(new StandardEnvironment())
+        def manager = new ProfilingGrailsPluginManager(application, discovery)
+        def originalOut = System.out
+        def originalErr = System.err
+        def capturedOut = new ByteArrayOutputStream()
+        def capturedErr = new ByteArrayOutputStream()
+        System.setOut(new PrintStream(capturedOut, true))
+        System.setErr(new PrintStream(capturedErr, true))
+
+        when:
+        manager.loadPlugins()
+
+        then:
+        !capturedOut.toString().contains('Loading plugins')
+        capturedErr.toString().contains('INFO 
grails.plugins.DefaultGrailsPluginManager - Loading plugins started')
+        capturedErr.toString() ==~ /(?s).*INFO 
grails\.plugins\.DefaultGrailsPluginManager - Loading plugins took \d+.*/
+
+        cleanup:
+        System.setOut(originalOut)
+        System.setErr(originalErr)
+    }
+
+    void "deprecated constructors log their warning in the historical 
category"() {
+        given:
+        def application = new DefaultGrailsApplication()
+        def applicationContext = new GenericApplicationContext()
+        def discovery = Mock(PluginDiscovery)
+        
applicationContext.beanFactory.registerSingleton(PluginDiscovery.BEAN_NAME, 
discovery)
+        applicationContext.refresh()
+        application.mainContext = applicationContext
+        def originalErr = System.err
+        def capturedErr = new ByteArrayOutputStream()
+        System.setErr(new PrintStream(capturedErr, true))
+
+        when:
+        def manager = new ProfilingGrailsPluginManager(new Class<?>[0], 
application)
+
+        then:
+        manager
+        1 * discovery.reset()
+        1 * discovery.setPluginClasses(_)
+        1 * discovery.init(_)
+        capturedErr.toString().contains('WARN 
grails.plugins.DefaultGrailsPluginManager - Using deprecated 
DefaultGrailsPluginManager constructor.')
+
+        cleanup:
+        System.setErr(originalErr)
+        applicationContext.close()
+    }
+}
diff --git a/grails-data-hibernate5/grails-plugin/build.gradle 
b/grails-data-hibernate5/grails-plugin/build.gradle
index 30c278467d..6a402ad4b3 100644
--- a/grails-data-hibernate5/grails-plugin/build.gradle
+++ b/grails-data-hibernate5/grails-plugin/build.gradle
@@ -82,6 +82,7 @@ dependencies {
     }
     testRuntimeOnly 'org.springframework:spring-aop'
     testRuntimeOnly 'org.springframework:spring-expression'
+    testRuntimeOnly 'org.slf4j:slf4j-simple'
     testRuntimeOnly 'org.yaml:snakeyaml'
 }
 
@@ -89,4 +90,4 @@ apply {
     from 
rootProject.layout.projectDirectory.file('gradle/hibernate5-test-config.gradle')
     from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle')
     from 
rootProject.layout.projectDirectory.file('gradle/grails-extension-gradle-config.gradle')
-}
\ No newline at end of file
+}
diff --git 
a/grails-data-hibernate5/grails-plugin/src/main/groovy/grails/plugin/hibernate/commands/SchemaExportCommand.groovy
 
b/grails-data-hibernate5/grails-plugin/src/main/groovy/grails/plugin/hibernate/commands/SchemaExportCommand.groovy
index 5b6a06a7b9..dfb6f469f7 100644
--- 
a/grails-data-hibernate5/grails-plugin/src/main/groovy/grails/plugin/hibernate/commands/SchemaExportCommand.groovy
+++ 
b/grails-data-hibernate5/grails-plugin/src/main/groovy/grails/plugin/hibernate/commands/SchemaExportCommand.groovy
@@ -23,6 +23,9 @@ import groovy.transform.CompileStatic
 import org.hibernate.engine.spi.SessionFactoryImplementor
 import org.hibernate.tool.hbm2ddl.SchemaExport as HibernateSchemaExport
 import org.hibernate.tool.schema.TargetType
+import org.hibernate.tool.schema.spi.SchemaManagementException
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
 
 import grails.dev.commands.ApplicationCommand
 import grails.dev.commands.ExecutionContext
@@ -40,6 +43,8 @@ import org.grails.orm.hibernate.HibernateDatastore
 @CompileStatic
 class SchemaExportCommand implements ApplicationCommand {
 
+    private static final Logger LOG = 
LoggerFactory.getLogger(SchemaExportCommand)
+
     final String description = 'Creates a DDL file of the database schema'
     Boolean skipBootstrap = true
 
@@ -90,14 +95,28 @@ class SchemaExportCommand implements ApplicationCommand {
             targetTypes = EnumSet.of(TargetType.SCRIPT)
         }
 
-        schemaExport.execute(targetTypes, HibernateSchemaExport.Action.CREATE, 
metadata, serviceRegistry)
+        try {
+            schemaExport.execute(targetTypes, 
HibernateSchemaExport.Action.CREATE, metadata, serviceRegistry)
+        } catch (SchemaManagementException e) {
+            reportFailure(e)
+            return false
+        }
 
         if (schemaExport.exceptions) {
             def e = (Exception) schemaExport.exceptions[0]
-            e.printStackTrace()
+            reportFailure(e)
             return false
         }
         return true
     }
 
+    private static void reportFailure(Exception exception) {
+        if (LOG.errorEnabled) {
+            LOG.error('Unable to export database schema', exception)
+        }
+        else {
+            exception.printStackTrace(System.err)
+        }
+    }
+
 }
diff --git 
a/grails-data-hibernate5/grails-plugin/src/test/groovy/grails/plugin/hibernate/commands/SchemaExportCommandSpec.groovy
 
b/grails-data-hibernate5/grails-plugin/src/test/groovy/grails/plugin/hibernate/commands/SchemaExportCommandSpec.groovy
new file mode 100644
index 0000000000..3dee175e58
--- /dev/null
+++ 
b/grails-data-hibernate5/grails-plugin/src/test/groovy/grails/plugin/hibernate/commands/SchemaExportCommandSpec.groovy
@@ -0,0 +1,139 @@
+/*
+ *  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.hibernate.commands
+
+import java.util.concurrent.TimeUnit
+
+import grails.dev.commands.ExecutionContext
+import grails.gorm.annotation.Entity
+import org.grails.build.parsing.CommandLine
+import org.grails.orm.hibernate.HibernateDatastore
+import org.slf4j.LoggerFactory
+import org.springframework.context.ConfigurableApplicationContext
+import spock.lang.Specification
+import spock.lang.TempDir
+
+class SchemaExportCommandSpec extends Specification {
+
+    @TempDir
+    File tempDir
+
+    void "schema export failure is visible with #loggingDescription"() {
+        given:
+        String output = runFixture(apiOnly)
+
+        expect:
+        output.contains('result=false')
+        if (apiOnly) {
+            assert output.contains('SchemaManagementException')
+            assert !output.contains('Unable to export database schema')
+        }
+        else {
+            assert output.count('Unable to export database schema') == 1
+            assert output.contains('ERROR')
+            assert output.contains('SchemaManagementException')
+        }
+        output.readLines().last() == 'verified'
+
+        where:
+        apiOnly | loggingDescription
+        true    | 'an API-only SLF4J classpath'
+        false   | 'an ERROR-capable SLF4J provider'
+    }
+
+    private String runFixture(boolean apiOnly) {
+        File argumentsFile = new File(tempDir, "schema-export-${apiOnly}.args")
+        File outputFile = new File(tempDir, "schema-export-${apiOnly}.output")
+        argumentsFile.text = """\
+            -cp
+            ${testRuntimeClasspath(apiOnly)}
+            ${SchemaExportCommandSpec.name}
+            ${apiOnly}
+            """.stripIndent().trim()
+        Process process = new ProcessBuilder(
+            new File(System.getProperty('java.home'), 'bin/java').absolutePath,
+            "@${argumentsFile.absolutePath}"
+        ).redirectErrorStream(true).redirectOutput(outputFile).start()
+        process.outputStream.close()
+        try {
+            awaitProcess(process, 'SchemaExportCommand logging fixture')
+            String output = outputFile.getText('UTF-8').trim()
+            assert process.exitValue() == 0 : output
+            output
+        } finally {
+            if (process.isAlive()) {
+                process.destroyForcibly()
+                process.waitFor(5, TimeUnit.SECONDS)
+            }
+        }
+    }
+
+    private static void awaitProcess(Process process, String fixtureName) {
+        if (process.waitFor(60, TimeUnit.SECONDS)) {
+            return
+        }
+        process.destroyForcibly()
+        if (!process.waitFor(5, TimeUnit.SECONDS)) {
+            throw new AssertionError("${fixtureName} did not terminate after 
timing out")
+        }
+        throw new AssertionError("${fixtureName} timed out after 60 seconds")
+    }
+
+    private static String testRuntimeClasspath(boolean apiOnly) {
+        String[] entries = 
System.getProperty('java.class.path').split(File.pathSeparator)
+        if (!apiOnly) {
+            return entries.join(File.pathSeparator)
+        }
+        entries.findAll { !it.contains('slf4j-simple') && 
!it.contains('logback-classic') }.join(File.pathSeparator)
+    }
+
+    static void main(String[] args) {
+        boolean apiOnly = Boolean.parseBoolean(args[0])
+        def hibernateDatastore = new 
HibernateDatastore(Hibernate5SchemaExportLoggingEntity)
+        def applicationContext = [
+            getBean: { String name, Class type ->
+                assert name == 'hibernateDatastore'
+                assert type == HibernateDatastore
+                hibernateDatastore
+            }
+        ] as ConfigurableApplicationContext
+        def command = new SchemaExportCommand(applicationContext: 
applicationContext)
+        def targetDirectory = File.createTempDir('hibernate5-schema-export', 
'')
+        def commandLine = [
+            getRemainingArgs: { [targetDirectory.absolutePath] },
+            getUndeclaredOptions: { [:] }
+        ] as CommandLine
+        try {
+            assert LoggerFactory.getLogger(SchemaExportCommand).errorEnabled 
== !apiOnly
+            boolean result = command.handle(new ExecutionContext(commandLine))
+            println "result=${result}"
+            assert !result
+        } finally {
+            hibernateDatastore?.close()
+            targetDirectory?.deleteDir()
+        }
+        println 'verified'
+    }
+}
+
+@Entity
+class Hibernate5SchemaExportLoggingEntity {
+    Long id
+    String name
+}
diff --git a/grails-data-hibernate7/grails-plugin/build.gradle 
b/grails-data-hibernate7/grails-plugin/build.gradle
index 56e9b994d5..26616508f3 100644
--- a/grails-data-hibernate7/grails-plugin/build.gradle
+++ b/grails-data-hibernate7/grails-plugin/build.gradle
@@ -78,6 +78,7 @@ dependencies {
     testRuntimeOnly 'org.hibernate.orm:hibernate-jcache'
     testRuntimeOnly 'org.springframework:spring-aop'
     testRuntimeOnly 'org.springframework:spring-expression'
+    testRuntimeOnly 'org.slf4j:slf4j-simple'
     testRuntimeOnly 'org.yaml:snakeyaml'
 }
 
diff --git 
a/grails-data-hibernate7/grails-plugin/src/main/groovy/grails/plugin/hibernate/commands/SchemaExportCommand.groovy
 
b/grails-data-hibernate7/grails-plugin/src/main/groovy/grails/plugin/hibernate/commands/SchemaExportCommand.groovy
index 5b6a06a7b9..dfb6f469f7 100644
--- 
a/grails-data-hibernate7/grails-plugin/src/main/groovy/grails/plugin/hibernate/commands/SchemaExportCommand.groovy
+++ 
b/grails-data-hibernate7/grails-plugin/src/main/groovy/grails/plugin/hibernate/commands/SchemaExportCommand.groovy
@@ -23,6 +23,9 @@ import groovy.transform.CompileStatic
 import org.hibernate.engine.spi.SessionFactoryImplementor
 import org.hibernate.tool.hbm2ddl.SchemaExport as HibernateSchemaExport
 import org.hibernate.tool.schema.TargetType
+import org.hibernate.tool.schema.spi.SchemaManagementException
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
 
 import grails.dev.commands.ApplicationCommand
 import grails.dev.commands.ExecutionContext
@@ -40,6 +43,8 @@ import org.grails.orm.hibernate.HibernateDatastore
 @CompileStatic
 class SchemaExportCommand implements ApplicationCommand {
 
+    private static final Logger LOG = 
LoggerFactory.getLogger(SchemaExportCommand)
+
     final String description = 'Creates a DDL file of the database schema'
     Boolean skipBootstrap = true
 
@@ -90,14 +95,28 @@ class SchemaExportCommand implements ApplicationCommand {
             targetTypes = EnumSet.of(TargetType.SCRIPT)
         }
 
-        schemaExport.execute(targetTypes, HibernateSchemaExport.Action.CREATE, 
metadata, serviceRegistry)
+        try {
+            schemaExport.execute(targetTypes, 
HibernateSchemaExport.Action.CREATE, metadata, serviceRegistry)
+        } catch (SchemaManagementException e) {
+            reportFailure(e)
+            return false
+        }
 
         if (schemaExport.exceptions) {
             def e = (Exception) schemaExport.exceptions[0]
-            e.printStackTrace()
+            reportFailure(e)
             return false
         }
         return true
     }
 
+    private static void reportFailure(Exception exception) {
+        if (LOG.errorEnabled) {
+            LOG.error('Unable to export database schema', exception)
+        }
+        else {
+            exception.printStackTrace(System.err)
+        }
+    }
+
 }
diff --git 
a/grails-data-hibernate7/grails-plugin/src/test/groovy/grails/plugin/hibernate/commands/SchemaExportCommandSpec.groovy
 
b/grails-data-hibernate7/grails-plugin/src/test/groovy/grails/plugin/hibernate/commands/SchemaExportCommandSpec.groovy
new file mode 100644
index 0000000000..be98c0071a
--- /dev/null
+++ 
b/grails-data-hibernate7/grails-plugin/src/test/groovy/grails/plugin/hibernate/commands/SchemaExportCommandSpec.groovy
@@ -0,0 +1,139 @@
+/*
+ *  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.hibernate.commands
+
+import java.util.concurrent.TimeUnit
+
+import grails.dev.commands.ExecutionContext
+import grails.gorm.annotation.Entity
+import org.grails.build.parsing.CommandLine
+import org.grails.orm.hibernate.HibernateDatastore
+import org.slf4j.LoggerFactory
+import org.springframework.context.ConfigurableApplicationContext
+import spock.lang.Specification
+import spock.lang.TempDir
+
+class SchemaExportCommandSpec extends Specification {
+
+    @TempDir
+    File tempDir
+
+    void "schema export failure is visible with #loggingDescription"() {
+        given:
+        String output = runFixture(apiOnly)
+
+        expect:
+        output.contains('result=false')
+        if (apiOnly) {
+            assert output.contains('SchemaManagementException')
+            assert !output.contains('Unable to export database schema')
+        }
+        else {
+            assert output.count('Unable to export database schema') == 1
+            assert output.contains('ERROR')
+            assert output.contains('SchemaManagementException')
+        }
+        output.readLines().last() == 'verified'
+
+        where:
+        apiOnly | loggingDescription
+        true    | 'an API-only SLF4J classpath'
+        false   | 'an ERROR-capable SLF4J provider'
+    }
+
+    private String runFixture(boolean apiOnly) {
+        File argumentsFile = new File(tempDir, "schema-export-${apiOnly}.args")
+        File outputFile = new File(tempDir, "schema-export-${apiOnly}.output")
+        argumentsFile.text = """\
+            -cp
+            ${testRuntimeClasspath(apiOnly)}
+            ${SchemaExportCommandSpec.name}
+            ${apiOnly}
+            """.stripIndent().trim()
+        Process process = new ProcessBuilder(
+            new File(System.getProperty('java.home'), 'bin/java').absolutePath,
+            "@${argumentsFile.absolutePath}"
+        ).redirectErrorStream(true).redirectOutput(outputFile).start()
+        process.outputStream.close()
+        try {
+            awaitProcess(process, 'SchemaExportCommand logging fixture')
+            String output = outputFile.getText('UTF-8').trim()
+            assert process.exitValue() == 0 : output
+            output
+        } finally {
+            if (process.isAlive()) {
+                process.destroyForcibly()
+                process.waitFor(5, TimeUnit.SECONDS)
+            }
+        }
+    }
+
+    private static void awaitProcess(Process process, String fixtureName) {
+        if (process.waitFor(60, TimeUnit.SECONDS)) {
+            return
+        }
+        process.destroyForcibly()
+        if (!process.waitFor(5, TimeUnit.SECONDS)) {
+            throw new AssertionError("${fixtureName} did not terminate after 
timing out")
+        }
+        throw new AssertionError("${fixtureName} timed out after 60 seconds")
+    }
+
+    private static String testRuntimeClasspath(boolean apiOnly) {
+        String[] entries = 
System.getProperty('java.class.path').split(File.pathSeparator)
+        if (!apiOnly) {
+            return entries.join(File.pathSeparator)
+        }
+        entries.findAll { !it.contains('slf4j-simple') && 
!it.contains('logback-classic') }.join(File.pathSeparator)
+    }
+
+    static void main(String[] args) {
+        boolean apiOnly = Boolean.parseBoolean(args[0])
+        def hibernateDatastore = new 
HibernateDatastore(Hibernate7SchemaExportLoggingEntity)
+        def applicationContext = [
+            getBean: { String name, Class type ->
+                assert name == 'hibernateDatastore'
+                assert type == HibernateDatastore
+                hibernateDatastore
+            }
+        ] as ConfigurableApplicationContext
+        def command = new SchemaExportCommand(applicationContext: 
applicationContext)
+        def targetDirectory = File.createTempDir('hibernate7-schema-export', 
'')
+        def commandLine = [
+            getRemainingArgs: { [targetDirectory.absolutePath] },
+            getUndeclaredOptions: { [:] }
+        ] as CommandLine
+        try {
+            assert LoggerFactory.getLogger(SchemaExportCommand).errorEnabled 
== !apiOnly
+            boolean result = command.handle(new ExecutionContext(commandLine))
+            println "result=${result}"
+            assert !result
+        } finally {
+            hibernateDatastore?.close()
+            targetDirectory?.deleteDir()
+        }
+        println 'verified'
+    }
+}
+
+@Entity
+class Hibernate7SchemaExportLoggingEntity {
+    Long id
+    String name
+}
diff --git a/grails-forge/grails-forge-core/build.gradle 
b/grails-forge/grails-forge-core/build.gradle
index 5ae9de8d9f..58ec9e62af 100644
--- a/grails-forge/grails-forge-core/build.gradle
+++ b/grails-forge/grails-forge-core/build.gradle
@@ -73,7 +73,7 @@ dependencies {
         exclude group: 'org.apache.groovy', module: 'groovy-all'
     }
 
-    testRuntimeOnly "ch.qos.logback:logback-classic"
+    testImplementation "ch.qos.logback:logback-classic"
     testRuntimeOnly "org.objenesis:objenesis:$objenesisVersion"
     testImplementation 'org.apache.groovy:groovy-test'
 }
@@ -133,4 +133,3 @@ nohttp {
         it.dependsOn(copyGrailsWrapper, grailsVersionInfoTask)
     }
 }
-
diff --git 
a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/build/dependencies/PomDependencyVersionResolver.java
 
b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/build/dependencies/PomDependencyVersionResolver.java
index 87a9f5c1ef..0ed5422baa 100644
--- 
a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/build/dependencies/PomDependencyVersionResolver.java
+++ 
b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/build/dependencies/PomDependencyVersionResolver.java
@@ -22,6 +22,8 @@ import io.micronaut.core.annotation.NonNull;
 import io.micronaut.core.io.ResourceResolver;
 import io.micronaut.core.util.StringUtils;
 import jakarta.inject.Singleton;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.w3c.dom.Document;
 import org.w3c.dom.Node;
 import org.w3c.dom.NodeList;
@@ -41,6 +43,8 @@ import java.util.stream.Collectors;
 @Singleton
 public class PomDependencyVersionResolver implements CoordinateResolver {
 
+    private static final Logger LOG = 
LoggerFactory.getLogger(PomDependencyVersionResolver.class);
+
     private static final String NODE_NAME_TEXT = "#text";
     private final Map<String, Coordinate> coordinates;
 
@@ -94,7 +98,7 @@ public class PomDependencyVersionResolver implements 
CoordinateResolver {
                     }
                 }
             } catch (IOException | SAXException | ParserConfigurationException 
e) {
-                e.printStackTrace();
+                LOG.warn("Unable to read dependency versions from " + url, e);
             }
         }
         this.coordinates = coordinates;
diff --git 
a/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/build/dependencies/PomDependencyVersionResolverSpec.groovy
 
b/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/build/dependencies/PomDependencyVersionResolverSpec.groovy
index 2625691155..0e0297c338 100644
--- 
a/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/build/dependencies/PomDependencyVersionResolverSpec.groovy
+++ 
b/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/build/dependencies/PomDependencyVersionResolverSpec.groovy
@@ -17,9 +17,15 @@
  *  under the License.
  */
 
-    package org.grails.forge.build.dependencies
+package org.grails.forge.build.dependencies
 
+import ch.qos.logback.classic.Level
+import ch.qos.logback.classic.Logger
+import ch.qos.logback.classic.spi.ILoggingEvent
+import ch.qos.logback.core.read.ListAppender
 import io.micronaut.context.ApplicationContext
+import io.micronaut.core.io.ResourceResolver
+import org.slf4j.LoggerFactory
 import spock.lang.AutoCleanup
 import spock.lang.Shared
 import spock.lang.Specification
@@ -39,4 +45,35 @@ class PomDependencyVersionResolverSpec extends Specification 
{
         expect:
         pomDependencyVersionResolver.coordinates
     }
+
+    void "PomDependencyVersionResolver skips malformed POM resources"() {
+        given:
+        def malformedPom = File.createTempFile('malformed-pom', '.xml')
+        malformedPom.text = '<project><dependencies><dependency></project>'
+        def resourceResolver = Mock(ResourceResolver)
+        resourceResolver.getResources('classpath:pom.xml') >> 
[malformedPom.toURI().toURL()].stream()
+        Logger logger = (Logger) 
LoggerFactory.getLogger(PomDependencyVersionResolver)
+        def originalLevel = logger.level
+        def appender = new ListAppender<ILoggingEvent>()
+        appender.start()
+        logger.addAppender(appender)
+        logger.level = Level.WARN
+
+        when:
+        def resolver = new PomDependencyVersionResolver(resourceResolver)
+
+        then:
+        resolver.coordinates.isEmpty()
+        !resolver.resolve('missing-artifact').present
+        appender.list.any {
+            it.level == Level.WARN &&
+                    it.formattedMessage.contains('Unable to read dependency 
versions from') &&
+                    it.throwableProxy.className == 
'org.xml.sax.SAXParseException'
+        }
+
+        cleanup:
+        logger.detachAppender(appender)
+        logger.level = originalLevel
+        malformedPom?.delete()
+    }
 }
diff --git a/grails-shell-cli/build.gradle b/grails-shell-cli/build.gradle
index 9b45417a15..845d534c75 100644
--- a/grails-shell-cli/build.gradle
+++ b/grails-shell-cli/build.gradle
@@ -115,6 +115,7 @@ dependencies {
 
     // Testing
     testImplementation 'org.slf4j:slf4j-simple'
+    testImplementation 'ch.qos.logback:logback-classic'
     testImplementation 'org.spockframework:spock-core'
 
     testRuntimeOnly 'net.bytebuddy:byte-buddy' // Required by Spock's mocking 
support
diff --git a/grails-shell-cli/src/main/groovy/org/grails/cli/GrailsCli.groovy 
b/grails-shell-cli/src/main/groovy/org/grails/cli/GrailsCli.groovy
index 1d4ce96fc1..1620b921ae 100644
--- a/grails-shell-cli/src/main/groovy/org/grails/cli/GrailsCli.groovy
+++ b/grails-shell-cli/src/main/groovy/org/grails/cli/GrailsCli.groovy
@@ -30,6 +30,8 @@ import org.jline.reader.EndOfFileException
 import org.jline.reader.UserInterruptException
 import org.jline.reader.impl.completer.ArgumentCompleter
 import org.jline.terminal.Terminal
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
 import org.gradle.tooling.BuildActionExecuter
 import org.gradle.tooling.BuildCancelledException
 import org.gradle.tooling.ProjectConnection
@@ -79,6 +81,8 @@ import org.grails.exceptions.ExceptionUtils
 @CompileStatic
 class GrailsCli {
 
+    private static final Logger LOG = LoggerFactory.getLogger(GrailsCli)
+
     static final String ARG_SPLIT_PATTERN = /(?<!\\)\s+/
     public static final String DEFAULT_PROFILE_NAME = 
ProfileRepository.DEFAULT_PROFILE_NAME
     private static final String USAGE_MESSAGE = 'create-app [NAME] 
--profile=web'
@@ -98,7 +102,7 @@ class GrailsCli {
             try {
                 SETTINGS_MAP.merge(new 
ConfigSlurper().parse(BuildSettings.SHARED_SETTINGS_FILE.toURI().toURL()))
             } catch (Throwable e) {
-                e.printStackTrace()
+                LOG.error("Problem loading 
$BuildSettings.SHARED_SETTINGS_FILE", e)
                 System.err.println("ERROR: Problem loading 
$BuildSettings.SHARED_SETTINGS_FILE: ${e.message}")
             }
 
diff --git 
a/grails-shell-cli/src/main/groovy/org/grails/cli/command/run/SpringApplicationRunner.java
 
b/grails-shell-cli/src/main/groovy/org/grails/cli/command/run/SpringApplicationRunner.java
index 4c4265eda1..6c854c9939 100644
--- 
a/grails-shell-cli/src/main/groovy/org/grails/cli/command/run/SpringApplicationRunner.java
+++ 
b/grails-shell-cli/src/main/groovy/org/grails/cli/command/run/SpringApplicationRunner.java
@@ -33,6 +33,8 @@ import java.util.logging.Level;
 import org.grails.cli.boot.SpringApplicationLauncher;
 import org.grails.cli.compiler.GroovyCompiler;
 import org.grails.cli.util.ResourceUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Compiles Groovy code running the resulting classes using a {@code 
SpringApplication}.
@@ -45,6 +47,8 @@ import org.grails.cli.util.ResourceUtils;
  */
 public class SpringApplicationRunner {
 
+    private static final Logger LOG = 
LoggerFactory.getLogger(SpringApplicationRunner.class);
+
     private static int watcherCounter = 0;
 
     private static int runnerCounter = 0;
@@ -109,7 +113,8 @@ public class SpringApplicationRunner {
                     throw ex;
                 }
                 else {
-                    ex.printStackTrace();
+                    logOrPrintStackTrace(LOG, "Unable to compile and run 
application after a file change", ex,
+                            Level.SEVERE);
                 }
             }
         }
@@ -171,7 +176,7 @@ public class SpringApplicationRunner {
                         .launch(this.compiledSources, 
SpringApplicationRunner.this.args);
                 }
                 catch (Exception ex) {
-                    ex.printStackTrace();
+                    logOrPrintStackTrace(LOG, "Unable to launch application", 
ex, Level.SEVERE);
                 }
             }
         }
@@ -190,7 +195,7 @@ public class SpringApplicationRunner {
                         // Not an application context that we can close
                     }
                     catch (Exception ex) {
-                        ex.printStackTrace();
+                        logOrPrintStackTrace(LOG, "Unable to close application 
context", ex, Level.WARNING);
                     }
                     finally {
                         this.applicationContext = null;
@@ -201,6 +206,20 @@ public class SpringApplicationRunner {
 
     }
 
+    private static void logOrPrintStackTrace(Logger logger, String message, 
Exception exception, Level level) {
+        if (level == Level.WARNING) {
+            if (logger.isWarnEnabled()) {
+                logger.warn(message, exception);
+                return;
+            }
+        }
+        else if (logger.isErrorEnabled()) {
+            logger.error(message, exception);
+            return;
+        }
+        exception.printStackTrace();
+    }
+
     /**
      * Thread to watch for file changes and trigger recompile/reload.
      */
diff --git 
a/grails-shell-cli/src/test/groovy/org/grails/cli/GrailsCliLoggingVerifier.groovy
 
b/grails-shell-cli/src/test/groovy/org/grails/cli/GrailsCliLoggingVerifier.groovy
new file mode 100644
index 0000000000..ce85c0a5cd
--- /dev/null
+++ 
b/grails-shell-cli/src/test/groovy/org/grails/cli/GrailsCliLoggingVerifier.groovy
@@ -0,0 +1,63 @@
+/*
+ *  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.cli
+
+import ch.qos.logback.classic.Level
+import ch.qos.logback.classic.Logger
+import ch.qos.logback.classic.LoggerContext
+import ch.qos.logback.classic.spi.ILoggingEvent
+import ch.qos.logback.core.read.ListAppender
+import org.slf4j.LoggerFactory
+
+class GrailsCliLoggingVerifier {
+
+    private final String homeDirectory
+
+    GrailsCliLoggingVerifier(String homeDirectory) {
+        this.homeDirectory = homeDirectory
+    }
+
+    void verify() {
+        LoggerContext context = (LoggerContext) 
LoggerFactory.getILoggerFactory()
+        context.reset()
+        context.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME).level = 
Level.ERROR
+        Logger logger = context.getLogger('org.grails.cli.GrailsCli')
+        ListAppender<ILoggingEvent> appender = new ListAppender<>()
+        appender.start()
+        logger.addAppender(appender)
+        PrintStream originalErr = System.err
+        ByteArrayOutputStream stderr = new ByteArrayOutputStream()
+        System.setErr(new PrintStream(stderr, true))
+        try {
+            System.setProperty('user.home', homeDirectory)
+            Class.forName('org.grails.cli.GrailsCli')
+            assert appender.list.size() == 1
+            ILoggingEvent event = appender.list[0]
+            assert event.loggerName == 'org.grails.cli.GrailsCli'
+            assert event.level == Level.ERROR
+            assert event.formattedMessage.contains('Problem loading')
+            assert 
event.throwableProxy.className.contains('MultipleCompilationErrorsException')
+            assert stderr.toString('UTF-8').contains('ERROR: Problem loading')
+        }
+        finally {
+            System.setErr(originalErr)
+            logger.detachAppender(appender)
+        }
+    }
+}
diff --git 
a/grails-shell-cli/src/test/groovy/org/grails/cli/GrailsCliSpec.groovy 
b/grails-shell-cli/src/test/groovy/org/grails/cli/GrailsCliSpec.groovy
new file mode 100644
index 0000000000..04a5276cd0
--- /dev/null
+++ b/grails-shell-cli/src/test/groovy/org/grails/cli/GrailsCliSpec.groovy
@@ -0,0 +1,94 @@
+/*
+ *  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.cli
+
+import java.util.concurrent.TimeUnit
+
+import spock.lang.Specification
+import spock.lang.TempDir
+
+class GrailsCliSpec extends Specification {
+
+    @TempDir
+    File tempDir
+
+    void "shared settings parse failures are logged and reported to stderr"() {
+        given:
+        File grailsHome = new File(tempDir, '.grails')
+        assert grailsHome.mkdirs()
+        File settingsFile = new File(grailsHome, 'settings.groovy')
+        settingsFile.text = 'invalid = ['
+
+        expect:
+        loadGrailsCli(tempDir) == 'verified'
+    }
+
+    private String loadGrailsCli(File homeDirectory) {
+        File argumentsFile = new File(tempDir, 'grails-cli.args')
+        argumentsFile.text = """\
+            -cp
+            ${testRuntimeClasspath()}
+            ${GrailsCliSpec.name}
+            ${homeDirectory.absolutePath}
+            """.stripIndent().trim()
+        Process process = new ProcessBuilder(
+            new File(System.getProperty('java.home'), 'bin/java').absolutePath,
+            "@${argumentsFile.absolutePath}"
+        ).redirectErrorStream(true).start()
+        process.outputStream.close()
+        try {
+            awaitProcess(process, 'Grails CLI fixture')
+            String output = process.inputStream.withCloseable { 
it.getText('UTF-8').trim() }
+            assert process.exitValue() == 0 : output
+            output.readLines().last()
+        } finally {
+            if (process.isAlive()) {
+                process.destroyForcibly()
+                process.waitFor(5, TimeUnit.SECONDS)
+            }
+            process.inputStream.close()
+            process.errorStream.close()
+        }
+    }
+
+    private static void awaitProcess(Process process, String fixtureName) {
+        if (process.waitFor(30, TimeUnit.SECONDS)) {
+            return
+        }
+        process.destroyForcibly()
+        if (!process.waitFor(5, TimeUnit.SECONDS)) {
+            throw new AssertionError("${fixtureName} did not terminate after 
timing out")
+        }
+        throw new AssertionError("${fixtureName} timed out after 30 seconds")
+    }
+
+    private static String testRuntimeClasspath() {
+        [
+            System.getProperty('java.class.path'),
+            new File('grails-shell-cli/build/resources/test').absolutePath
+        ].join(File.pathSeparator)
+    }
+
+    static void main(String[] args) {
+        System.setProperty('slf4j.provider', 
'ch.qos.logback.classic.spi.LogbackServiceProvider')
+        GrailsCliLoggingVerifier verifier = new 
GrailsCliLoggingVerifier(args[0])
+        verifier.verify()
+        println 'verified'
+    }
+}
diff --git 
a/grails-shell-cli/src/test/groovy/org/grails/cli/command/run/LifecycleVerifier.groovy
 
b/grails-shell-cli/src/test/groovy/org/grails/cli/command/run/LifecycleVerifier.groovy
new file mode 100644
index 0000000000..b48d93b738
--- /dev/null
+++ 
b/grails-shell-cli/src/test/groovy/org/grails/cli/command/run/LifecycleVerifier.groovy
@@ -0,0 +1,195 @@
+/*
+ *  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.cli.command.run
+
+import java.util.concurrent.TimeUnit
+import java.util.logging.Level
+
+import ch.qos.logback.classic.Logger
+import ch.qos.logback.classic.LoggerContext
+import ch.qos.logback.classic.spi.ILoggingEvent
+import ch.qos.logback.core.read.ListAppender
+import org.grails.cli.compiler.GroovyCompilerScope
+import org.slf4j.LoggerFactory
+
+class LifecycleVerifier {
+
+    private final String failureMode
+    private final boolean loggingEnabled
+
+    LifecycleVerifier(String failureMode, boolean loggingEnabled) {
+        this.failureMode = failureMode
+        this.loggingEnabled = loggingEnabled
+    }
+
+    void verify() {
+        LoggerContext context = (LoggerContext) 
LoggerFactory.getILoggerFactory()
+        context.reset()
+        Logger rootLogger = 
context.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME)
+        rootLogger.level = loggingEnabled ? ch.qos.logback.classic.Level.DEBUG 
: ch.qos.logback.classic.Level.OFF
+        Logger runnerLogger = context.getLogger(SpringApplicationRunner.name)
+        ListAppender<ILoggingEvent> appender = new ListAppender<>()
+        appender.start()
+        runnerLogger.addAppender(appender)
+        PrintStream originalErr = System.err
+        ByteArrayOutputStream stderr = new ByteArrayOutputStream()
+        System.setErr(new PrintStream(stderr, true))
+        File source = File.createTempFile('runner-lifecycle', '.groovy')
+        try {
+            verifyLifecycle(source, stderr, appender)
+        }
+        finally {
+            System.setErr(originalErr)
+            runnerLogger.detachAppender(appender)
+            source.delete()
+        }
+    }
+
+    private void verifyLifecycle(File source, ByteArrayOutputStream stderr, 
ListAppender<ILoggingEvent> appender) {
+        source.text = successfulApplicationSource()
+        SpringApplicationRunner runner = new 
SpringApplicationRunner(configuration(failureMode == 'reload'), 
[source.toURI().toString()] as String[])
+        if (failureMode == 'reload') {
+            runner.compileAndRun()
+            Thread.sleep(1100)
+            source.text = invalidApplicationSource()
+            assert source.setLastModified(System.currentTimeMillis() + 2000)
+            waitForFailure(stderr, appender)
+        }
+        else {
+            source.text = failureMode == 'launch' ? launchFailureSource() : 
shutdownFailureSource()
+            runner.compileAndRun()
+            if (failureMode == 'shutdown') {
+                runner.stop()
+            }
+        }
+        assertFailure(stderr.toString('UTF-8'), appender.list)
+    }
+
+    private void waitForFailure(ByteArrayOutputStream stderr, 
ListAppender<ILoggingEvent> appender) {
+        long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10)
+        while (System.nanoTime() < deadline && stderr.size() == 0 && 
appender.list.empty) {
+            Thread.sleep(50)
+        }
+        assert stderr.size() > 0 || !appender.list.empty
+    }
+
+    private void assertFailure(String stderr, List<ILoggingEvent> events) {
+        if (loggingEnabled) {
+            assert stderr.empty
+            assert events.size() == 1
+            ILoggingEvent event = events[0]
+            assert event.loggerName == SpringApplicationRunner.name
+            assert event.level == (failureMode == 'shutdown' ? 
ch.qos.logback.classic.Level.WARN : ch.qos.logback.classic.Level.ERROR)
+            assert event.formattedMessage == expectedMessage()
+            assert event.throwableProxy != null
+        }
+        else {
+            assert events.empty
+            assert stderr.contains(expectedFailure())
+            assert stderr.count(failureHeader()) == 1
+        }
+    }
+
+    private String expectedMessage() {
+        switch (failureMode) {
+            case 'launch':
+                return 'Unable to launch application'
+            case 'reload':
+                return 'Unable to compile and run application after a file 
change'
+            default:
+                return 'Unable to close application context'
+        }
+    }
+
+    private String expectedFailure() {
+        failureMode == 'reload' ? 'ReloadFailure' : "${failureMode} failure"
+    }
+
+    private String failureHeader() {
+        failureMode == 'reload' ? 'MultipleCompilationErrorsException' : 
expectedFailure()
+    }
+
+    private SpringApplicationRunnerConfiguration configuration(boolean watch) {
+        [
+            getScope                  : { GroovyCompilerScope.DEFAULT },
+            isGuessImports            : { false },
+            isGuessDependencies       : { false },
+            isAutoconfigure           : { false },
+            getClasspath              : { ['.'] as String[] },
+            getRepositoryConfiguration: { [] },
+            isQuiet                   : { true },
+            isWatchForFileChanges     : { watch },
+            getLogLevel               : { loggingEnabled ? Level.INFO : 
Level.OFF }
+        ] as SpringApplicationRunnerConfiguration
+    }
+
+    private static String successfulApplicationSource() {
+        applicationSource('new Object()')
+    }
+
+    private static String launchFailureSource() {
+        applicationSource("throw new IllegalStateException('launch failure')")
+    }
+
+    private static String shutdownFailureSource() {
+        """\
+            package org.springframework.boot
+
+            class SpringApplication {
+                SpringApplication(Class[] sources) {
+                }
+
+                void setDefaultProperties(Map<String, Object> 
defaultProperties) {
+                }
+
+                Object run(String[] args) {
+                    new FailingContext()
+                }
+            }
+
+            class FailingContext {
+                void close() {
+                    throw new IllegalStateException('shutdown failure')
+                }
+            }
+            """.stripIndent()
+    }
+
+    private static String invalidApplicationSource() {
+        'class Broken extends ReloadFailure { }\n'
+    }
+
+    private static String applicationSource(String runBody) {
+        """\
+            package org.springframework.boot
+
+            class SpringApplication {
+                SpringApplication(Class[] sources) {
+                }
+
+                void setDefaultProperties(Map<String, Object> 
defaultProperties) {
+                }
+
+                Object run(String[] args) {
+                    ${runBody}
+                }
+            }
+            """.stripIndent()
+    }
+}
diff --git 
a/grails-shell-cli/src/test/groovy/org/grails/cli/command/run/SpringApplicationRunnerSpec.groovy
 
b/grails-shell-cli/src/test/groovy/org/grails/cli/command/run/SpringApplicationRunnerSpec.groovy
new file mode 100644
index 0000000000..ddd2d76fa9
--- /dev/null
+++ 
b/grails-shell-cli/src/test/groovy/org/grails/cli/command/run/SpringApplicationRunnerSpec.groovy
@@ -0,0 +1,114 @@
+/*
+ *  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.cli.command.run
+
+import java.util.concurrent.TimeUnit
+
+import spock.lang.Specification
+import spock.lang.TempDir
+
+class SpringApplicationRunnerSpec extends Specification {
+
+    @TempDir
+    File tempDir
+
+    void "ROOT OFF prints #failureMode failures to stderr"() {
+        expect:
+        runFixture(failureMode, false) == 'verified'
+
+        where:
+        failureMode << ['launch', 'reload', 'shutdown']
+    }
+
+    void "enabled logging captures #failureMode failures without stderr 
duplication"() {
+        expect:
+        runFixture(failureMode, true) == 'verified'
+
+        where:
+        failureMode << ['launch', 'reload', 'shutdown']
+    }
+
+    private String runFixture(String failureMode, boolean loggingEnabled) {
+        File argumentsFile = new File(tempDir, 
"${failureMode}-${loggingEnabled}.args")
+        argumentsFile.text = """\
+            -cp
+            ${testRuntimeClasspath()}
+            ${SpringApplicationRunnerSpec.name}
+            ${failureMode}
+            ${loggingEnabled}
+            """.stripIndent().trim()
+        Process process = new ProcessBuilder(
+            new File(System.getProperty('java.home'), 'bin/java').absolutePath,
+            "@${argumentsFile.absolutePath}"
+        ).redirectErrorStream(true).start()
+        process.outputStream.close()
+        try {
+            awaitProcess(process, 'Spring application runner fixture')
+            String output = process.inputStream.withCloseable { 
it.getText('UTF-8').trim() }
+            assert process.exitValue() == 0 : output
+            output.readLines().last()
+        } finally {
+            if (process.isAlive()) {
+                process.destroyForcibly()
+                process.waitFor(5, TimeUnit.SECONDS)
+            }
+            process.inputStream.close()
+            process.errorStream.close()
+        }
+    }
+
+    private static void awaitProcess(Process process, String fixtureName) {
+        if (process.waitFor(30, TimeUnit.SECONDS)) {
+            return
+        }
+        process.destroyForcibly()
+        if (!process.waitFor(5, TimeUnit.SECONDS)) {
+            throw new AssertionError("${fixtureName} did not terminate after 
timing out")
+        }
+        throw new AssertionError("${fixtureName} timed out after 30 seconds")
+    }
+
+    private static String testRuntimeClasspath() {
+        [
+            System.getProperty('java.class.path'),
+            new File('grails-shell-cli/build/resources/test').absolutePath
+        ].join(File.pathSeparator)
+    }
+
+    static void main(String[] args) {
+        String failureMode = args[0]
+        boolean loggingEnabled = Boolean.parseBoolean(args[1])
+        System.setProperty('slf4j.provider', 
'ch.qos.logback.classic.spi.LogbackServiceProvider')
+        LifecycleVerifier verifier = new LifecycleVerifier(failureMode, 
loggingEnabled)
+        try {
+            verifier.verify()
+            println 'verified'
+            if (failureMode == 'reload') {
+                System.exit(0)
+            }
+        }
+        catch (Throwable throwable) {
+            throwable.printStackTrace()
+            if (failureMode == 'reload') {
+                System.exit(1)
+            }
+            throw throwable
+        }
+    }
+}
diff --git 
a/grails-shell-cli/src/test/resources/org/grails/cli/compiler/dependencies/spring-boot-dependencies-effective-bom.xml
 
b/grails-shell-cli/src/test/resources/org/grails/cli/compiler/dependencies/spring-boot-dependencies-effective-bom.xml
new file mode 100644
index 0000000000..c27818f3bd
--- /dev/null
+++ 
b/grails-shell-cli/src/test/resources/org/grails/cli/compiler/dependencies/spring-boot-dependencies-effective-bom.xml
@@ -0,0 +1,9 @@
+<project>
+    <modelVersion>4.0.0</modelVersion>
+    <groupId>test</groupId>
+    <artifactId>spring-boot-dependencies</artifactId>
+    <version>1.0.0</version>
+    <dependencyManagement>
+        <dependencies/>
+    </dependencyManagement>
+</project>

Reply via email to