13 changed files with 688 additions and 159 deletions
@ -0,0 +1,135 @@
@@ -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<String> before, List<String> beforeName, List<String> after, |
||||
List<String> afterName) { |
||||
|
||||
private AutoConfigurationClass(String name, Map<String, List<String>> 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<String, List<String>> attributes = new HashMap<>(); |
||||
|
||||
private static final Set<String> 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; |
||||
} |
||||
|
||||
} |
||||
|
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,66 @@
@@ -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<String> loadImports() { |
||||
File importsFile = getSource().getSingleFile(); |
||||
try { |
||||
return Files.readAllLines(importsFile.toPath()); |
||||
} |
||||
catch (IOException ex) { |
||||
throw new UncheckedIOException(ex); |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,212 @@
@@ -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<String> optionalDependencyClassNames = getProject().getObjects().setProperty(String.class); |
||||
|
||||
private SetProperty<String> 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<String> 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<String, List<String>> problems = new TreeMap<>(); |
||||
Set<String> optionalOnlyClassNames = new HashSet<>(this.optionalDependencyClassNames.get()); |
||||
Set<String> 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<File> classFiles() { |
||||
List<File> classFiles = new ArrayList<>(); |
||||
for (File root : this.classpath.getFiles()) { |
||||
try (Stream<Path> 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<String> optionalOnlyClassNames, |
||||
Set<String> requiredClassNames, Map<String, List<String>> 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<String, List<String>> 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); |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,133 @@
@@ -0,0 +1,133 @@
|
||||
/* |
||||
* 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.ArrayList; |
||||
import java.util.Collections; |
||||
import java.util.List; |
||||
import java.util.stream.Collectors; |
||||
|
||||
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<String> imports = loadImports(); |
||||
List<String> 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<String> sortedValues = new ArrayList<>(imports); |
||||
Collections.sort(sortedValues); |
||||
if (!sortedValues.equals(imports)) { |
||||
File sortedOutputFile = getOutputDirectory().file("sorted-" + importsFile.getName()).get().getAsFile(); |
||||
writeString(sortedOutputFile, |
||||
sortedValues.stream().collect(Collectors.joining(System.lineSeparator())) + System.lineSeparator()); |
||||
problems.add("Entries should be sorted alphabetically (expect content written to " |
||||
+ sortedOutputFile.getAbsolutePath() + ")"); |
||||
} |
||||
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<String> 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))); |
||||
} |
||||
writeString(outputFile, report.toString()); |
||||
} |
||||
|
||||
private void writeString(File file, String content) { |
||||
try { |
||||
Files.writeString(file.toPath(), content); |
||||
} |
||||
catch (IOException ex) { |
||||
throw new UncheckedIOException(ex); |
||||
} |
||||
} |
||||
|
||||
} |
||||
Loading…
Reference in new issue