Copilot commented on code in PR #16224:
URL: https://github.com/apache/grails-core/pull/16224#discussion_r3862482374
##########
grails-core/src/main/groovy/org/grails/compiler/injection/GlobalGrailsClassInjectorTransformation.groovy:
##########
@@ -194,6 +195,12 @@ class GlobalGrailsClassInjectorTransformation implements
ASTTransformation, Comp
// create or update grails-plugin.xml
generatePluginXml(pluginClassNode, pluginVersion,
transformedClassNames, pluginXmlFile)
}
+
+ // The generated auto-configurations register themselves as they are
created, but a descriptor
+ // that was deleted, or that no longer has a beans closure, creates
nothing and so says nothing
+ // about the entry it used to leave behind. This runs for every source
unit of a Grails
+ // project, which is what makes the entry go when the class it names
does.
+ AutoConfigurationImportsWriter.reconcile(compilationTargetDirectory,
compilationUnit)
Review Comment:
This reconciliation hook only runs when the Groovy compiler visits a project
source unit. An incremental build caused solely by deleting a descriptor can
remove its class outputs without presenting any source unit here, so the stale
imports entry remains; similarly, adding or deleting the hand-authored resource
does not invalidate `compileGroovy`, so the documented opt-in can leave the
generated file absent (or duplicated) until a clean/source rebuild.
Reconciliation and hand-authored-file switching need to be wired to a build
step whose inputs include both the source set and this resource, rather than
relying only on an AST visit.
##########
grails-beans-dsl/src/main/java/org/grails/compiler/beans/GrailsBeansASTTransformation.java:
##########
@@ -324,9 +326,18 @@ private ClassNode createAutoConfigurationSibling(ClassNode
pluginClass, Annotati
sibling.addAnnotations(siblingAnnotations);
pluginClass.getAnnotations().removeAll(siblingAnnotations);
+ // The name is settled here and nowhere else, so this is where it can
be registered.
+ AutoConfigurationImportsWriter.register(siblingName,
targetDirectory(source), source, compilationUnit);
+
return sibling;
}
+ /** The compiler's output directory, which is where generated metadata
belongs. */
+ private static File targetDirectory(SourceUnit source) {
+ CompilerConfiguration configuration = source == null ? null :
source.getConfiguration();
+ return configuration == null ? null :
configuration.getTargetDirectory();
Review Comment:
This bypasses the repository's existing Groovy-Eclipse target resolution.
For an `EclipseSourceUnit`, `CompilerConfiguration.targetDirectory` may be null
or project-relative, which is why
`GlobalGrailsClassInjectorTransformation.resolveCompilationTargetDirectory`
delegates to `GroovyEclipseCompilationHelper` (lines 462-468). Here that makes
`register` silently do nothing or write relative to the compiler process, so
Eclipse builds omit or misplace the imports file. Pass the already-resolved
target into this transformation or provide equivalent Eclipse-aware resolution
without introducing the existing dependency cycle.
##########
grails-beans-dsl/src/main/java/org/grails/compiler/beans/AutoConfigurationImportsWriter.java:
##########
@@ -0,0 +1,263 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.grails.compiler.beans;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.WeakHashMap;
+
+import org.codehaus.groovy.control.SourceUnit;
+import org.codehaus.groovy.control.messages.WarningMessage;
+
+/**
+ * Registers a generated auto-configuration in
+ * {@code
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports}.
+ *
+ * <p>The class a {@code beans} closure compiles to is created during
compilation and is not a source
+ * file anyone can open. Leaving its registration to be written by hand made a
plugin whose beans are
+ * silently never registered the ordinary consequence of not knowing the class
exists - and the name
+ * to write is one only the compiler knows, since it follows from the
descriptor's name and package.
+ * Writing it where the class is created is the only point at which that name
is known for certain.
+ *
+ * <p>A module that keeps the file by hand at {@value
#SOURCE_IMPORTS_LOCATION} keeps it: generating
+ * a second copy would put the same resource at the same path twice, and
folding its entries into a
+ * copy under the build directory would lose them the moment anyone deleted
the file that was, until
+ * then, where they were written down. Such a module is warned when the
generated class is missing
+ * from it and is otherwise left alone, so nothing that builds today builds
differently - deleting
+ * the hand-authored file is what opts in, and is safe once it holds nothing
but what is generated.
+ *
+ * <p>That one conventional location is all a compiler can look in: a source
set's resource
+ * directories are a build-tool notion and are not among the things the
compiler is told, so a module
+ * that relocates them keeps a file this cannot see and gets a second copy
generated, which the build
+ * then reports as two resources at one path. {@code FactoriesFileWriter} reads
+ * {@code META-INF/grails.factories} from the same fixed location for the same
reason. Handling a
+ * relocated one needs the build to say where it is.
+ *
+ * <p>Hand-authored entries have to remain possible: a module may register a
class from another jar,
+ * one annotated with a composed annotation, or one carrying no annotation at
all, the imports file
+ * being the registration and {@code @AutoConfiguration} only supplying
ordering.
+ *
+ * @since 8.0
+ */
+public final class AutoConfigurationImportsWriter {
+
+ public static final String IMPORTS_LOCATION =
+
"META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports";
+
+ static final String SOURCE_IMPORTS_LOCATION = "src/main/resources/" +
IMPORTS_LOCATION;
+
+ /** Set by the Grails Gradle plugin on the compiler's fork options; see
GrailsAppBaseDirProvider. */
+ private static final String BASE_DIR_PROPERTY = "base.dir";
+
+ private static final String COMMENT_START = "#";
+
+ private static final String CLASS_FILE_EXTENSION = ".class";
+
+ /**
+ * What each compilation has registered so far, so an entry survives the
pruning below before the
+ * class file backing it has been written - class generation runs long
after this does, and two
+ * descriptors recompiling together would otherwise prune each other.
Weakly keyed on the
+ * compilation, which is what makes the state per-build rather than
per-JVM in a reused daemon.
+ */
+ private static final Map<Object, Set<String>> REGISTERED_BY_COMPILATION =
+ Collections.synchronizedMap(new WeakHashMap<>());
+
+ private AutoConfigurationImportsWriter() {
+ }
+
+ /**
+ * Adds {@code className} to the generated imports file under {@code
targetDirectory}, together
+ * with anything an earlier source unit of the same compilation registered
there. A module that
+ * keeps the file by hand is warned instead, and its file is left as the
only one.
+ *
+ * @param className the generated auto-configuration's binary name
+ * @param targetDirectory the compilation output directory, or {@code
null} when the compiler did
+ * not supply one - in which case there is nowhere
to write and the class
+ * stays registerable by hand
+ * @return {@code true} when the file was written
+ */
+ static boolean register(String className, File targetDirectory, SourceUnit
source, Object compilation) {
+ if (className == null || className.isEmpty() || targetDirectory ==
null) {
+ return false;
+ }
+
+ File sourceDirectory = findSourceDirectory(targetDirectory);
+ File handAuthored = sourceDirectory == null ? null : new
File(sourceDirectory, SOURCE_IMPORTS_LOCATION);
+ if (handAuthored != null && handAuthored.isFile()) {
+ Set<String> handAuthoredEntries = new TreeSet<>();
+ readEntries(handAuthored, handAuthoredEntries);
+ if (!handAuthoredEntries.contains(className)) {
+ warn(source, className + " is generated from a beans closure
but is not listed in " +
+ SOURCE_IMPORTS_LOCATION + ", so Spring Boot will not
read it. Add it there, or delete " +
+ "that file once it holds nothing that is not generated
and it will be written for you.");
+ }
+ return false;
+ }
+
+ Set<String> registeredHere = registeredBy(compilation);
+ registeredHere.add(className);
+
+ File importsFile = new File(targetDirectory, IMPORTS_LOCATION);
+ Set<String> entries = new TreeSet<>();
+ readEntries(importsFile, entries);
+
+ // A descriptor that was renamed, deleted, or given a different
autoConfigurationName leaves
+ // an entry naming a class that is no longer generated, and Spring
Boot fails to start on an
+ // auto-configuration it cannot load. Anything this compilation
registered is kept regardless:
+ // its class file is written in a later phase than this one runs in.
+ entries.removeIf(entry -> !registeredHere.contains(entry) &&
!isGeneratedHere(targetDirectory, entry));
+
+ Set<String> written = new TreeSet<>(entries);
+ written.addAll(registeredHere);
+ if (written.equals(entries) && importsFile.isFile() &&
entries.contains(className)) {
+ return false;
+ }
+ entries = written;
+
+ return write(importsFile, entries);
+ }
+
+ /**
+ * Writes the entries, or deletes the file when none are left - an empty
imports file is a
+ * resource that says nothing, and leaving one behind is the sort of thing
that shows up in a
+ * reproducible-build diff.
+ */
+ private static boolean write(File importsFile, Set<String> entries) {
+ try {
+ if (entries.isEmpty()) {
+ return Files.deleteIfExists(importsFile.toPath());
+ }
+ Files.createDirectories(importsFile.toPath().getParent());
+ // Sorted and newline-terminated, so recompiling the same sources
rewrites the same bytes.
+ Files.write(importsFile.toPath(), (String.join("\n", entries) +
"\n")
+ .getBytes(StandardCharsets.UTF_8));
+ return true;
+ }
+ catch (IOException notWritable) {
+ // The class is still generated and still registerable by hand, so
failing compilation
+ // over the convenience of not having to would be the worse trade.
+ return false;
Review Comment:
An `IOException` here is silently discarded, and the caller ignores the
false result, so compilation succeeds while the generated auto-configuration is
not registered—the exact silent failure this writer is intended to prevent.
Report the failure through the `SourceUnit` warning/error collector or fail
compilation instead of returning without diagnostics.
##########
grails-beans-dsl/src/test/groovy/org/grails/compiler/beans/AutoConfigurationImportsWriterSpec.groovy:
##########
@@ -0,0 +1,268 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.grails.compiler.beans
+
+import org.codehaus.groovy.control.CompilationUnit
+import org.codehaus.groovy.control.CompilerConfiguration
+import org.codehaus.groovy.control.Phases
+import spock.lang.Specification
+import spock.lang.TempDir
+
+/**
+ * The class a {@code beans} closure compiles to is generated rather than
written, so nobody can
+ * list it in {@code AutoConfiguration.imports} without first knowing it
exists. These drive a real
+ * compilation with a target directory, which is the only thing that makes the
file observable.
+ */
+class AutoConfigurationImportsWriterSpec extends Specification {
+
+ private static final String IMPORTS =
+
'META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports'
+
+ @TempDir
+ File projectDir
+
+ private File targetDir
+ private String previousBaseDir
+
+ void setup() {
+ targetDir = new File(projectDir, 'build/classes/groovy/main')
+ targetDir.mkdirs()
+ previousBaseDir = System.setProperty('base.dir',
projectDir.absolutePath)
+ }
+
+ void cleanup() {
+ if (previousBaseDir == null) {
+ System.clearProperty('base.dir')
+ }
+ else {
+ System.setProperty('base.dir', previousBaseDir)
+ }
+ }
+
+ void 'the generated sibling registers itself'() {
+ when:
+ compile(plugin('Greeting'))
+
+ then: 'the name only the compiler knows is written where Spring Boot
reads it'
+ importsEntries() == ['com.example.GreetingAutoConfiguration']
+ }
+
+ void 'autoConfigurationName registers under the name it asks for'() {
+ when:
+ compile(plugin('Renamed', "autoConfigurationName =
'LegacyAutoConfiguration'"))
+
+ then: 'what is registered is the name the class is actually generated
under'
+ importsEntries() == ['com.example.LegacyAutoConfiguration']
+ }
+
+ void 'siblings from separate source units accumulate rather than replacing
one another'() {
+ when: 'two descriptors compile separately, as they do in a real build'
+ compile(plugin('First'))
+ compile(plugin('Second'))
+
+ then:
+ importsEntries() == ['com.example.FirstAutoConfiguration',
'com.example.SecondAutoConfiguration']
+ }
+
+ void 'a renamed descriptor does not leave its old entry behind'() {
+ given: 'a descriptor compiles, is renamed, and the stale output is
cleaned - an incremental build'
+ compile(plugin('Greeting'))
+ new File(targetDir,
'com/example/GreetingAutoConfiguration.class').delete()
+ new File(targetDir, 'com/example/GreetingGrailsPlugin.class').delete()
+
+ when:
+ compile(plugin('Farewell'))
+
+ then: 'an entry naming a class that is no longer generated fails
Spring Boot at startup'
+ importsEntries() == ['com.example.FarewellAutoConfiguration']
+ }
+
+ void 'a descriptor left untouched by an incremental build keeps its
entry'() {
+ given: 'two descriptors, then only the one about to be rebuilt is
cleaned, as Gradle does'
+ compile(plugin('Greeting'))
+ compile(plugin('Farewell'))
+ new File(targetDir,
'com/example/FarewellAutoConfiguration.class').delete()
+
+ when:
+ compile(plugin('Farewell'))
+
+ then: 'the one that was not rebuilt is still generated, so it is still
registered'
+ importsEntries() == ['com.example.FarewellAutoConfiguration',
'com.example.GreetingAutoConfiguration']
+ }
+
+ void 'two descriptors recompiling together do not prune one another'() {
+ given: 'both were built before, and both stale outputs are cleaned'
+ compile(plugin('Greeting'))
+ compile(plugin('Farewell'))
+ new File(targetDir,
'com/example/GreetingAutoConfiguration.class').delete()
+ new File(targetDir,
'com/example/FarewellAutoConfiguration.class').delete()
+
+ when: 'they compile as one unit, so neither class file exists while
the other registers'
+ compileTogether([plugin('Greeting'), plugin('Farewell')])
+
+ then:
+ importsEntries() == ['com.example.FarewellAutoConfiguration',
'com.example.GreetingAutoConfiguration']
+ }
+
+ void 'a descriptor deleted with nothing to replace it takes its entry with
it'() {
+ given: 'the descriptor is gone, so nothing generates a sibling and
register is never called'
+ compile(plugin('Greeting'))
+ new File(targetDir,
'com/example/GreetingAutoConfiguration.class').delete()
+ new File(targetDir, 'com/example/GreetingGrailsPlugin.class').delete()
+
+ when: 'the compilation reconciles, which it does whether or not
anything was generated'
+ AutoConfigurationImportsWriter.reconcile(targetDir, null)
+
+ then: 'the file goes with the last entry - an empty imports file is a
resource saying nothing'
+ importsEntries() == []
+ !new File(targetDir, IMPORTS).exists()
+ }
+
+ void 'reconciling leaves an entry whose class is still generated'() {
+ given:
+ compile(plugin('Greeting'))
+
+ when:
+ AutoConfigurationImportsWriter.reconcile(targetDir, null)
+
+ then:
+ importsEntries() == ['com.example.GreetingAutoConfiguration']
+ }
+
+ void 'reconciling does not create a file for a module that generates
nothing'() {
+ when: 'a module with no beans closure anywhere, which is most of them'
+ AutoConfigurationImportsWriter.reconcile(targetDir, null)
+
+ then:
+ !new File(targetDir, IMPORTS).exists()
+ }
+
+ void 'reconciling leaves a hand-authored file alone'() {
+ given: 'no generated file, which is how a module keeping its own looks
from here'
+ File handAuthored = new File(projectDir,
"src/main/resources/${IMPORTS}")
+ handAuthored.parentFile.mkdirs()
+ handAuthored.text = 'com.elsewhere.FromAnotherJar\n'
+
+ when:
+ AutoConfigurationImportsWriter.reconcile(targetDir, null)
+
+ then:
+ handAuthored.readLines() == ['com.elsewhere.FromAnotherJar']
+ !new File(targetDir, IMPORTS).exists()
+ }
+
+ void 'a module that keeps the file by hand keeps it'() {
+ given: 'a hand-authored file, which may hold entries no compilation
can discover'
+ File handAuthored = new File(projectDir,
"src/main/resources/${IMPORTS}")
+ handAuthored.parentFile.mkdirs()
+ handAuthored.text =
'com.example.GreetingAutoConfiguration\ncom.elsewhere.FromAnotherJar\n'
+
+ when:
+ compile(plugin('Greeting'))
+
+ then: 'nothing is generated beside it, which would put the same
resource at the same path twice'
+ !new File(targetDir, IMPORTS).exists()
+
+ and: 'and the entries it alone knows about are untouched'
+ handAuthored.readLines().contains('com.elsewhere.FromAnotherJar')
+ }
+
+ void 'a hand-authored file missing the generated class is warned about'() {
+ given:
+ File handAuthored = new File(projectDir,
"src/main/resources/${IMPORTS}")
+ handAuthored.parentFile.mkdirs()
+ handAuthored.text = 'com.elsewhere.FromAnotherJar\n'
+
+ when:
+ compile(plugin('Greeting'))
+
+ then: 'silently registering nothing is the failure this exists to
prevent'
+ warnings().any {
+ it.contains('com.example.GreetingAutoConfiguration') &&
it.contains(IMPORTS)
+ }
+ }
+
+ private List<String> collectedWarnings = []
+
+ private List<String> warnings() {
+ collectedWarnings
+ }
+
+ private List<String> importsEntries() {
+ File file = new File(targetDir, IMPORTS)
+ file.exists() ? file.readLines().findAll { it.trim() &&
!it.startsWith('#') } : []
+ }
+
+ private static String plugin(String name, String grailsBeansMembers = '') {
+ """
+ package com.example
+
+ import grails.compiler.beans.GrailsBeans
+ import grails.plugins.Plugin
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans(${grailsBeansMembers})
+ @AutoConfiguration
+ class ${name}GrailsPlugin extends Plugin {
+ def beans = {
+ bean('${name.uncapitalize()}Greeting', String) { 'hello' }
+ }
+ }
+ """
+ }
+
+ private CompilerConfiguration compileTogether(List<String> sources) {
+ CompilationUnit unit = newUnit()
+ sources.eachWithIndex { String source, int index ->
unit.addSource("Together${index}.groovy", source) }
+ run(unit)
+ }
+
+ /** A real compilation, since only a target directory makes the generated
file observable. */
+ private CompilerConfiguration compile(String source) {
+ CompilationUnit unit = newUnit()
+ unit.addSource("Source${System.identityHashCode(source)}.groovy",
source)
+ run(unit)
+ }
+
+ private CompilationUnit newUnit() {
+ CompilerConfiguration configuration = new CompilerConfiguration()
+ configuration.targetDirectory = targetDir
+ new CompilationUnit(configuration, null, new
GroovyClassLoader(getClass().classLoader, configuration))
+ }
+
+ /**
+ * Compiled through to OUTPUT, which is the phase that writes the class
files. Stopping earlier
+ * would leave the output directory empty, and what is still generated
there is exactly what
+ * decides whether an entry is kept.
+ */
+ private CompilerConfiguration run(CompilationUnit unit) {
+ try {
+ unit.compile(Phases.OUTPUT)
+ }
+ catch (Exception ignored) {
+ // the generated file is what is under test, not the class
+ }
Review Comment:
Suppressing every compilation exception lets these tests pass after the
metadata is written even when the generated sibling cannot actually compile or
be emitted. That defeats the stated real-compilation coverage and can hide
regressions in the behavior being registered; allow `unit.compile` failures to
fail the spec.
--
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]