From 5464812f80081cf85a6734608f13406cbddcafcc Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Mon, 2 Jun 2025 17:10:33 +0100 Subject: [PATCH] Improve checking of auto-configuration --- .../autoconfigure/AutoConfigurationClass.java | 135 +++++++++++ .../AutoConfigurationImportsTask.java | 66 ++++++ .../AutoConfigurationPlugin.java | 122 +++------- .../CheckAutoConfigurationClasses.java | 212 ++++++++++++++++++ .../CheckAutoConfigurationImports.java | 126 +++++++++++ 5 files changed, 575 insertions(+), 86 deletions(-) create mode 100644 buildSrc/src/main/java/org/springframework/boot/build/autoconfigure/AutoConfigurationClass.java create mode 100644 buildSrc/src/main/java/org/springframework/boot/build/autoconfigure/AutoConfigurationImportsTask.java create mode 100644 buildSrc/src/main/java/org/springframework/boot/build/autoconfigure/CheckAutoConfigurationClasses.java create mode 100644 buildSrc/src/main/java/org/springframework/boot/build/autoconfigure/CheckAutoConfigurationImports.java diff --git a/buildSrc/src/main/java/org/springframework/boot/build/autoconfigure/AutoConfigurationClass.java b/buildSrc/src/main/java/org/springframework/boot/build/autoconfigure/AutoConfigurationClass.java new file mode 100644 index 00000000000..2d3ffff8e8f --- /dev/null +++ b/buildSrc/src/main/java/org/springframework/boot/build/autoconfigure/AutoConfigurationClass.java @@ -0,0 +1,135 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed 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.springframework.boot.build.autoconfigure; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import org.springframework.asm.AnnotationVisitor; +import org.springframework.asm.ClassReader; +import org.springframework.asm.ClassVisitor; +import org.springframework.asm.SpringAsmInfo; +import org.springframework.asm.Type; + +/** + * An {@code @AutoConfiguration} class. + * + * @param name name of the auto-configuration class + * @param before values of the {@code before} attribute + * @param beforeName values of the {@code beforeName} attribute + * @param after values of the {@code after} attribute + * @param afterName values of the {@code afterName} attribute + * @author Andy Wilkinson + */ +public record AutoConfigurationClass(String name, List before, List beforeName, List after, + List afterName) { + + private AutoConfigurationClass(String name, Map> attributes) { + this(name, attributes.getOrDefault("before", Collections.emptyList()), + attributes.getOrDefault("beforeName", Collections.emptyList()), + attributes.getOrDefault("after", Collections.emptyList()), + attributes.getOrDefault("afterName", Collections.emptyList())); + } + + static AutoConfigurationClass of(File classFile) { + try (FileInputStream input = new FileInputStream(classFile)) { + ClassReader classReader = new ClassReader(input); + AutoConfigurationClassVisitor visitor = new AutoConfigurationClassVisitor(); + classReader.accept(visitor, ClassReader.SKIP_DEBUG | ClassReader.SKIP_CODE | ClassReader.SKIP_FRAMES); + return visitor.autoConfigurationClass; + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private static final class AutoConfigurationClassVisitor extends ClassVisitor { + + private AutoConfigurationClass autoConfigurationClass; + + private String name; + + private AutoConfigurationClassVisitor() { + super(SpringAsmInfo.ASM_VERSION); + } + + @Override + public void visit(int version, int access, String name, String signature, String superName, + String[] interfaces) { + this.name = Type.getObjectType(name).getClassName(); + } + + @Override + public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) { + String annotationClassName = Type.getType(descriptor).getClassName(); + if ("org.springframework.boot.autoconfigure.AutoConfiguration".equals(annotationClassName)) { + return new AutoConfigurationAnnotationVisitor(); + } + return null; + } + + private final class AutoConfigurationAnnotationVisitor extends AnnotationVisitor { + + private Map> attributes = new HashMap<>(); + + private static final Set INTERESTING_ATTRIBUTES = Set.of("before", "beforeName", "after", + "afterName"); + + private AutoConfigurationAnnotationVisitor() { + super(SpringAsmInfo.ASM_VERSION); + } + + @Override + public void visitEnd() { + AutoConfigurationClassVisitor.this.autoConfigurationClass = new AutoConfigurationClass( + AutoConfigurationClassVisitor.this.name, this.attributes); + } + + @Override + public AnnotationVisitor visitArray(String attributeName) { + if (INTERESTING_ATTRIBUTES.contains(attributeName)) { + return new AnnotationVisitor(SpringAsmInfo.ASM_VERSION) { + + @Override + public void visit(String name, Object value) { + if (value instanceof Type type) { + value = type.getClassName(); + } + AutoConfigurationAnnotationVisitor.this.attributes + .computeIfAbsent(attributeName, (n) -> new ArrayList<>()) + .add(Objects.toString(value)); + } + + }; + } + return null; + } + + } + + } + +} diff --git a/buildSrc/src/main/java/org/springframework/boot/build/autoconfigure/AutoConfigurationImportsTask.java b/buildSrc/src/main/java/org/springframework/boot/build/autoconfigure/AutoConfigurationImportsTask.java new file mode 100644 index 00000000000..71b16f00bbd --- /dev/null +++ b/buildSrc/src/main/java/org/springframework/boot/build/autoconfigure/AutoConfigurationImportsTask.java @@ -0,0 +1,66 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed 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.springframework.boot.build.autoconfigure; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.util.List; + +import org.gradle.api.DefaultTask; +import org.gradle.api.Task; +import org.gradle.api.file.FileCollection; +import org.gradle.api.file.FileTree; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.PathSensitive; +import org.gradle.api.tasks.PathSensitivity; +import org.gradle.api.tasks.SkipWhenEmpty; + +/** + * A {@link Task} that uses a project's auto-configuration imports. + * + * @author Andy Wilkinson + */ +public abstract class AutoConfigurationImportsTask extends DefaultTask { + + static final String IMPORTS_FILE = "META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports"; + + private FileCollection sourceFiles = getProject().getObjects().fileCollection(); + + @InputFiles + @SkipWhenEmpty + @PathSensitive(PathSensitivity.RELATIVE) + public FileTree getSource() { + return this.sourceFiles.getAsFileTree().matching((filter) -> filter.include(IMPORTS_FILE)); + } + + public void setSource(Object source) { + this.sourceFiles = getProject().getObjects().fileCollection().from(source); + } + + protected List loadImports() { + File importsFile = getSource().getSingleFile(); + try { + return Files.readAllLines(importsFile.toPath()); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + +} diff --git a/buildSrc/src/main/java/org/springframework/boot/build/autoconfigure/AutoConfigurationPlugin.java b/buildSrc/src/main/java/org/springframework/boot/build/autoconfigure/AutoConfigurationPlugin.java index 86a552466b8..76f0af53f11 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/autoconfigure/AutoConfigurationPlugin.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/autoconfigure/AutoConfigurationPlugin.java @@ -16,31 +16,20 @@ package org.springframework.boot.build.autoconfigure; -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.Collections; -import java.util.List; +import java.util.Map; -import com.tngtech.archunit.core.domain.JavaClass; -import com.tngtech.archunit.lang.ArchCondition; -import com.tngtech.archunit.lang.ArchRule; -import com.tngtech.archunit.lang.ConditionEvents; -import com.tngtech.archunit.lang.SimpleConditionEvent; -import com.tngtech.archunit.lang.syntax.ArchRuleDefinition; import org.gradle.api.Plugin; import org.gradle.api.Project; import org.gradle.api.artifacts.Configuration; import org.gradle.api.plugins.JavaPlugin; import org.gradle.api.plugins.JavaPluginExtension; -import org.gradle.api.provider.Provider; -import org.gradle.api.tasks.PathSensitivity; import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.TaskProvider; import org.springframework.boot.build.DeployedPlugin; -import org.springframework.boot.build.architecture.ArchitectureCheck; import org.springframework.boot.build.architecture.ArchitecturePlugin; +import org.springframework.boot.build.optional.OptionalDependenciesPlugin; /** * {@link Plugin} for projects that define auto-configuration. When applied, the plugin @@ -70,14 +59,16 @@ public class AutoConfigurationPlugin implements Plugin { */ public static final String AUTO_CONFIGURATION_METADATA_CONFIGURATION_NAME = "autoConfigurationMetadata"; - private static final String AUTO_CONFIGURATION_IMPORTS_PATH = "META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports"; - @Override public void apply(Project project) { project.getPlugins().apply(DeployedPlugin.class); project.getPlugins().withType(JavaPlugin.class, (javaPlugin) -> { Configuration annotationProcessors = project.getConfigurations() .getByName(JavaPlugin.ANNOTATION_PROCESSOR_CONFIGURATION_NAME); + SourceSet main = project.getExtensions() + .getByType(JavaPluginExtension.class) + .getSourceSets() + .getByName(SourceSet.MAIN_SOURCE_SET_NAME); annotationProcessors.getDependencies() .add(project.getDependencies() .project(Collections.singletonMap("path", @@ -87,10 +78,6 @@ public class AutoConfigurationPlugin implements Plugin { .project(Collections.singletonMap("path", ":spring-boot-project:spring-boot-tools:spring-boot-configuration-processor"))); project.getTasks().register("autoConfigurationMetadata", AutoConfigurationMetadata.class, (task) -> { - SourceSet main = project.getExtensions() - .getByType(JavaPluginExtension.class) - .getSourceSets() - .getByName(SourceSet.MAIN_SOURCE_SET_NAME); task.setSourceSet(main); task.dependsOn(main.getClassesTaskName()); task.getOutputFile() @@ -99,74 +86,37 @@ public class AutoConfigurationPlugin implements Plugin { .add(AutoConfigurationPlugin.AUTO_CONFIGURATION_METADATA_CONFIGURATION_NAME, task.getOutputFile(), (artifact) -> artifact.builtBy(task)); }); + project.getTasks() + .register("checkAutoConfigurationImports", CheckAutoConfigurationImports.class, (task) -> { + task.setSource(main.getResources()); + task.setClasspath(main.getOutput().getClassesDirs()); + task.setDescription("Checks the %s file of the main source set." + .formatted(AutoConfigurationImportsTask.IMPORTS_FILE)); + }); + Configuration requiredClasspath = project.getConfigurations() + .create("autoConfigurationRequiredClasspath") + .extendsFrom(project.getConfigurations().getByName(main.getImplementationConfigurationName()), + project.getConfigurations().getByName(main.getRuntimeOnlyConfigurationName())); + requiredClasspath.getDependencies() + .add(project.getDependencies() + .project(Map.of("path", ":spring-boot-project:spring-boot-autoconfigure"))); + TaskProvider checkAutoConfigurationClasses = project.getTasks() + .register("checkAutoConfigurationClasses", CheckAutoConfigurationClasses.class, (task) -> { + task.setSource(main.getResources()); + task.setClasspath(main.getOutput().getClassesDirs()); + task.setRequiredDependencies(requiredClasspath); + task.setDescription("Checks the auto-configuration classes of the main source set."); + }); project.getPlugins() - .withType(ArchitecturePlugin.class, (plugin) -> configureArchitecturePluginTasks(project)); - }); - } - - private void configureArchitecturePluginTasks(Project project) { - project.getTasks().configureEach((task) -> { - if ("checkArchitectureMain".equals(task.getName()) && task instanceof ArchitectureCheck architectureCheck) { - configureCheckArchitectureMain(project, architectureCheck); - } + .withType(OptionalDependenciesPlugin.class, + (plugin) -> checkAutoConfigurationClasses.configure((check) -> { + Configuration optionalClasspath = project.getConfigurations() + .create("autoConfigurationOptionalClassPath") + .extendsFrom(project.getConfigurations() + .getByName(OptionalDependenciesPlugin.OPTIONAL_CONFIGURATION_NAME)); + check.setOptionalDependencies(optionalClasspath); + })); }); } - private void configureCheckArchitectureMain(Project project, ArchitectureCheck architectureCheck) { - SourceSet main = project.getExtensions() - .getByType(JavaPluginExtension.class) - .getSourceSets() - .getByName(SourceSet.MAIN_SOURCE_SET_NAME); - File resourcesDirectory = main.getOutput().getResourcesDir(); - architectureCheck.dependsOn(main.getProcessResourcesTaskName()); - architectureCheck.getInputs() - .files(resourcesDirectory) - .optional() - .withPathSensitivity(PathSensitivity.RELATIVE); - architectureCheck.getRules() - .add(allClassesAnnotatedWithAutoConfigurationShouldBeListedInAutoConfigurationImports( - autoConfigurationImports(project, resourcesDirectory))); - } - - private ArchRule allClassesAnnotatedWithAutoConfigurationShouldBeListedInAutoConfigurationImports( - Provider imports) { - return ArchRuleDefinition.classes() - .that() - .areAnnotatedWith("org.springframework.boot.autoconfigure.AutoConfiguration") - .should(beListedInAutoConfigurationImports(imports)) - .allowEmptyShould(true); - } - - private ArchCondition beListedInAutoConfigurationImports(Provider imports) { - return new ArchCondition<>("be listed in " + AUTO_CONFIGURATION_IMPORTS_PATH) { - - @Override - public void check(JavaClass item, ConditionEvents events) { - AutoConfigurationImports autoConfigurationImports = imports.get(); - if (!autoConfigurationImports.imports.contains(item.getName())) { - events.add(SimpleConditionEvent.violated(item, - item.getName() + " was not listed in " + autoConfigurationImports.importsFile)); - } - } - - }; - } - - private Provider autoConfigurationImports(Project project, File resourcesDirectory) { - Path importsFile = new File(resourcesDirectory, AUTO_CONFIGURATION_IMPORTS_PATH).toPath(); - return project.provider(() -> { - try { - return new AutoConfigurationImports(project.getProjectDir().toPath().relativize(importsFile), - Files.readAllLines(importsFile)); - } - catch (IOException ex) { - throw new RuntimeException("Failed to read AutoConfiguration.imports", ex); - } - }); - } - - private record AutoConfigurationImports(Path importsFile, List imports) { - - } - } diff --git a/buildSrc/src/main/java/org/springframework/boot/build/autoconfigure/CheckAutoConfigurationClasses.java b/buildSrc/src/main/java/org/springframework/boot/build/autoconfigure/CheckAutoConfigurationClasses.java new file mode 100644 index 00000000000..d56e536ec2e --- /dev/null +++ b/buildSrc/src/main/java/org/springframework/boot/build/autoconfigure/CheckAutoConfigurationClasses.java @@ -0,0 +1,212 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed 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.springframework.boot.build.autoconfigure; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.stream.Stream; + +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.file.FileCollection; +import org.gradle.api.provider.SetProperty; +import org.gradle.api.tasks.Classpath; +import org.gradle.api.tasks.OutputDirectory; +import org.gradle.api.tasks.TaskAction; +import org.gradle.api.tasks.VerificationException; +import org.gradle.language.base.plugins.LifecycleBasePlugin; + +/** + * Task to check a project's {@code @AutoConfiguration} classes. + * + * @author Andy Wilkinson + */ +public abstract class CheckAutoConfigurationClasses extends AutoConfigurationImportsTask { + + private FileCollection classpath = getProject().getObjects().fileCollection(); + + private FileCollection optionalDependencies = getProject().getObjects().fileCollection(); + + private FileCollection requiredDependencies = getProject().getObjects().fileCollection(); + + private SetProperty optionalDependencyClassNames = getProject().getObjects().setProperty(String.class); + + private SetProperty requiredDependencyClassNames = getProject().getObjects().setProperty(String.class); + + public CheckAutoConfigurationClasses() { + getOutputDirectory().convention(getProject().getLayout().getBuildDirectory().dir(getName())); + setGroup(LifecycleBasePlugin.VERIFICATION_GROUP); + this.optionalDependencyClassNames.set(getProject().provider(() -> classNamesOf(this.optionalDependencies))); + this.requiredDependencyClassNames.set(getProject().provider(() -> classNamesOf(this.requiredDependencies))); + } + + private static List classNamesOf(FileCollection classpath) { + return classpath.getFiles().stream().flatMap((file) -> { + try (JarFile jarFile = new JarFile(file)) { + return Collections.list(jarFile.entries()) + .stream() + .filter((entry) -> !entry.isDirectory()) + .map(JarEntry::getName) + .filter((entryName) -> entryName.endsWith(".class")) + .map((entryName) -> entryName.substring(0, entryName.length() - ".class".length())) + .map((entryName) -> entryName.replace("/", ".")); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + }).toList(); + } + + @Classpath + public FileCollection getClasspath() { + return this.classpath; + } + + public void setClasspath(Object classpath) { + this.classpath = getProject().getObjects().fileCollection().from(classpath); + } + + @Classpath + public FileCollection getOptionalDependencies() { + return this.optionalDependencies; + } + + public void setOptionalDependencies(Object classpath) { + this.optionalDependencies = getProject().getObjects().fileCollection().from(classpath); + } + + @Classpath + public FileCollection getRequiredDependencies() { + return this.requiredDependencies; + } + + public void setRequiredDependencies(Object classpath) { + this.requiredDependencies = getProject().getObjects().fileCollection().from(classpath); + } + + @OutputDirectory + public abstract DirectoryProperty getOutputDirectory(); + + @TaskAction + void execute() { + Map> problems = new TreeMap<>(); + Set optionalOnlyClassNames = new HashSet<>(this.optionalDependencyClassNames.get()); + Set requiredClassNames = this.requiredDependencyClassNames.get(); + optionalOnlyClassNames.removeAll(requiredClassNames); + classFiles().forEach((classFile) -> { + AutoConfigurationClass autoConfigurationClass = AutoConfigurationClass.of(classFile); + if (autoConfigurationClass != null) { + check(autoConfigurationClass, optionalOnlyClassNames, requiredClassNames, problems); + } + }); + File outputFile = getOutputDirectory().file("failure-report.txt").get().getAsFile(); + writeReport(problems, outputFile); + if (!problems.isEmpty()) { + throw new VerificationException( + "Auto-configuration class check failed. See '%s' for details".formatted(outputFile)); + } + } + + private List classFiles() { + List classFiles = new ArrayList<>(); + for (File root : this.classpath.getFiles()) { + try (Stream files = Files.walk(root.toPath())) { + files.forEach((file) -> { + if (Files.isRegularFile(file) && file.getFileName().toString().endsWith(".class")) { + classFiles.add(file.toFile()); + } + }); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + return classFiles; + } + + private void check(AutoConfigurationClass autoConfigurationClass, Set optionalOnlyClassNames, + Set requiredClassNames, Map> problems) { + if (!autoConfigurationClass.name().endsWith("AutoConfiguration")) { + problems.computeIfAbsent(autoConfigurationClass.name(), (name) -> new ArrayList<>()) + .add("Name of a class annotated with @AutoConfiguration should end with AutoConfiguration"); + } + autoConfigurationClass.before().forEach((before) -> { + if (optionalOnlyClassNames.contains(before)) { + problems.computeIfAbsent(autoConfigurationClass.name(), (name) -> new ArrayList<>()) + .add("before '%s' is from an optional dependency and should be declared in beforeName" + .formatted(before)); + } + }); + autoConfigurationClass.beforeName().forEach((beforeName) -> { + if (!optionalOnlyClassNames.contains(beforeName)) { + String problem = requiredClassNames.contains(beforeName) + ? "beforeName '%s' is from a required dependency and should be declared in before" + .formatted(beforeName) + : "beforeName '%s' not found".formatted(beforeName); + problems.computeIfAbsent(autoConfigurationClass.name(), (name) -> new ArrayList<>()).add(problem); + } + }); + autoConfigurationClass.after().forEach((after) -> { + if (optionalOnlyClassNames.contains(after)) { + problems.computeIfAbsent(autoConfigurationClass.name(), (name) -> new ArrayList<>()) + .add("after '%s' is from an optional dependency and should be declared in afterName" + .formatted(after)); + } + }); + autoConfigurationClass.afterName().forEach((afterName) -> { + if (!optionalOnlyClassNames.contains(afterName)) { + String problem = requiredClassNames.contains(afterName) + ? "afterName '%s' is from a required dependency and should be declared in after" + .formatted(afterName) + : "afterName '%s' not found".formatted(afterName); + problems.computeIfAbsent(autoConfigurationClass.name(), (name) -> new ArrayList<>()).add(problem); + } + }); + } + + private void writeReport(Map> problems, File outputFile) { + outputFile.getParentFile().mkdirs(); + StringBuilder report = new StringBuilder(); + if (!problems.isEmpty()) { + report.append("Found auto-configuration class problems:%n".formatted()); + problems.forEach((className, classProblems) -> { + report.append(" - %s:%n".formatted(className)); + classProblems.forEach((problem) -> report.append(" - %s%n".formatted(problem))); + }); + } + try { + Files.writeString(outputFile.toPath(), report.toString(), StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + +} diff --git a/buildSrc/src/main/java/org/springframework/boot/build/autoconfigure/CheckAutoConfigurationImports.java b/buildSrc/src/main/java/org/springframework/boot/build/autoconfigure/CheckAutoConfigurationImports.java new file mode 100644 index 00000000000..299e6bbdda6 --- /dev/null +++ b/buildSrc/src/main/java/org/springframework/boot/build/autoconfigure/CheckAutoConfigurationImports.java @@ -0,0 +1,126 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed 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.springframework.boot.build.autoconfigure; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.file.FileCollection; +import org.gradle.api.tasks.Classpath; +import org.gradle.api.tasks.OutputDirectory; +import org.gradle.api.tasks.TaskAction; +import org.gradle.api.tasks.VerificationException; +import org.gradle.language.base.plugins.LifecycleBasePlugin; + +/** + * Task to check the contents of a project's + * {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports} + * file. + * + * @author Andy Wilkinson + */ +public abstract class CheckAutoConfigurationImports extends AutoConfigurationImportsTask { + + private FileCollection classpath = getProject().getObjects().fileCollection(); + + public CheckAutoConfigurationImports() { + getOutputDirectory().convention(getProject().getLayout().getBuildDirectory().dir(getName())); + setGroup(LifecycleBasePlugin.VERIFICATION_GROUP); + } + + @Classpath + public FileCollection getClasspath() { + return this.classpath; + } + + public void setClasspath(Object classpath) { + this.classpath = getProject().getObjects().fileCollection().from(classpath); + } + + @OutputDirectory + public abstract DirectoryProperty getOutputDirectory(); + + @TaskAction + void execute() { + File importsFile = getSource().getSingleFile(); + check(importsFile); + } + + private void check(File importsFile) { + List imports = loadImports(); + List problems = new ArrayList<>(); + for (String imported : imports) { + File classFile = find(imported); + if (classFile == null) { + problems.add("'%s' was not found".formatted(imported)); + } + else if (!correctlyAnnotated(classFile)) { + problems.add("'%s' is not annotated with @AutoConfiguration".formatted(imported)); + } + } + List sortedValues = new ArrayList<>(imports); + Collections.sort(sortedValues); + if (!sortedValues.equals(imports)) { + problems.add("Entries should be sorted alphabetically"); + } + File outputFile = getOutputDirectory().file("failure-report.txt").get().getAsFile(); + writeReport(importsFile, problems, outputFile); + if (!problems.isEmpty()) { + throw new VerificationException("%s check failed. See '%s' for details" + .formatted(AutoConfigurationImportsTask.IMPORTS_FILE, outputFile)); + } + } + + private File find(String className) { + for (File root : this.classpath.getFiles()) { + String classFilePath = className.replace(".", "/") + ".class"; + File classFile = new File(root, classFilePath); + if (classFile.isFile()) { + return classFile; + } + } + return null; + } + + private boolean correctlyAnnotated(File classFile) { + return AutoConfigurationClass.of(classFile) != null; + } + + private void writeReport(File importsFile, List problems, File outputFile) { + outputFile.getParentFile().mkdirs(); + StringBuilder report = new StringBuilder(); + if (!problems.isEmpty()) { + report.append("Found problems in '%s':%n".formatted(importsFile)); + problems.forEach((problem) -> report.append(" - %s%n".formatted(problem))); + } + try { + Files.writeString(outputFile.toPath(), report.toString(), StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + +}