You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by ja...@apache.org on 2026/04/25 16:11:13 UTC

(grails-core) 06/07: Make issue-11767 spring-config-metadata merge properly cacheable

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

jamesfredley pushed a commit to branch upgrade/gradle-9.3.1
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit d9a218175ac98c4b9e81267e0d28b793b1ca7f83
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Sat Apr 25 11:53:59 2026 -0400

    Make issue-11767 spring-config-metadata merge properly cacheable
    
    Replaces the previous outputs.upToDateWhen { false } / mutate-in-place
    implementation flagged by jdaugherty in PR #15365 review with a
    properly-declared incremental task that Gradle can mark UP-TO-DATE
    between builds.
    
    Changes:
    * Merge writes to a new build/merged-spring-configuration-metadata/
      directory instead of mutating build/classes/{java,groovy}/main.
    * Inputs are declared as a lazy FileCollection that filters to existing
      files at execution time, so Gradle no longer fails the validation
      when one or both compilers are NO-SOURCE.
    * outputs.cacheIf { true } enables Gradle build-cache reuse.
    * The task explicitly dependsOn compileJava and compileGroovy so the
      compile output dirs always populate before the merge runs.
    * The jar task uses an eachFile filter to drop the per-compiler copies
      but keep the merged copy, instead of a global exclude that would have
      also discarded the merged document.
    * Removed the long block comment - the inline 7-line summary above the
      task is enough context for future readers.
    
    Verified:
    * gradle :grails-test-examples-plugins-issue-11767:clean :...:jar
      produces build/libs/...jar containing exactly one
      META-INF/spring-configuration-metadata.json with both
      PluginJavaMicronautBean (groups + properties) and
      PluginGroovyMicronautBean entries merged.
    * A second jar invocation reports compileJava, compileGroovy,
      mergeMicronautConfigMetadata, and jar all UP-TO-DATE - the previous
      always-rerun behavior is gone.
    * The downstream consumer (:grails-test-examples-issue-11767) compiles
      cleanly, confirming the merged metadata still satisfies its
      @ConfigurationProperties('my') consumer code.
    
    Assisted-by: claude-code:claude-opus-4-7
---
 .../plugins/issue-11767/build.gradle               | 91 ++++++++++++++--------
 1 file changed, 57 insertions(+), 34 deletions(-)

diff --git a/grails-test-examples/plugins/issue-11767/build.gradle b/grails-test-examples/plugins/issue-11767/build.gradle
index 1052a19aaf..1b68d01892 100644
--- a/grails-test-examples/plugins/issue-11767/build.gradle
+++ b/grails-test-examples/plugins/issue-11767/build.gradle
@@ -42,45 +42,55 @@ apply {
     from rootProject.layout.projectDirectory.file('gradle/grails-extension-gradle-config.gradle')
 }
 
-// Root-cause fix for duplicate META-INF/spring-configuration-metadata.json:
-//
 // This plugin intentionally exposes both a Java bean (PluginJavaMicronautBean) and a
-// Groovy bean (PluginGroovyMicronautBean) that share the @ConfigurationProperties('my')
-// prefix to exercise the original issue 11767 scenario. As a side effect, two different
-// Micronaut tools produce the same metadata file:
-//   - micronaut-inject-java runs as a Java annotation processor during compileJava and
-//     writes build/classes/java/main/META-INF/spring-configuration-metadata.json
-//     describing PluginJavaMicronautBean.
-//   - micronaut-inject-groovy (pulled in transitively via grails-micronaut) runs as a
-//     Groovy AST transform during compileGroovy and writes
-//     build/classes/groovy/main/META-INF/spring-configuration-metadata.json describing
-//     PluginGroovyMicronautBean.
-// The two files are not equivalent: each describes the beans visible to its own compiler.
-// The shared CompilePlugin convention sets jar.duplicatesStrategy = FAIL so the build
-// surfaces any double-configuration. Here the duplicate is by design, so we merge the
-// two JSON documents into a single file (preserving every group / property / hint) and
-// remove the now-merged source so the jar task sees exactly one metadata file. This is
-// preferable to dropping one of the documents (DuplicatesStrategy.EXCLUDE) because that
-// would silently lose the bean type information for one of the languages.
+// Groovy bean (PluginGroovyMicronautBean) under @ConfigurationProperties('my') to
+// exercise the issue 11767 scenario. As a side effect, micronaut-inject-java (Java AP)
+// and micronaut-inject-groovy (Groovy AST) each emit a non-equivalent
+// META-INF/spring-configuration-metadata.json describing only the beans visible to
+// their compiler, which collides with the shared jar.duplicatesStrategy = FAIL.
+// Merge the two JSON documents into one combined file in a dedicated build directory,
+// then exclude the per-compiler copies from the jar and feed the merged file in.
+def metadataRelativePath = 'META-INF/spring-configuration-metadata.json'
+def javaMetadata = layout.buildDirectory.file("classes/java/main/${metadataRelativePath}")
+def groovyMetadata = layout.buildDirectory.file("classes/groovy/main/${metadataRelativePath}")
+def mergedMetadataDir = layout.buildDirectory.dir('merged-spring-configuration-metadata')
+def mergedMetadata = mergedMetadataDir.map { it.file(metadataRelativePath) }
+
 def mergeMicronautConfigMetadata = tasks.register('mergeMicronautConfigMetadata') {
     description = 'Merges the spring-configuration-metadata.json emitted separately by ' +
             'micronaut-inject-java and micronaut-inject-groovy into one combined document.'
     group = 'build'
 
-    def metadataRelativePath = 'META-INF/spring-configuration-metadata.json'
-    def javaMetadataProvider = layout.buildDirectory.file("classes/java/main/${metadataRelativePath}")
-    def groovyMetadataProvider = layout.buildDirectory.file("classes/groovy/main/${metadataRelativePath}")
-
-    // Re-run whenever either compile step emits new metadata; the task mutates files
-    // inside the compile output directories so we cannot rely on standard up-to-date
-    // tracking. The work is cheap (two small JSON files) so always running is acceptable.
-    outputs.upToDateWhen { false }
+    // Both files may or may not exist depending on which compiler ran and what beans
+    // it found, so feed them as a lazy FileCollection that filters to existing files
+    // at execution time. Declaring the producing tasks as dependencies anchors them
+    // in the task graph so this task runs after compileJava and compileGroovy.
+    dependsOn tasks.named('compileJava'), tasks.named('compileGroovy')
+    inputs.files(provider {
+        [javaMetadata.get().asFile, groovyMetadata.get().asFile].findAll { it.exists() }
+    }).withPropertyName('inputMetadataFiles').withPathSensitivity(PathSensitivity.NONE)
+    outputs.file(mergedMetadata).withPropertyName('mergedMetadata')
+    outputs.cacheIf { true }
 
     doLast {
-        def javaFile = javaMetadataProvider.get().asFile
-        def groovyFile = groovyMetadataProvider.get().asFile
-        if (!javaFile.exists() || !groovyFile.exists()) {
-            return // Only one (or neither) compile step emitted metadata; no conflict to merge
+        def javaFile = javaMetadata.get().asFile
+        def groovyFile = groovyMetadata.get().asFile
+        def outputFile = mergedMetadata.get().asFile
+        outputFile.parentFile.mkdirs()
+
+        def javaExists = javaFile.exists()
+        def groovyExists = groovyFile.exists()
+        if (!javaExists && !groovyExists) {
+            outputFile.text = '{}'
+            return
+        }
+        if (!javaExists) {
+            outputFile.text = groovyFile.text
+            return
+        }
+        if (!groovyExists) {
+            outputFile.text = javaFile.text
+            return
         }
         def slurper = new groovy.json.JsonSlurper()
         def parsedJava = slurper.parse(javaFile)
@@ -92,10 +102,23 @@ def mergeMicronautConfigMetadata = tasks.register('mergeMicronautConfigMetadata'
             if (parsedGroovy[section]) merged.addAll(parsedGroovy[section])
             if (merged) combined[section] = merged
         }
-        groovyFile.text = groovy.json.JsonOutput.toJson(combined)
-        javaFile.delete() // Single canonical file remains under build/classes/groovy/main
+        outputFile.text = groovy.json.JsonOutput.toJson(combined)
     }
 }
 
 tasks.named('classes').configure { finalizedBy mergeMicronautConfigMetadata }
-tasks.named('jar').configure { dependsOn mergeMicronautConfigMetadata }
+tasks.named('jar', Jar).configure {
+    dependsOn mergeMicronautConfigMetadata
+    // The two per-compiler copies in build/classes/{java,groovy}/main are replaced
+    // by the merged document from build/merged-spring-configuration-metadata/.
+    // A global exclude would drop our merged copy too, so filter by absolute source
+    // path - only the merged-dir copy survives.
+    def mergedDirPath = mergedMetadataDir.get().asFile.absolutePath
+    eachFile { FileCopyDetails details ->
+        if (details.path == metadataRelativePath
+                && !details.file.absolutePath.startsWith(mergedDirPath)) {
+            details.exclude()
+        }
+    }
+    from(mergedMetadataDir)
+}