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


##########
grails-core/src/main/groovy/org/grails/core/io/support/GrailsFactoriesLoader.groovy:
##########
@@ -101,6 +101,56 @@ class GrailsFactoriesLoader extends FactoriesLoaderSupport 
{
         results
     }
 
+    /**
+     * Loads factory class names grouped by the resource that declares them 
without loading the
+     * classes themselves. This lets callers that need diagnostic context 
apply stricter class
+     * loading semantics than the framework-wide best-effort factory loader.
+     */
+    static Map<String, List<String>> loadFactoryDeclarations(
+            Class<?> factoryClass,
+            ClassLoader classLoader = GrailsFactoriesLoader.classLoader,
+            String resourceLocation = FACTORIES_RESOURCE_LOCATION) {
+        Assert.notNull(factoryClass, "'factoryClass' must not be null")
+        loadFactoryDeclarations(factoryClass.name, classLoader, 
resourceLocation)
+    }
+
+    static Map<String, List<String>> loadFactoryDeclarations(
+            String factoryClassName,
+            ClassLoader classLoader = GrailsFactoriesLoader.classLoader,
+            String resourceLocation = FACTORIES_RESOURCE_LOCATION) {
+        Assert.hasText(factoryClassName, "'factoryClassName' must not be 
empty")
+        ClassLoader factoryClassLoader = classLoader ?: 
GrailsFactoriesLoader.classLoader
+        Map<String, List<String>> declarations = new LinkedHashMap<>()
+        Enumeration<URL> resources = 
factoryClassLoader.getResources(resourceLocation)
+        while (resources.hasMoreElements()) {
+            URL resource = resources.nextElement()
+            Properties properties = new Properties()
+            try {
+                resource.openStream().withCloseable { InputStream input ->
+                    properties.load(input)
+                }
+            }
+            catch (IOException | IllegalArgumentException ignored) {

Review Comment:
   `catch (IOException | IllegalArgumentException ignored) { continue }` drops 
the entire resource with no log line at any level — the class has no logger.
   
   That would be defensible if this only backed the legacy diagnostic, but it 
doesn't. `loadFactoryDeclarations` is now the reader for modern discovery too: 
`ApplicationContextCommandRegistry:64` for commands and `:159` for providers, 
both against `CLI_FACTORIES_RESOURCE_LOCATION`. So a single plugin shipping a 
`META-INF/grails-cli.factories` with a stray backslash — `Properties.load` 
throws `IllegalArgumentException("Malformed \\uxxxx encoding")` — loses *every* 
one of that plugin's Grails 8 commands and providers. The user sees commands 
that simply aren't there, with nothing to chase.
   
   It's also a loudness regression against what it replaced. 
`FactoriesLoaderSupport.loadFactoryNames` 
(`grails-gradle/model/.../FactoriesLoaderSupport.groovy:99-101`) rethrows 
`IOException` as `IllegalArgumentException` and doesn't catch 
`IllegalArgumentException` at all, so a malformed factories file propagated 
instead of vanishing. `ApplicationCommandProviderSpec:301` currently locks the 
new silence in — it asserts only `missingCommandHint == null` and 
`noExceptionThrown()`.
   
   This is the same failure mode you and I already agreed on for linkage 
errors: a command the user expects, silently absent, diagnosable by nobody. 
Give the class a logger and `warn` with the resource URL and the cause before 
continuing.
   
   Implemented on 
[test/8.0.x-legacy-command-e2e](https://github.com/jdaugherty/grails-core/tree/test/8.0.x-legacy-command-e2e):
 the class gets `@Slf4j` and the catch warns with the resource URL and cause, 
keeping the per-resource `continue`. The existing malformed-resource test now 
asserts the warning instead of only `noExceptionThrown()`, and a new one proves 
a malformed `grails-cli.factories` in one plugin no longer costs a *different* 
plugin its modern commands. `:grails-core:test --tests 
ApplicationCommandProviderSpec` green (23 features).



##########
grails-test-examples/legacy-g7-command-plugin/src/main/groovy/legacy/g7/commands/HelloG7PrecompiledCommand.groovy:
##########
@@ -0,0 +1,49 @@
+/*
+ *  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 legacy.g7.commands
+
+import groovy.transform.CompileStatic
+
+import grails.dev.commands.ApplicationCommand
+import grails.dev.commands.ExecutionContext
+
+/**
+ * Precompiled against Grails 7 / Groovy 4 so the monorepo can prove that an 
unchanged
+ * published Grails 7 application-command binary still links and runs through 
the Grails 8
+ * compatibility bridge.
+ */
+@CompileStatic
+class HelloG7PrecompiledCommand implements ApplicationCommand {
+
+    @Override
+    String getName() {

Review Comment:
   Overriding `getName()` here is a legitimate Grails 7 shape and this command 
should keep it. The gap is that it's the *only* shape covered.
   
   The deprecated trait ships two behaviours. One is "the command declares its 
own name", which this fixture exercises. The other is "the command declares no 
name and the trait derives one" — `ApplicationCommand.getName()` at 
`grails-core-cli-legacy/.../grails/dev/commands/ApplicationCommand.groovy:50-57`,
 which runs `GrailsNameUtils.getScriptName(getLogicalName(...))` and, from a 
Groovy 4 binary, reaches it through `ApplicationCommand$Trait$Helper`. That 
second path has no coverage anywhere in the tree: both fixture commands 
override the getters (here at :35/:39, `HelloG7PrecompiledGrailsCommand` at 
:29/:33), `LegacyApplicationCommandAdapterSpec:54-55` overrides them, and so do 
all eight classes in `LegacyCommandRegistryLoadingSpec` (:332, :354, :373, 
:395, :415, :440, :460, :484).
   
   It matters because `class FooBarCommand implements ApplicationCommand` with 
no `getName()` is the shape `create-command` generated, so a large share of the 
published Grails 7 commands this bridge exists to keep working never declare a 
name at all. For those the derivation *is* the registration key — if the Groovy 
5 recompile of the trait changes how that default is woven or dispatched into 
Groovy-4-compiled implementors, they register under the wrong key or not at 
all. That's precisely the class of failure this fixture was built to catch, and 
it's the one variant it currently can't see.
   
   So: keep this command as-is, and add a third precompiled one alongside it 
that declares neither getter, letting the trait derive the name. The spec then 
looks it up under whatever the derivation produces, which only passes if the 
default survives the version boundary.
   
   A unit test over in `grails-core-cli-legacy` wouldn't substitute for this — 
that would be a Groovy 5 implementor against a Groovy 5 trait, which isn't the 
cross-version question. It has to be a Groovy 4 binary to mean anything, which 
is why it belongs in this fixture.
   
   There's a working version of this on 
[test/8.0.x-legacy-command-e2e](https://github.com/jdaugherty/grails-core/tree/test/8.0.x-legacy-command-e2e):
 a third precompiled `HelloDerivedNameCommand` matching the `create-command` 
template exactly - implements `GrailsApplicationCommand`, declares neither 
getter - which reads its own `name` inside `handle()` so the derivation is 
exercised from the Groovy 4 bytecode rather than only from the recompiled 
trait. It registers as `hello-derived-name` (confirmed by running the trait 
against the precompiled class), and the integration spec asserts both the 
registration key and the rendered output. Green.



##########
grails-console/src/main/groovy/grails/ui/command/GrailsApplicationContextCommandRunner.groovy:
##########
@@ -43,10 +44,12 @@ class GrailsApplicationContextCommandRunner extends 
DevelopmentGrailsApplication
 
     @Override
     ConfigurableApplicationContext run(String... args) {
-        def command = 
ApplicationContextCommandRegistry.instance.findCommand(commandName)
+        ApplicationContextCommandRegistry commandRegistry = 
ApplicationContextCommandRegistry.instance
+        def command = commandRegistry.findCommand(commandName)
         if (command) {
+            Object autowireTarget = resolveAutowireTarget(command)
 
-            Object skipBootstrap = 
command.hasProperty('skipBootstrap')?.getProperty(command)
+            Object skipBootstrap = 
autowireTarget.hasProperty('skipBootstrap')?.getProperty(autowireTarget)

Review Comment:
   Reading `skipBootstrap` off the unwrapped target is the right call — the 
adapter forwards `handle`, not arbitrary properties — but nothing tests it. 
`grep -rn skipBootstrap` across the repo returns only production code: this 
file, and the `skipBootstrap = true` declarations in the hibernate5/7 
`SchemaExportCommand` and `ApplicationContextDatabaseMigrationCommand`, 
scaffolding, and spring-security. No spec mentions it.
   
   That matters more than a coverage count, because the flag decides whether 
`BootStrap.groovy` runs. If a later change collapses this back to 
`command.hasProperty('skipBootstrap')`, a legacy command declaring `Boolean 
skipBootstrap = true` silently stops setting `Settings.SETTING_SKIP_BOOTSTRAP` 
and BootStrap executes during something like `dbm-update` against whatever 
datasource is configured — with the whole suite still green.
   
   `run()` has the same hole: `LegacyCommandCompatibilityIntegrationSpec` 
performs the autowire unwrap by hand at :85-98 instead of driving the runner, 
so reverting line 79 back to `command` also leaves every test passing.
   
   Small fix: extract the flag lookup into a static helper next to 
`resolveAutowireTarget` and cover adapter-target / plain-command / no-property 
in `GrailsApplicationContextCommandRunnerSpec`, which this commit already added.
   
   Implemented on 
[test/8.0.x-legacy-command-e2e](https://github.com/jdaugherty/grails-core/tree/test/8.0.x-legacy-command-e2e):
 the lookup moves into `static Boolean resolveSkipBootstrap(Object)` beside 
`resolveAutowireTarget`, with `run()` behaviour unchanged (the 
`System.getProperty` guard stays put). Four features in 
`GrailsApplicationContextCommandRunnerSpec` cover plain command, no-property, 
adapter-target, and non-Boolean. Mutation-checked: pointing the helper back at 
`command` instead of the resolved target fails exactly the adapter-target case. 
`:grails-console:test --tests GrailsApplicationContextCommandRunnerSpec` green 
(34 features).



##########
grails-test-examples/legacy-g7-command-plugin/build.gradle:
##########
@@ -0,0 +1,75 @@
+/*
+ *  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.
+ */
+
+// Standalone composite-build fixture compiled against published Grails 7 / 
Groovy 4.
+// Included by the monorepo root so grails-test-examples-legacy-commands can 
consume a real
+// precompiled Grails 7 ApplicationCommand binary instead of recompiling 
sources under Grails 8.
+plugins {
+    id 'java-library'
+    id 'groovy'
+}
+
+group = 'legacy.g7.commands'
+version = '0.0.1'
+
+java {
+    toolchain {
+        languageVersion = JavaLanguageVersion.of(17)

Review Comment:
   Commenting on this line, though the Java version itself isn't the issue — 17 
is correct and should stay. It's the minimum JDK for a Grails 7 app (`7.0.x` 
pins `java=17.0.18-librca` in `.sdkmanrc` and `javaVersion=17` in 
`gradle.properties`), and matching what a Grails 7 app would actually be built 
with is the entire point of this fixture; anything newer would weaken what it 
proves. The issue is *where* that requirement is expressed.
   
   Right now it's a Gradle toolchain, and this is the only production build in 
the repo that pins one — every other `JavaLanguageVersion` is a TestKit 
resource substituted with `__CURRENT_JDK__` or a gated java-compat test 
project. We've deliberately stayed off toolchain configuration, and I don't 
want this fixture to be what introduces it into the core build.
   
   As written it also can't be satisfied. Nothing provisions a JDK 17: there's 
no `foojay-resolver` or `toolchainManagement` block in this build's 
`settings.gradle` (which is just `rootProject.name`) or in the root 
`settings.gradle`, and no `org.gradle.java.installations.*` property anywhere. 
The root `.sdkmanrc` pins `java=21.0.7-librca` and nothing else, and 
`etc/bin/Dockerfile` is `FROM bellsoft/liberica-openjdk-debian:21.0.7` with 
only a secondary JDK 25 at `/opt/liberica-jdk25`.
   
   And it isn't confined to running the integration test. The fixture jar is an 
`implementation` dependency (`legacy-commands/build.gradle:45`), so 
`buildLegacyG7CommandFixture` is in the task graph for plain compilation:
   
   ```
   $ ./gradlew :grails-test-examples-legacy-commands:compileGroovy --dry-run 
-PskipTests
   :grails-test-examples-legacy-commands:buildLegacyG7CommandFixture SKIPPED
   :grails-test-examples-legacy-commands:compileGroovy SKIPPED
   ```
   
   That's reached by `etc/bin/test-reproducible-builds.sh:42` (`./gradlew build 
--rerun-tasks -PskipTests ...`). With JDK 17 hidden:
   
   ```
   $ ./gradlew -p grails-test-examples/legacy-g7-command-plugin jar \
       -Dorg.gradle.java.installations.auto-detect=false
   > Cannot find a Java installation on your machine matching:
     {languageVersion=17, vendor=any vendor, ...}. Toolchain auto-provisioning 
is not enabled.
   ```
   
   So a contributor whose only JDK is the one we document can't build the repo. 
CI can't catch it — it's green only because the GitHub runner images happen to 
ship a JDK 17 that Gradle auto-detects.
   
   The fix isn't a different Java version or a resolver plugin. It's to stop 
wiring a second JDK requirement into the core build at all, and instead use the 
composition model we already use for every other build in this repo that has 
its own needs: `grails-forge`, `grails-gradle`, `build-logic`. Those are 
independent builds with their own `settings.gradle` and their own `gradlew`, 
and the dependency runs one way — `grails-forge/settings.gradle:75` does 
`includeBuild('..')`, so Forge composes grails-core and grails-core's own 
`./gradlew build` never reaches Forge. CI runs each as its own job 
(`working-directory: 'grails-forge'`, `working-directory: 'grails-gradle'`).
   
   I'd like a new top-level `end-to-end` build to serve as the base for this 
kind of test, with the Grails 7 fixture and the `legacy-commands` example that 
consumes it as its first occupants:
   
   - drop the `java { toolchain { ... } }` block;
   - move the fixture and its consuming example into `end-to-end`, out of the 
root build graph, so `./gradlew build -PskipTests` never triggers 
`buildLegacyG7CommandFixture` and the core build never needs a JDK it doesn't 
have;
   - declare the Java 17 requirement in a `.sdkmanrc` next to the Grails 7 half 
— same mechanism the root and `7.0.x` already use, and it keeps 17 an explicit, 
greppable statement of intent rather than something Gradle has to go find;
   - run it from its own CI workflow, the way Forge and the Gradle plugins are 
run.
   
   That also gives us an obvious home for the next end-to-end compatibility 
test instead of finding a new corner for each one.
   
   Two things worth planning for in that layout, both of which a dedicated 
workflow handles cleanly and the core build can't:
   
   1. **Two JDKs, explicitly.** The Grails 7 fixture wants 17; the Grails 8 app 
consuming it needs 21+. In a dedicated workflow that's just two `setup-java` 
steps around two build invocations, which is more honest than asking one Gradle 
build to straddle both — and it's what removes the auto-detection dependency 
that's making CI pass by luck today.
   2. **Keep the Grails 7 half outside dependency substitution.** If 
`includeBuild('..')` reaches it, `org.apache.grails:grails-core:7.0.14` gets 
rewritten to the local Groovy 5 project and the proof evaporates — which is 
exactly why you chose `GradleBuild` over `includeBuild` originally, and I 
agreed with that reasoning. The Grails 8 consuming app can compose the root 
normally; the fixture can't.
   
   I've put this together as a working branch rather than leave it as a sketch: 
[test/8.0.x-legacy-command-e2e](https://github.com/jdaugherty/grails-core/tree/test/8.0.x-legacy-command-e2e)
 ([single commit](https://github.com/jdaugherty/grails-core/commit/afa4d80440)).
   
   The three projects move to a top-level `end-to-end` build and the toolchain 
block is gone. Only the Grails 7 fixture carries a `.sdkmanrc` (17); the Grails 
8 side tracks the repository's root `.sdkmanrc` rather than re-pinning 21, 
since it has to run on whatever the core build it consumes runs on. 
`.github/workflows/end-to-end.yml` reads both versions out of those two files, 
so it cannot drift from what `sdk env` gives a developer.
   
   The part worth a look is how it resolves Grails. Rather than 
`includeBuild('..')`, it consumes the artifacts the core build publishes — the 
same `build/local-maven` that grails-forge points its generated applications 
at, populated by `publishAllPublicationsToTestCaseMavenRepoRepository` in the 
root and grails-gradle builds. That is what makes these end-to-end: they go 
through real poms and module metadata the way an application does. 
`settings.gradle` scopes the repository with `exclusiveContent` so a remote 
snapshot can't quietly satisfy an `org.apache.grails` request and leave the 
suite testing something other than the working tree.
   
   It also disposes of the CLI companion problem instead of working around it. 
`grails-core-cli` is a secondary capability of `:grails-core` rather than a 
project, so composite substitution can't express it and dies on a capability 
self-conflict — but it's a first-class published module whose metadata 
`CliPublishingSupport` already rewrites for external consumers, so resolving 
from the repository just works. Verified byte-for-byte: the `grails-core-cli` 
jar the suite resolves is sha1-identical to the one the publish task had just 
written to `build/local-maven`.
   
   Verified end to end: `end-to-end/./gradlew check` green with all six 
integration tests passing, the root `./gradlew` configures with zero references 
to the moved projects, and the fixture builds under JDK 17 with 
`-Dorg.gradle.java.installations.auto-detect=false`. Take it as a proposal, not 
a demand.



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

To unsubscribe, e-mail: [email protected]

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

Reply via email to