Add checks for factories files

META-INF/spring.factories and META-INF/spring/aot.factories in the
main source set are now checked. The checks verify that:

- Each class listed in the values exists in the source set's output
- The classes are listed alphabetically
- Nested classes are identified using their binary name

Closes gh-44676
This commit is contained in:
Andy Wilkinson
2025-03-12 11:46:58 +00:00
parent ad9f0a6164
commit 3a6e4e9e58
14 changed files with 304 additions and 23 deletions

View File

@@ -32,6 +32,7 @@ import io.spring.javaformat.gradle.tasks.CheckFormat;
import io.spring.javaformat.gradle.tasks.Format;
import org.gradle.api.JavaVersion;
import org.gradle.api.Project;
import org.gradle.api.Task;
import org.gradle.api.artifacts.Configuration;
import org.gradle.api.artifacts.ConfigurationContainer;
import org.gradle.api.artifacts.Dependency;
@@ -54,6 +55,8 @@ import org.gradle.external.javadoc.CoreJavadocOptions;
import org.springframework.boot.build.architecture.ArchitecturePlugin;
import org.springframework.boot.build.classpath.CheckClasspathForProhibitedDependencies;
import org.springframework.boot.build.optional.OptionalDependenciesPlugin;
import org.springframework.boot.build.springframework.CheckAotFactories;
import org.springframework.boot.build.springframework.CheckSpringFactories;
import org.springframework.boot.build.testing.TestFailuresPlugin;
import org.springframework.boot.build.toolchain.ToolchainPlugin;
import org.springframework.util.StringUtils;
@@ -98,6 +101,19 @@ import org.springframework.util.StringUtils;
* <li>{@code Implementation-Version}
* </ul>
* <li>{@code spring-boot-parent} is used for dependency management</li>
* <li>Additional checks are configured:
* <ul>
* <li>For all source sets:
* <ul>
* <li>Prohibited dependencies on the compile classpath
* <li>Prohibited dependencies on the runtime classpath
* </ul>
* <li>For the {@code main} source set:
* <ul>
* <li>{@code META-INF/spring/aot.factories}
* <li>{@code META-INF/spring.factories}
* </ul>
* </ul>
* </ul>
*
* <p/>
@@ -123,6 +139,7 @@ class JavaConventions {
configureDependencyManagement(project);
configureToolchain(project);
configureProhibitedDependencyChecks(project);
configureFactoriesFilesChecks(project);
});
}
@@ -304,4 +321,26 @@ class JavaConventions {
project.getTasks().getByName(JavaBasePlugin.CHECK_TASK_NAME).dependsOn(checkClasspathForProhibitedDependencies);
}
private void configureFactoriesFilesChecks(Project project) {
SourceSetContainer sourceSets = project.getExtensions().getByType(JavaPluginExtension.class).getSourceSets();
sourceSets.matching((sourceSet) -> SourceSet.MAIN_SOURCE_SET_NAME.equals(sourceSet.getName()))
.configureEach((main) -> {
TaskProvider<Task> check = project.getTasks().named(JavaBasePlugin.CHECK_TASK_NAME);
TaskProvider<CheckAotFactories> checkAotFactories = project.getTasks()
.register("checkAotFactories", CheckAotFactories.class, (task) -> {
task.setSource(main.getResources());
task.setClasspath(main.getOutput().getClassesDirs());
task.setDescription("Checks the META-INF/spring/aot.factories file of the main source set.");
});
check.configure((task) -> task.dependsOn(checkAotFactories));
TaskProvider<CheckSpringFactories> checkSpringFactories = project.getTasks()
.register("checkSpringFactories", CheckSpringFactories.class, (task) -> {
task.setSource(main.getResources());
task.setClasspath(main.getOutput().getClassesDirs());
task.setDescription("Checks the META-INF/spring.factories file of the main source set.");
});
check.configure((task) -> task.dependsOn(checkSpringFactories));
});
}
}

View File

@@ -0,0 +1,32 @@
/*
* 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.springframework;
import org.gradle.api.Task;
/**
* {@link Task} that checks {@code META-INF/spring/aot.factories}.
*
* @author Andy Wilkinson
*/
public abstract class CheckAotFactories extends CheckFactoriesFile {
public CheckAotFactories() {
super("META-INF/spring/aot.factories");
}
}

View File

@@ -0,0 +1,178 @@
/*
* 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.springframework;
import java.io.File;
import java.io.FileInputStream;
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.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import org.gradle.api.DefaultTask;
import org.gradle.api.GradleException;
import org.gradle.api.Task;
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.file.FileCollection;
import org.gradle.api.file.FileTree;
import org.gradle.api.tasks.Classpath;
import org.gradle.api.tasks.InputFiles;
import org.gradle.api.tasks.OutputDirectory;
import org.gradle.api.tasks.PathSensitive;
import org.gradle.api.tasks.PathSensitivity;
import org.gradle.api.tasks.SkipWhenEmpty;
import org.gradle.api.tasks.TaskAction;
import org.gradle.language.base.plugins.LifecycleBasePlugin;
import org.springframework.core.io.support.SpringFactoriesLoader;
import org.springframework.util.StringUtils;
/**
* {@link Task} that checks files loaded by {@link SpringFactoriesLoader}.
*
* @author Andy Wilkinson
*/
public abstract class CheckFactoriesFile extends DefaultTask {
private final String path;
private FileCollection sourceFiles = getProject().getObjects().fileCollection();
private FileCollection classpath = getProject().getObjects().fileCollection();
protected CheckFactoriesFile(String path) {
this.path = path;
getOutputDirectory().convention(getProject().getLayout().getBuildDirectory().dir(getName()));
setGroup(LifecycleBasePlugin.VERIFICATION_GROUP);
}
@InputFiles
@SkipWhenEmpty
@PathSensitive(PathSensitivity.RELATIVE)
public FileTree getSource() {
return this.sourceFiles.getAsFileTree().matching((filter) -> filter.include(this.path));
}
public void setSource(Object source) {
this.sourceFiles = getProject().getObjects().fileCollection().from(source);
}
@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() {
getSource().forEach(this::check);
}
private void check(File factoriesFile) {
Properties factories = load(factoriesFile);
Map<String, List<String>> problems = new LinkedHashMap<>();
for (String key : factories.stringPropertyNames()) {
List<String> values = Arrays
.asList(StringUtils.commaDelimitedListToStringArray(factories.getProperty(key)));
for (String value : values) {
boolean found = find(value);
if (!found) {
List<String> problemsForKey = problems.computeIfAbsent(key, (k) -> new ArrayList<>());
String binaryName = binaryNameOf(value);
found = find(binaryName);
if (found) {
problemsForKey
.add("'%s' should be listed using its binary name '%s'".formatted(value, binaryName));
}
else {
problemsForKey.add("'%s' was not found".formatted(value));
}
}
}
List<String> sortedValues = new ArrayList<>(values);
Collections.sort(sortedValues);
if (!sortedValues.equals(values)) {
List<String> problemsForKey = problems.computeIfAbsent(key, (k) -> new ArrayList<>());
problemsForKey.add("Entries should be sorted alphabetically");
}
}
File outputFile = getOutputDirectory().file("failure-report.txt").get().getAsFile();
writeReport(factoriesFile, problems, outputFile);
if (!problems.isEmpty()) {
throw new GradleException("%s check failed. See '%s' for details".formatted(this.path, outputFile));
}
}
private boolean find(String className) {
for (File root : this.classpath.getFiles()) {
String classFilePath = className.replace(".", "/") + ".class";
if (new File(root, classFilePath).isFile()) {
return true;
}
}
return false;
}
private String binaryNameOf(String className) {
int lastDotIndex = className.lastIndexOf('.');
return className.substring(0, lastDotIndex) + "$" + className.substring(lastDotIndex + 1);
}
private Properties load(File aotFactories) {
Properties properties = new Properties();
try (FileInputStream input = new FileInputStream(aotFactories)) {
properties.load(input);
return properties;
}
catch (IOException ex) {
throw new UncheckedIOException(ex);
}
}
private void writeReport(File factoriesFile, Map<String, List<String>> problems, File outputFile) {
outputFile.getParentFile().mkdirs();
StringBuilder report = new StringBuilder();
if (!problems.isEmpty()) {
report.append("Found problems in '%s':%n".formatted(factoriesFile));
problems.forEach((key, problemsForKey) -> {
report.append(" - %s:%n".formatted(key));
problemsForKey.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);
}
}
}

View File

@@ -0,0 +1,32 @@
/*
* 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.springframework;
import org.gradle.api.Task;
/**
* {@link Task} that checks {@code META-INF/spring.factories}.
*
* @author Andy Wilkinson
*/
public abstract class CheckSpringFactories extends CheckFactoriesFile {
public CheckSpringFactories() {
super("META-INF/spring.factories");
}
}