Skip to content

Opt-in backwards compatibility for Grails 7 command plugins on Grails 8 - #16011

Merged
jamesfredley merged 20 commits into
8.0.xfrom
feat/8.0.x-legacy-command-compat
Jul 29, 2026
Merged

Opt-in backwards compatibility for Grails 7 command plugins on Grails 8#16011
jamesfredley merged 20 commits into
8.0.xfrom
feat/8.0.x-legacy-command-compat

Conversation

@jamesfredley

@jamesfredley jamesfredley commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Summary

PR #15948 split CLI command code off the application runtime classpath. The command contract moved from grails.dev.commands.* to org.apache.grails.core.cli.*, and registration moved from META-INF/grails.factories to META-INF/grails-cli.factories. That split is preserved.

The side effect is that an unchanged, already-published Grails 7 application-command plugin would otherwise lose those commands on Grails 8: the old types no longer link, and the old factory entries are never read.

This PR restores that path with a deprecated, execution-only compatibility layer so existing Grails 7 application commands can keep working when opted in, without a plugin re-release and without putting the deprecated contract on the compile ABI of grails-core-cli.

Final behavior

Compatibility layer

  • New artifact: org.apache.grails:grails-core-cli-legacy
    • Restores the deprecated grails.dev.commands.* command contract
    • Provides LegacyApplicationCommandAdapter / provider wiring
  • Neutral SPI in grails-core-cli:
    • ApplicationCommandProvider
    • ApplicationCommandRegistrar
    • ApplicationCommandTargetAware
  • ApplicationContextCommandRegistry discovers providers from META-INF/grails-cli.factories
    • dual-classloader
    • class-identity deduplicated
    • failure-isolated
    • modern command wins name clashes
    • one-time deprecation warning only when a legacy command is actually installed
  • grails-core-cli has zero compile/runtime dependency on grails.dev.commands.*
  • Runner unwrap uses ApplicationCommandTargetAware.getTarget() so Spring autowiring and skipBootstrap hit the real legacy command

Auto-provisioning flags

Flag Default Controls
cliAutoProvision true Modern CLI tier: grails-core-cli, grails-console, and discovered companion -cli artifacts
legacyCommandSupport false Only the Grails 7 application-command bridge: grails-core-cli-legacy

Rules:

  • Modern CLI auto-provisioning runs when cliAutoProvision = true
  • Legacy bridge auto-provisioning runs only when both cliAutoProvision = true and legacyCommandSupport = true
  • legacyCommandSupport = false (the default) turns off only the Grails 7 bridge; modern companions still auto-provision
  • cliAutoProvision = false turns off the whole auto-provisioned CLI tier, including the legacy bridge

Examples:

// default: modern CLI on, legacy bridge off
// no grails {} block required

// keep modern CLI companions, enable only Grails 7 bridge
grails {
    legacyCommandSupport = true
}

// disable all CLI auto-provisioning; declare deps yourself
grails {
    cliAutoProvision = false
}
dependencies {
    grailsCli 'org.apache.grails:grails-core-cli'
    grailsCli 'org.apache.grails:grails-console'
    grailsCli 'org.apache.grails:grails-data-hibernate7-dbmigration-cli'
    grailsCliLegacy 'org.apache.grails:grails-core-cli-legacy'
}

Project-property equivalents:

  • grailsCliAutoProvision=false
  • grailsLegacyCommandSupport=true (opt in; default is off)

Classpath shape

  • grailsCliLegacy is execution-only
  • It feeds grailsCliClasspath and test runtime classpaths
  • It never extends application/plugin compile classpaths
  • Auto-provisioned CLI coordinates are version-pinned to the current Grails version so consumers without a BOM-managed -cli coordinate still resolve

How legacy commands run

  • Legacy Grails 7 application commands do not get named Gradle tasks
  • Configuration-time runtimeClasspath scans for META-INF/grails.factories were removed (nondeterministic in multi-project builds)
  • Run them through the generic runCommand task, or the shell (which routes legacy adapters there):
./gradlew runCommand "-Pargs=generate-controller example.Book"
  • Named per-command Gradle tasks remain available only for migrated commands published in a companion -cli artifact

Command mechanisms

Mechanism Packaged as Result on Grails 8 with this PR
create-command / grails.dev.commands.ApplicationCommand class + META-INF/grails.factories Works via grails-core-cli-legacy when both flags are on (runCommand / shell)
create-script / META-INF/commands/*.groovy script resources Unaffected by the split; hyphenated script name regression fixed
YAML multi-step / META-INF/commands/*.yml YAML resources Unaffected
ServiceLoader / profile commands profile / services Unaffected

Codegen script fix

Hyphenated script filenames (for example audit-quickstart) now resolve under the filename-derived command name under Groovy 5.

Testing

  • Provider / adapter / registry unit coverage, including failure isolation and linkage diagnostics
  • Gradle TestKit:
    • modern companion auto-discovery
    • compile-visible / runtime-isolated CLI tier
    • cliAutoProvision = false disables the whole tier (including when legacy is explicitly opted in)
    • legacyCommandSupport defaults to false
    • explicit legacyCommandSupport = false / true property cases
    • legacy runtime commands do not invent named Gradle tasks; runCommand remains available
  • The compatibility suite lives in the top-level end-to-end build, which is its own Gradle build. The root build never reaches it, so ./gradlew build at the repository root is unaffected, and it is run by its own .github/workflows/end-to-end.yml:
    • end-to-end/legacy-commands - the Grails 8 application under test; opts in with grails { legacyCommandSupport = true }
    • end-to-end/legacy-commands-plugin - a Grails 8 plugin whose legacy commands are recompiled under Groovy 5
    • It resolves Grails from the artifacts the core build publishes into build/local-maven (scoped with exclusiveContent) rather than by project substitution, so the tests consume grails-core through real poms and Gradle module metadata the way an application does - including the -cli companion artifacts, which composite substitution cannot express because grails-core-cli is a secondary capability of :grails-core rather than a project
  • Real Grails 7 / Groovy 4 precompiled binary fixture: end-to-end/legacy-g7-command-plugin
    • A standalone build with its own settings.gradle, its own mavenCentral()-only repository, and its own JDK 17 declared in .sdkmanrc (no Gradle toolchain, so no surrounding build inherits a second JDK requirement)
    • Pinned with enforcedPlatform('org.apache.grails:grails-bom:7.0.14'), resolving Groovy 4.0.32
    • Consumed as a prebuilt jar, never includeBuild, so composite substitution cannot rewrite org.apache.grails:grails-core to this repository's Groovy 5 projects - which is exactly what the fixture exists to prevent
    • Manifest Grails-Compile-Version / Groovy-Compile-Version are derived from the resolved compile classpath, not hand-typed, and are asserted by the integration test
    • Three genuinely precompiled commands prove the trait ABI holds across the Groovy 4 -> Groovy 5 boundary:
      • plain ApplicationCommand (G7-RAN)
      • GrailsApplicationCommand exercising the @Delegate forwarders (file(...), mkdir, render(...)) and applicationContext (G7-CONTEXT-true)
      • one declaring neither getName() nor getDescription() - the shape create-command generated on Grails 7 - so the trait's default name derivation is proven, registering as hello-derived-name
  • Upgrade / What's New docs updated for both flags and runCommand-only legacy execution

Migration

  • Consumers of already-published Grails 7 application-command plugins: enable grails { legacyCommandSupport = true } (or -PgrailsLegacyCommandSupport=true), or declare grailsCliLegacy explicitly when cliAutoProvision is disabled
  • Plugin authors should migrate to org.apache.grails.core.cli.* and publish a companion -cli artifact
  • A plugin that still authors against the deprecated contract must depend on grails-core-cli-legacy explicitly for compile
  • The compatibility layer is deprecated and may be removed in a future major release; no removal major has been announced

Related

PR #15948 split the CLI command tier off the application runtime classpath.
As part of that split the command contract moved from grails.dev.commands.*
to org.apache.grails.core.cli.* (with no forwarding alias) and command
registration moved from META-INF/grails.factories (key
grails.dev.commands.ApplicationCommand) to META-INF/grails-cli.factories.
The result is that an unchanged, already-published Grails 7 command plugin
silently loses all of its application commands on Grails 8: the command
classes no longer link and their factory entries are never read.

This adds a deprecated compatibility layer that lives entirely in the
grails-core-cli tier (so the runtime classpath split is preserved) and lets
unchanged Grails 7 command plugins keep working without a re-release:

- Restore the deprecated grails.dev.commands.* command contract
  (ApplicationCommand, GrailsApplicationCommand, ExecutionContext and the
  io/template helpers) so pre-compiled Grails 7 command classes link again.
- Adapt legacy commands to the new contract via LegacyApplicationCommandAdapter
  / LegacyApplicationCommandAware, and discover them from the legacy
  META-INF/grails.factories key in ApplicationContextCommandRegistry. Each
  legacy command is instantiated in isolation so one faulty command cannot
  suppress the rest, and a new-contract command always wins a name clash. A
  one-time deprecation warning nudges authors to migrate.
- Unwrap the adapter in GrailsApplicationContextCommandRunner so Spring
  autowiring and the skipBootstrap lookup target the real legacy command that
  is subsequently executed.
- Register per-command Gradle tasks for legacy commands found on the runtime
  classpath, degrading gracefully when the classpath cannot be resolved at
  configuration time (the generic runCommand task always works).

Separately, fix a Groovy 5 regression affecting create-script codegen scripts:
a hyphenated script filename (for example audit-quickstart) produced a class
name without the hyphen, so the command resolved under the wrong name.
GroovyScriptCommandFactory now uses the filename-derived command name.

Adds unit and integration tests (including exception-isolation and
adapter/runner coverage), a real end-to-end functional example under
grails-test-examples/legacy-commands that boots a Grails 8 application and
runs a legacy plugin's ApplicationCommand, GrailsApplicationCommand and
hyphenated script, and upgrade documentation describing the compatibility
path and which command mechanisms are affected.

Assisted-by: claude-code:claude-4.8-opus
Copilot AI review requested due to automatic review settings July 19, 2026 10:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR restores backwards compatibility for already-published Grails 7 command plugins when running on Grails 8, while preserving the CLI/runtime classpath split introduced in #15948. It reintroduces the deprecated grails.dev.commands.* contract inside the CLI tier, adds adapter/registry/runner support to load legacy commands from META-INF/grails.factories, and fixes a Groovy 5 regression where hyphenated script commands (e.g. audit-quickstart) could be discovered under the wrong name.

Changes:

  • Add a deprecated Grails 7 command compatibility layer (legacy contract types + adapter + dual-load registry behavior + runner unwrap).
  • Restore hyphen-preserving discovery for plugin-provided Groovy script commands.
  • Add unit + functional test coverage and a new end-to-end test-example fixture for legacy command plugins; update Grails 8 upgrade documentation accordingly.

Reviewed changes

Copilot reviewed 32 out of 32 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
settings.gradle Registers new legacy-commands test-example projects in the build.
grails-test-examples/legacy-commands/src/integration-test/groovy/legacycommands/LegacyCommandCompatibilityIntegrationSpec.groovy End-to-end integration test exercising legacy command discovery/adaptation and legacy script packaging.
grails-test-examples/legacy-commands/grails-app/init/legacycommands/Application.groovy Test-example application entry point.
grails-test-examples/legacy-commands/grails-app/controllers/legacycommands/UrlMappings.groovy Test-example URL mappings (baseline web profile wiring).
grails-test-examples/legacy-commands/grails-app/conf/logback.xml Test-example logging configuration.
grails-test-examples/legacy-commands/grails-app/conf/application.yml Test-example app configuration for integration execution.
grails-test-examples/legacy-commands/build.gradle Test-example app build with legacy-commands plugin dependency.
grails-test-examples/legacy-commands-plugin/src/main/scripts/hello-legacy-script.groovy Legacy plugin script command fixture with a hyphenated command name.
grails-test-examples/legacy-commands-plugin/src/main/resources/META-INF/grails.factories Legacy factories registration under grails.dev.commands.ApplicationCommand.
grails-test-examples/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/LegacyCommandsPluginGrailsPlugin.groovy Plugin descriptor for the legacy command fixture plugin.
grails-test-examples/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/HelloLegacyGrailsCommand.groovy Legacy GrailsApplicationCommand fixture.
grails-test-examples/legacy-commands-plugin/src/main/groovy/legacy/commands/plugin/HelloLegacyAppCommand.groovy Legacy ApplicationCommand fixture.
grails-test-examples/legacy-commands-plugin/build.gradle Fixture plugin build (compiles against grails-core-cli without publishing a -cli companion).
grails-shell-cli/src/test/groovy/org/grails/cli/profile/commands/factory/LegacyPluginScriptCompatSpec.groovy Unit test ensuring Grails 7-style plugin scripts remain discoverable with hyphenated names.
grails-shell-cli/src/main/groovy/org/grails/cli/profile/commands/factory/GroovyScriptCommandFactory.groovy Ensures compiled script commands use the filename-derived command name (hyphen-preserving).
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/commands/GrailsCliGradlePlugin.groovy Adds legacy factories scanning on runtimeClasspath to keep per-command Gradle tasks for Grails 7 plugins.
grails-doc/src/en/guide/upgrading/upgrading80x.adoc Documents the deprecated compatibility layer and the migration path for plugin authors.
grails-core/src/test/groovy/org/apache/grails/core/cli/LegacyCommandRegistryLoadingSpec.groovy Unit tests for legacy factories loading, isolation on constructor failure, and collision preference rules.
grails-core/src/test/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapterSpec.groovy Unit test verifying adapter forwarding into legacy execution context.
grails-core/src/cli/groovy/org/apache/grails/core/cli/LegacyApplicationCommandAware.groovy Marker interface for adapters to allow runner autowire/inspection unwrap.
grails-core/src/cli/groovy/org/apache/grails/core/cli/compat/LegacyApplicationCommandAdapter.groovy Adapter bridging legacy grails.dev.commands.ApplicationCommand into the Grails 8 CLI contract.
grails-core/src/cli/groovy/org/apache/grails/core/cli/ApplicationContextCommandRegistry.groovy Registry now dual-loads legacy factories and wraps legacy commands via adapter with one-time warning.
grails-core/src/cli/groovy/grails/dev/commands/template/TemplateRendererImpl.groovy Restored deprecated template renderer implementation for legacy command plugins.
grails-core/src/cli/groovy/grails/dev/commands/template/TemplateRenderer.groovy Restored deprecated template renderer API for legacy command plugins.
grails-core/src/cli/groovy/grails/dev/commands/template/TemplateException.groovy Restored deprecated template exception for legacy command plugins.
grails-core/src/cli/groovy/grails/dev/commands/io/FileSystemInteractionImpl.groovy Restored deprecated filesystem interaction implementation for legacy command plugins.
grails-core/src/cli/groovy/grails/dev/commands/io/FileSystemInteraction.groovy Restored deprecated filesystem interaction API for legacy command plugins.
grails-core/src/cli/groovy/grails/dev/commands/GrailsApplicationCommand.groovy Restored deprecated legacy GrailsApplicationCommand trait for Grails 7 commands.
grails-core/src/cli/groovy/grails/dev/commands/ExecutionContext.groovy Restored deprecated legacy execution context type.
grails-core/src/cli/groovy/grails/dev/commands/ApplicationCommand.groovy Restored deprecated legacy application command contract trait.
grails-console/src/test/groovy/grails/ui/command/GrailsApplicationContextCommandRunnerSpec.groovy Tests that runner unwraps legacy adapter for autowiring/inspection.
grails-console/src/main/groovy/grails/ui/command/GrailsApplicationContextCommandRunner.groovy Runner unwraps legacy adapters for autowiring and skipBootstrap inspection.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

…mmand-compat

# Conflicts:
#	grails-doc/src/en/guide/upgrading/upgrading80x.adoc
- Guard the thread-context classloader command scan so legacy commands are not
  re-instantiated when the context and registry classloaders are the same
  (their constructors may have side effects); the redundant pass was fully
  discarded by the name-collision check anyway.
- Log the throwable (not just its message) when a legacy command fails to load
  through the compatibility layer, so the stack trace is available for
  diagnosis.
- Add a Gradle TestKit test proving a legacy command shipped in a runtime jar
  (registered under grails.dev.commands.ApplicationCommand in
  META-INF/grails.factories) registers its per-command task in a consuming
  application.
- Deepen the functional example: assert Spring autowiring reaches the wrapped
  legacy command through the runner's unwrap path (a real service is injected),
  and that the legacy GrailsApplicationCommand file() code-generation DSL
  produces output through the adapter.

Assisted-by: claude-code:claude-4.8-opus
- Gather legacy command classes from the registry and thread-context
  classloaders into a set de-duplicated by Class identity before instantiating
  any, so a parent-visible legacy command reported through a delegating child
  classloader is not instantiated twice (its constructor may have side effects)
  only to be discarded on the name-collision check.
- Correct whatsNew.adoc, which still stated a command-bearing Grails 7 plugin's
  commands are unavailable until it publishes a companion -cli artifact; they
  now remain available through the deprecated compatibility layer.

Assisted-by: claude-code:claude-4.8-opus
Replace the manual `private static final Logger LOG = LoggerFactory.getLogger(...)`
declaration with the codebase-standard `@Slf4j` annotation (generates a `log`
field), matching the dominant convention across grails-core. Addresses review
feedback on PR #16011.

Assisted-by: claude-code:claude-opus-4-8
Comment thread grails-test-examples/legacy-commands-plugin/build.gradle Outdated
@jdaugherty

Copy link
Copy Markdown
Contributor

@jamesfredley if you can let me know today, I'm happy to add the proposed compatibility layer. I'd like to proceed with the M4. Are you wanting this done before the M4?

…egacy

The backwards-compatibility layer that keeps unchanged Grails 7 command
plugins (grails.dev.commands.ApplicationCommand) working on Grails 8 was
restored inside grails-core-cli, which put the deprecated contract on the
compile ABI of every plugin that depends on the CLI tier. Move the deprecated
types, the adapter, and the legacy loader into a new published subproject
grails-core-cli-legacy and provision it execution-only.

grails-core-cli now exposes a neutral SPI - ApplicationCommandProvider,
ApplicationCommandRegistrar, and ApplicationCommandTargetAware - and
ApplicationContextCommandRegistry discovers providers through
META-INF/grails-cli.factories (dual-classloader, class-identity deduplicated,
failure isolated) with modern commands winning name collisions. The legacy
module ships LegacyApplicationCommandProvider, which performs the deprecated
dual-classloader load, adapter-wrap, and one-time deprecation warning.
grails-core-cli retains zero references to grails.dev.commands.

The Grails Gradle plugin adds a non-resolvable grailsCliLegacy bucket that
feeds only grailsCliClasspath and the test runtime classpaths - never any
compile classpath - and is auto-provisioned by default via cliAutoProvision,
so compatibility stays on by default with zero user action. A plugin that
still authors legacy commands compiles against grails-core-cli-legacy
explicitly.

Assisted-by: claude-code:claude-opus-4-8
@codecov

codecov Bot commented Jul 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 32.66667% with 202 lines in your changes missing coverage. Please review.
✅ Project coverage is 51.5022%. Comparing base (c6ce3fe) to head (75ed393).

Files with missing lines Patch % Lines
.../dev/commands/template/TemplateRendererImpl.groovy 5.7971% 65 Missing ⚠️
...s/dev/commands/io/FileSystemInteractionImpl.groovy 10.6061% 59 Missing ⚠️
...radle/plugin/commands/GrailsCliGradlePlugin.groovy 0.0000% 33 Missing ⚠️
...cli/compat/LegacyApplicationCommandProvider.groovy 70.9091% 6 Missing and 10 partials ⚠️
...ds/factory/ApplicationContextCommandFactory.groovy 54.1667% 8 Missing and 3 partials ⚠️
...mmand/GrailsApplicationContextCommandRunner.groovy 40.0000% 6 Missing ⚠️
...rails/dev/commands/io/FileSystemInteraction.groovy 0.0000% 4 Missing ⚠️
...rails/core/io/support/GrailsFactoriesLoader.groovy 81.8182% 0 Missing and 4 partials ⚠️
...g/grails/gradle/plugin/core/GrailsExtension.groovy 33.3333% 2 Missing ⚠️
...oovy/org/grails/cli/profile/AbstractProfile.groovy 0.0000% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16011        +/-   ##
==================================================
+ Coverage     51.4910%   51.5022%   +0.0112%     
- Complexity      17762      17836        +74     
==================================================
  Files            2039       2044         +5     
  Lines           95537      95823       +286     
  Branches        16571      16629        +58     
==================================================
+ Hits            49193      49351       +158     
- Misses          39037      39142       +105     
- Partials         7307       7330        +23     
Files with missing lines Coverage Δ
.../cli/compat/LegacyApplicationCommandAdapter.groovy 100.0000% <100.0000%> (ø)
...commands/factory/GroovyScriptCommandFactory.groovy 90.0000% <100.0000%> (+28.8889%) ⬆️
...g/grails/gradle/plugin/core/GrailsExtension.groovy 54.3478% <33.3333%> (-1.4661%) ⬇️
...oovy/org/grails/cli/profile/AbstractProfile.groovy 38.8235% <0.0000%> (-0.1529%) ⬇️
...rails/dev/commands/io/FileSystemInteraction.groovy 0.0000% <0.0000%> (ø)
...rails/core/io/support/GrailsFactoriesLoader.groovy 59.6774% <81.8182%> (+12.1774%) ⬆️
...mmand/GrailsApplicationContextCommandRunner.groovy 11.9048% <40.0000%> (+9.1270%) ⬆️
...ds/factory/ApplicationContextCommandFactory.groovy 51.7241% <54.1667%> (+51.7241%) ⬆️
...cli/compat/LegacyApplicationCommandProvider.groovy 70.9091% <70.9091%> (ø)
...radle/plugin/commands/GrailsCliGradlePlugin.groovy 0.0000% <0.0000%> (ø)
... and 2 more

... and 12 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jdaugherty

Copy link
Copy Markdown
Contributor

@jamesfredley I strongly disagree with the legacy compatibility by default. A single setting can be changed as part of the upgrade if people use legacy plugins - this matches previous decisions like indy, and many more. Making this a default incentivizes people to not adopt the new api and when we remove it, it will break outside of a major release lifecycle.

…ge failures

Address non-blocking review feedback on the Grails 7 command compatibility
layer:

- Resolve a contributed command's name exactly once. ApplicationCommandRegistrar
  now returns the installed command name (or null when the name is already
  registered) instead of a boolean, so LegacyApplicationCommandProvider no longer
  reads command.name a second time for the deprecation warning - a stateful or
  second-call-throwing legacy getter can no longer be installed without emitting
  the promised warning.

- Surface binary-incompatible legacy plugins loudly. LegacyApplicationCommandProvider
  now catches LinkageError separately and logs it at error level naming the command
  class (a trait-woven Grails 7 binary that does not link against the restored
  contract), while still isolating the failure so other commands continue to load.
  Reflective construction wraps constructor throwables in InvocationTargetException,
  so linkage failures thrown from a constructor are unwrapped and classified
  correctly.

- Add regression tests: single name resolution under a throwing getter, the
  linkage-failure error path, and class-identity deduplication of providers
  discovered through both the registry and context class loaders.

Assisted-by: claude-code:claude-opus-4-8
@jdaugherty

Copy link
Copy Markdown
Contributor

@jamesfredley Please stop resolving comments when they're only half completed. It makes extremely hard to understand what's done and what isn't.

Resolve GrailsCliGradlePlugin conflict by keeping both the legacy
ApplicationCommand factories key and the Grails task group constant
introduced on 8.0.x.

Assisted-by: Sisyphus:xai/grok-4.5
@jamesfredley jamesfredley linked an issue Jul 22, 2026 that may be closed by this pull request
Split control of the Grails 7 application-command bridge from modern CLI
companion auto-provisioning. Both flags default to true; the legacy bridge
provisions only when both are enabled. Version-pin framework CLI artifacts to
the current Grails version, and derive external companion versions from the
producer module or jar name.

Assisted-by: Sisyphus:xai/grok-4.5
@jamesfredley

Copy link
Copy Markdown
Contributor Author

@jdaugherty Update after the latest push (c798c93778):

There is now a dedicated legacyCommandSupport flag (default on), separate from cliAutoProvision.

  • cliAutoProvision controls modern CLI companions only
  • legacyCommandSupport controls only the Grails 7 bridge (grails-core-cli-legacy)
  • Bridge provisions only when both are true
  • legacyCommandSupport = false keeps modern companions and disables only the bridge

I kept the default on so already-published G7 command plugins keep working with zero user action after the #15948 split. The separate flag is the opt-out for people who want the modern CLI tier without the compatibility bridge.

If the project decision is still "default off + loud detect/warn when legacy factories are present", that is a policy call and I can flip the default. The wiring is already split so that change is one convention flip, not a redesign.

@jamesfredley

Copy link
Copy Markdown
Contributor Author

@jdaugherty On M4 timing: yes, I want this compatibility path landed before M4 if we can.

Current pushed state:

  • separate execution-only grails-core-cli-legacy artifact
  • two flags (cliAutoProvision, legacyCommandSupport), both default on
  • linkage failures isolated/loud
  • docs updated

Still open for a stronger green light:

  • real Grails 7 / Groovy 4 precompiled fixture via composite build
  • whether legacyCommandSupport default should stay on or flip off
  • remaining Forge CI failures from empty -cli versions should be helped by the version-pin work in c798c93778; watching the new CI run

@jamesfredley

Copy link
Copy Markdown
Contributor Author

@jdaugherty Agreed. I will not resolve review threads unless the full ask is actually done and you are good with it.

For the current open items I am only replying with status, not marking resolved.

@jamesfredley

Copy link
Copy Markdown
Contributor Author

On the Forge failures (Could not find org.apache.grails:grails-scaffolding-cli:. / grails-data-hibernate5-cli:. with empty versions):

That was auto-provision creating companion coordinates without a version. The latest push pins framework CLI artifacts (grails-core-cli, grails-console, grails-core-cli-legacy) to the current Grails version, and derives external companion versions from the producer module/jar instead of leaving them versionless.

Watching the post-c798c93778 CI run for Forge. If anything still fails there I will chase it separately from the default-on policy discussion.

Remove configuration-time runtimeClasspath scanning for grails.dev.commands
ApplicationCommand entries. Legacy commands stay available through runCommand
and the shell adapter once grails-core-cli-legacy is provisioned.

Assisted-by: Sisyphus:xai/grok-4.5
Add a standalone fixture compiled against published grails-core 7.0.10 /
Groovy 4.0.30, wire it into the legacy-commands example via GradleBuild, and
assert the binary is discovered and executed through the Grails 8 adapter.
Document that legacy commands use runCommand rather than named Gradle tasks.

Assisted-by: Sisyphus:xai/grok-4.5
Use single-quoted SLF4J message templates, drop unnecessary safe-navigation
on non-null loop variables, and rename the empty catch variable to ignored so
EmptyCatchBlock accepts it.

Assisted-by: Sisyphus:xai/grok-4.5
@jamesfredley

Copy link
Copy Markdown
Contributor Author

Latest push summary (1d8edac99e)

Addressed remaining review/proof gaps and the Code Style CI failure.

1. Removed legacy named-task scan

  • Deleted loadLegacyCommandNamesFromRuntimeClasspath and the configuration-time runtimeClasspath scan for grails.dev.commands.ApplicationCommand
  • Legacy application commands no longer invent flaky named Gradle tasks
  • They stay available via runCommand / shell adapter once grails-core-cli-legacy is provisioned
  • TestKit now asserts LEGACY_COMMAND_TASK_PRESENT=false and RUN_COMMAND_TASK_PRESENT=true
  • Docs updated to show the runCommand path

2. Real Grails 7 / Groovy 4 precompiled fixture

  • New standalone project: grails-test-examples/legacy-g7-command-plugin
  • Compiles against published grails-core:7.0.10 / Groovy 4.0.30
  • Manifest stamps: Grails-Compile-Version: 7.0.10, Groovy-Compile-Version: 4.0.30
  • Built via GradleBuild (not monorepo includeBuild) so monorepo BOM/JVM substitution cannot rewrite the fixture
  • Wired into grails-test-examples/legacy-commands
  • Integration test proves:
    • Adapter targets legacy.g7.commands.HelloG7PrecompiledCommand
    • Class loads from the G7 fixture jar
    • Command executes and writes G7-RAN
  • LegacyCommandCompatibilityIntegrationSpec: 5/5 green locally, including the new G7 feature

3. Dual flags (already on tip before this note)

Flag Default Scope
cliAutoProvision on modern CLI tier + companions
legacyCommandSupport on only grails-core-cli-legacy

Legacy provisions only when both are true.

4. Code style CI fix

  • Single-quoted SLF4J templates
  • Dropped unnecessary ?. on non-null loop variables
  • Empty catch variable renamed to ignored (EmptyCatchBlock)
  • Local: :grails-core-cli-legacy:codenarcMain + :grails-core:codenarcCli green with ignoreFailures=false

Still open for reviewer preference

The Grails 7 application-command compatibility bridge is now off by
default. Consumers that still need unchanged Grails 7 command plugins
must opt in with grails { legacyCommandSupport = true } or
-PgrailsLegacyCommandSupport=true. Modern CLI auto-provisioning
(cliAutoProvision) remains on by default and is independent.

Update upgrade/What's New docs, TestKit coverage (default false,
explicit false/true, master-switch override), and the legacy-commands
fixture to match.

Assisted-by: Sisyphus:grok-4.5
@jamesfredley

Copy link
Copy Markdown
Contributor Author

Pushed 116b0efab3: flipped legacyCommandSupport default from true → false everywhere.

Code

  • GrailsExtension.legacyCommandSupport convention is now Boolean.FALSE when grailsLegacyCommandSupport is unset
  • Javadoc / plugin property docs describe opt-in (= true), not opt-out

Docs

  • Upgrade guide §35 / §35.1 and What's New: default false, enable examples, consumer checklist

Tests / fixtures

  • CliAutoDiscoverySpec: default-false, explicit false, explicit true, and cliAutoProvision=false still wins when legacy is opted in
  • grails-test-examples/legacy-commands opts in with grails { legacyCommandSupport = true }

PR description updated to match (default-off bridge, opt-in migration note).

@jamesfredley jamesfredley changed the title Restore backwards compatibility for Grails 7 command plugins on Grails 8 Restore opt-in backwards compatibility for Grails 7 command plugins on Grails 8 Jul 22, 2026
@jamesfredley jamesfredley changed the title Restore opt-in backwards compatibility for Grails 7 command plugins on Grails 8 Opt-in backwards compatibility for Grails 7 command plugins on Grails 8 Jul 22, 2026
Comment thread grails-test-examples/legacy-g7-command-plugin/build.gradle Outdated
Resolve CLI companions from Gradle component metadata, preserve command loading order and fatal error semantics, report unsupported Grails 7 command factories at runtime, and strengthen the real Grails 7 fixture coverage.

Assisted-by: opencode:gpt-5.6-sol codegraph
@jamesfredley jamesfredley moved this to Todo in Apache Grails Jul 24, 2026
@borinquenkid borinquenkid added this to the grails:8.0.0-RC1 milestone Jul 27, 2026

@jdaugherty jdaugherty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the whole branch against my earlier rounds and resolved all nine of my open threads at 75ed39351b — the ABI isolation, the LinkageError/VirtualMachineError split with jar-named, framework-blaming wording, the removal of the nondeterministic named-task scan, the component-metadata companion version resolution, the runtime-only legacy detection with hints on both unknown-command paths, and the precompiled Grails 7 / Groovy 4 fixture actually executing through the registry. The last of those (the fixture tightenings) I resolved on the strength of three of its four asks being done; the remaining one is carried by a comment below rather than left buried in a thread about the build file. The legacyCommandSupport default is false, which matches where the dev list landed; I have nothing further on that.

Everything still open below is already implemented and verified on a branch: test/8.0.x-legacy-command-e2e (one commit on top of 75ed39351b, opened as a draft against this branch in #16059). Take it, cherry-pick from it, or ignore it — it exists so none of this has to block the merge on working out the shape. Nothing below is a request for you to go and write code.

The two I'd want fixed before merge

  1. The Grails 7 fixture's JDK 17 requirement is expressed as a Gradle toolchain that nothing in the repo provisions, and it sits in the core build's task graph — so ./gradlew build -PskipTests fails on the documented developer JDK and in the reproducible-build container. Reproduced. The version is right; the placement isn't.
  2. loadFactoryDeclarations silently discards a malformed factories resource — and it now backs modern grails-cli.factories discovery, so one bad file loses a plugin's Grails 8 commands with no log line anywhere.

The other two

  1. The last part of my fixture tightening #4: the trait's default getName()/getDescription() derivation is shadowed by every legacy command in the tree, so the "command declares no name" variant — the shape create-command generated, and therefore what most published Grails 7 commands rely on for their registration key — has zero coverage.
  2. No test coverage for the skipBootstrap-from-target behaviour, which decides whether BootStrap runs during things like dbm-update.

What the branch does

The build half is the substantial part: the three legacy-command projects move to a new top-level end-to-end build, the toolchain block is gone, the Grails 7 fixture pins its own JDK and Gradle version (17 and 8.14.5, what Grails 7 uses) in its own .sdkmanrc with gradle-bootstrap generating that wrapper from it, and .github/workflows/end-to-end.yml provisions both JDKs by reading those files.

Rather than composing grails-core with includeBuild('..'), it resolves from the artifacts the core build publishes — the same build/local-maven grails-forge points its generated apps at. That is what makes them end-to-end, and it disposes of the CLI companion problem instead of working around it: grails-core-cli can't be reached by composite substitution (it's a capability of :grails-core, not a project) but is a first-class published module, so resolving from the repository just works.

The minor items — also done, except one

  • ThreadDeath removed from all four fatal-error guards; VirtualMachineError handling and the wrapped-cause walk untouched, and the specs now prove rethrow across two VirtualMachineError subtypes instead.
  • rethrowIfFatal() left duplicated, deliberately. grails-shell-cli cannot see grails-core at compile time (verified: its compileClasspath resolves grails-bootstrap, not grails-core — which is why the factory loads the registry by string name). The only type visible to all four sites is ExceptionUtils in grails-bootstrap, and putting a CLI error-triage rule there means new public API on a general-purpose published utility. Worse trade than 13 duplicated lines. The byte-identical instantiate()/instantiateCommand() pair inside the one class was collapsed.
  • Commands are now deduplicated across the registry and context classloaders, matching the provider path. Nice bit of evidence: the existing assertion was constructorCalls == registryClassLoaderConstructions + 1, and that + 1 was the double construction — it now asserts exactly one.
  • Legacy commands take part in ordering: the adapter projects the target's Ordered/@Order onto itself (plain OrderComparator reads only Ordered, never annotations) and the provider sorts before registering.
  • The stale "composite-build / included by the monorepo root" comments and the duplicated 7.0.14 pin are fixed.
  • ApplicationCommandProviderSpec's misnamed feature: the dead branch is measured-dead (instrumented the handler; exactly one openConnection) and removed, and the feature renamed to what it actually verifies. Making the branch live would have broken the test — the fixture throws AssertionError, which loadFactoryDeclarations does not catch, so the hint would come back null and fail the feature's own first assertion.
  • AbstractProfile:488 now has a spec driving the public Profile.handleCommand, red-checked against the pre-PR line.

On my inheritance question

Your answer is right that my literal proposal (legacy trait extends the new trait) fails: the bridge method lands abstract on the interface and an unrecompiled Grails 7 binary gets AbstractMethodError rather than a clean failure, which is worse than today. Worth noting for the record that "inheritance does not adapt the argument type" isn't true in general — a Java interface carrying a real default handle(new ExecutionContext) that delegates to handle(legacy ExecutionContext) does work on an unrecompiled binary. But the adapter is still the better choice, and for a concrete reason worth writing down: with the bridge, adding any member to org.apache.grails.core.cli.ApplicationCommand breaks every precompiled Grails 7 binary with AbstractMethodError, whereas the adapter absorbs that evolution. No change requested.

Verification on the branch

:grails-core:test 487, :grails-core-cli-legacy:test 19, :grails-shell-cli:test 175, :grails-console:test 36 — zero failures; codenarc and checkstyle clean on all four; the end-to-end suite 6/6 green against freshly published artifacts, and green in CI on #16059 including the two-JDK setup and the fixture-provenance check.


java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (single commit).

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.

properties.load(input)
}
}
catch (IOException | IllegalArgumentException ignored) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: 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).

class HelloG7PrecompiledCommand implements ApplicationCommand {

@Override
String getName() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Object autowireTarget = resolveAutowireTarget(command)

Object skipBootstrap = command.hasProperty('skipBootstrap')?.getProperty(command)
Object skipBootstrap = autowireTarget.hasProperty('skipBootstrap')?.getProperty(autowireTarget)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: 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).

@jdaugherty

Copy link
Copy Markdown
Contributor

I think AI double posted my responses, but #16059 fixes all of my issues with this branch. If we merge that into this, we should merge this change.

jdaugherty and others added 6 commits July 29, 2026 10:54
Addresses the outstanding review feedback on the Grails 7 command
compatibility layer.

Build placement. The Grails 7 fixture needs Java 17 - the minimum for a
Grails 7 app, so the binary matches what a real Grails 7 plugin is built
with - but expressing that as a Gradle toolchain put an unprovisionable
JDK requirement into the core build's task graph. Nothing in the repo
provisions a 17 (no foojay resolver, no toolchainManagement, no
org.gradle.java.installations.*), and because the fixture jar was an
implementation dependency, `./gradlew build -PskipTests` reached it - so
the build failed on the JDK the project documents in .sdkmanrc and in the
reproducible-build container. CI only passed because the runner images
happen to ship a 17 that Gradle auto-detects.

The three projects now live in a new top-level end-to-end build, so the
root build no longer reaches them. The fixture declares its JDK and
Gradle version in its own .sdkmanrc, matching what Grails 7 pins (17 and
8.14.5) rather than what this repository builds with, and is excluded
from the gradle-bootstrap wrapper propagation for that reason; the
end-to-end build itself is added to that propagation and tracks the root.
A dedicated workflow provisions both JDKs, reading the versions out of
those files.

Resolution goes through published artifacts rather than project
substitution, which is what makes these tests end-to-end: they consume
grails-core the way an application does, through real poms and module
metadata. The repository is the same build/local-maven that grails-forge
points its generated applications at, populated by
publishAllPublicationsToTestCaseMavenRepoRepository in both the root and
grails-gradle builds. settings.gradle scopes it with exclusiveContent so
a remote snapshot cannot quietly satisfy an org.apache.grails request and
leave the suite testing something other than the working tree.

That also disposes of the CLI companion problem rather than working
around it. grails-core-cli is a secondary capability of :grails-core, not
a project, so composite substitution cannot express it and hits a
capability self-conflict - but it is a first-class published module whose
metadata CliPublishingSupport already rewrites for external consumers, so
resolving from the repository gets it for free.

Trait-derived command names. Every legacy command in the tree overrode
getName()/getDescription(), so the trait's default derivation - what
create-command generated on Grails 7, and therefore what most published
Grails 7 commands rely on for their registration key - had no coverage.
Adds a third precompiled command declaring neither getter.

Factory resource failures are no longer silent. loadFactoryDeclarations
dropped a malformed resource with no log line at any level, and it backs
modern grails-cli.factories discovery, so one bad file lost every one of
a plugin's Grails 8 commands and providers. It now warns with the
resource URL and cause, keeping the per-resource isolation.

skipBootstrap resolution is covered. The lookup moves into a static
helper so the adapter-target case - where reading the flag off the
adapter instead of its target would let BootStrap run during dbm-update -
is guarded by a test.

Commands are now deduplicated across the registry and context
classloaders the way providers already were. A command declared in a
resource visible through both was constructed twice and the second
instance discarded, which the surrounding comment says the code exists to
avoid; an existing assertion encoded that double construction and now
asserts a single one.

Legacy commands take part in ordering. Modern commands are sorted before
first-wins registration, but legacy ones were registered in factory scan
order and the adapter carried none of the target's ordering, so two
Grails 7 plugins declaring the same command name resolved by jar order.
The adapter now projects the target's Ordered/@order onto itself and the
provider sorts before registering, restoring Grails 7 semantics.

ThreadDeath handling is removed from the four fatal-error guards.
Thread.stop() was removed in JDK 20, so on the 21 baseline the only
instances that branch ever saw were the ones the tests constructed, and
the type is deprecated for removal. VirtualMachineError handling and the
wrapped-cause walk are unchanged, and the specs now prove rethrow across
two VirtualMachineError subtypes instead. The duplicated rethrowIfFatal
is deliberately left in place: grails-shell-cli cannot see grails-core at
compile time, so consolidating would mean new public API on a
general-purpose utility for a CLI-internal concern.

Test quality. A spec named for a plugin-origin resolution failure
described a code path that does not exist and whose failure branch was
unreachable; it is renamed to what it verifies, with the dead branch
removed. AbstractProfile's unknown-command path, changed by this work but
uncovered, gets a spec driving the public Profile.handleCommand.

Also collapses two byte-identical instantiate helpers, drops a duplicated
fixture version pin in the integration spec, and corrects comments
describing the fixture as an included composite build.

Raises the forge CLI spec polling budget from 240s to 600s. The specs that
call executeGradleCommand('build') build a whole generated application -
asset compilation, bootWar, test and integrationTest - so 240s was
marginal rather than generous: on the Java 25 lane that build measured
217s when it passed, and a runner around 10% slower than average pushed
it past the limit and failed the lane on timing alone, with the poll
reporting a bare "condition not satisfied" because it only ever watches
for the success string.
Raises the forge CLI spec polling budget from 240s to 600s and stops the
wait once the build process is gone. The specs that call
executeGradleCommand('build') build a whole generated application - asset
compilation, bootWar, test and integrationTest - so 240s was marginal
rather than generous: on the Java 25 lane that build measured 217s when it
passed, and a runner around 10% slower than average pushed it past the
limit and failed the lane on timing alone.

Raising the budget alone would have made a genuinely failing generated
build take even longer to report, and report it only as a bare unsatisfied
condition, which is what made a failed build look like a hang.
testOutputContains therefore no longer uses PollingConditions, which
retries on any Throwable and so cannot short-circuit once the build has
finished; it polls directly and fails as soon as the process has exited
without emitting the expected output, naming the exit code and including
the build output. The early exit keys on process liveness rather than on a
BUILD FAILED marker because several specs legitimately wait for text from
a build that is expected to fail.
Move legacy command compatibility tests into an end-to-end build
Restore Apache RAT compliance for the end-to-end Gradle properties file.

Assisted-by: opencode:gpt-5.6-sol
Use the shared LogCapture fixture so diagnostic assertions observe SLF4J events directly instead of relying on an unconfigured standard-error appender.

Assisted-by: opencode:gpt-5.6-sol codegraph
Assisted-by: opencode:gpt-5.6-terra
System.err redirection no longer observes DefaultStackTraceFilterer
STACK_LOG output under the SLF4J/Logback test classpath, which left
baos empty and failed the default-on positive-control specs in CI.
Align GrailsUtilStackFiltererSpec and GrailsBootstrapRegistryInitializerSpec
with StackTraceFiltererSpec by asserting against the StackTrace logger
through the shared LogCapture fixture.

Assisted-by: Sisyphus:xai/grok-4.5
@borinquenkid

Copy link
Copy Markdown
Member

The TestLens failures for GrailsUtilStackFiltererSpec > installed DefaultStackTraceFilterer emits Full Stack Trace by default and GrailsBootstrapRegistryInitializerSpec > defaults logFullStackTraceOnFilter to true on the promoted DefaultStackTraceFilterer are a pre-existing bug on 8.0.x, unrelated to this PR's changes -- reproducible on plain 8.0.x today. DefaultStackTraceFilterer.STACK_LOG routes through a jcl-over-slf4j commons-logging binding, so the tests' System.setErr() capture never observes the emitted message.

Fix: #16067 (replaces the System.err capture with a Logback appender attached directly to the logger). Once that merges, rebasing onto 8.0.x should clear this failure here.

@testlens-app

testlens-app Bot commented Jul 29, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: bdb98a6
▶️ Tests: 41416 executed
⚪️ Checks: 60/60 completed


Learn more about TestLens at testlens.app.

@jamesfredley
jamesfredley merged commit 301b40c into 8.0.x Jul 29, 2026
60 of 61 checks passed
@github-project-automation github-project-automation Bot moved this from Todo to Done in Apache Grails Jul 29, 2026
@jamesfredley
jamesfredley deleted the feat/8.0.x-legacy-command-compat branch July 29, 2026 22:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Split CLI Dependencies from runtime classpath

5 participants