Use ClassLoader with ArchitectureCheck

Prior to this commit, certain rules, like BeanPostProcessor,
did not work with external classes. This commit ensures that
ArchRules are executed within a context ClassLoader that includes
all classes from the compile classpath.

See gh-45202

Signed-off-by: Dmytro Nosan <dimanosan@gmail.com>
This commit is contained in:
Dmytro Nosan
2025-04-15 02:09:35 +03:00
committed by Andy Wilkinson
parent 482f56ddbb
commit bb3cf6c088
7 changed files with 192 additions and 144 deletions

View File

@@ -16,22 +16,20 @@
package org.springframework.boot.build.architecture;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.function.Consumer;
import org.gradle.api.GradleException;
import org.gradle.api.Project;
import org.gradle.testfixtures.ProjectBuilder;
import org.gradle.testkit.runner.GradleRunner;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.core.io.ClassPathResource;
import org.springframework.util.FileSystemUtils;
import org.springframework.util.function.ThrowingConsumer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
/**
* Tests for {@link ArchitectureCheck}.
@@ -39,188 +37,207 @@ import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
* @author Andy Wilkinson
* @author Scott Frederick
* @author Ivan Malutin
* @author Dmytro Nosan
*/
class ArchitectureCheckTests {
@TempDir
File temp;
private Path projectDir;
@Test
void whenPackagesAreTangledTaskFailsAndWritesAReport() throws Exception {
prepareTask("tangled", (architectureCheck) -> {
assertThatExceptionOfType(GradleException.class).isThrownBy(architectureCheck::checkArchitecture);
assertThat(failureReport(architectureCheck)).isNotEmpty();
});
private Path buildFile;
@BeforeEach
void setup(@TempDir Path projectDir) {
this.projectDir = projectDir;
this.buildFile = projectDir.resolve("build.gradle");
}
@Test
void whenPackagesAreNotTangledTaskSucceedsAndWritesAnEmptyReport() throws Exception {
prepareTask("untangled", (architectureCheck) -> {
architectureCheck.checkArchitecture();
assertThat(failureReport(architectureCheck)).isEmpty();
});
}
File failureReport(ArchitectureCheck architectureCheck) {
return architectureCheck.getProject()
.getLayout()
.getBuildDirectory()
.file("checkArchitecture/failure-report.txt")
.get()
.getAsFile();
void whenPackagesAreTangledTaskFailsAndWritesAReport() throws IOException {
runGradleWithCompiledClasses("tangled",
shouldHaveFailureReportWithMessage("slices matching '(**)' should be free of cycles"));
}
@Test
void whenBeanPostProcessorBeanMethodIsNotStaticTaskFailsAndWritesAReport() throws Exception {
prepareTask("bpp/nonstatic", (architectureCheck) -> {
assertThatExceptionOfType(GradleException.class).isThrownBy(architectureCheck::checkArchitecture);
assertThat(failureReport(architectureCheck)).isNotEmpty();
});
void whenPackagesAreNotTangledTaskSucceedsAndWritesAnEmptyReport() throws IOException {
runGradleWithCompiledClasses("untangled", shouldHaveEmptyFailureReport());
}
@Test
void whenBeanPostProcessorBeanMethodIsStaticAndHasUnsafeParametersTaskFailsAndWritesAReport() throws Exception {
prepareTask("bpp/unsafeparameters", (architectureCheck) -> {
assertThatExceptionOfType(GradleException.class).isThrownBy(architectureCheck::checkArchitecture);
assertThat(failureReport(architectureCheck)).isNotEmpty();
});
void whenBeanPostProcessorBeanMethodIsNotStaticTaskFailsAndWritesAReport() throws IOException {
runGradleWithCompiledClasses("bpp/nonstatic",
shouldHaveFailureReportWithMessage(
"methods that are annotated with @Bean and have raw return type assignable "
+ "to org.springframework.beans.factory.config.BeanPostProcessor"));
}
@Test
void whenBeanPostProcessorBeanMethodIsStaticAndHasUnsafeParametersTaskFailsAndWritesAReport() throws IOException {
runGradleWithCompiledClasses("bpp/unsafeparameters",
shouldHaveFailureReportWithMessage(
"methods that are annotated with @Bean and have raw return type assignable "
+ "to org.springframework.beans.factory.config.BeanPostProcessor"));
}
@Test
void whenBeanPostProcessorBeanMethodIsStaticAndHasSafeParametersTaskSucceedsAndWritesAnEmptyReport()
throws Exception {
prepareTask("bpp/safeparameters", (architectureCheck) -> {
architectureCheck.checkArchitecture();
assertThat(failureReport(architectureCheck)).isEmpty();
});
throws IOException {
runGradleWithCompiledClasses("bpp/safeparameters", shouldHaveEmptyFailureReport());
}
@Test
void whenBeanPostProcessorBeanMethodIsStaticAndHasNoParametersTaskSucceedsAndWritesAnEmptyReport()
throws Exception {
prepareTask("bpp/noparameters", (architectureCheck) -> {
architectureCheck.checkArchitecture();
assertThat(failureReport(architectureCheck)).isEmpty();
});
throws IOException {
runGradleWithCompiledClasses("bpp/noparameters", shouldHaveEmptyFailureReport());
}
@Test
void whenBeanFactoryPostProcessorBeanMethodIsNotStaticTaskFailsAndWritesAReport() throws Exception {
prepareTask("bfpp/nonstatic", (architectureCheck) -> {
assertThatExceptionOfType(GradleException.class).isThrownBy(architectureCheck::checkArchitecture);
assertThat(failureReport(architectureCheck)).isNotEmpty();
});
void whenBeanFactoryPostProcessorBeanMethodIsNotStaticTaskFailsAndWritesAReport() throws IOException {
runGradleWithCompiledClasses("bfpp/nonstatic",
shouldHaveFailureReportWithMessage("methods that are annotated with @Bean and have raw return "
+ "type assignable to org.springframework.beans.factory.config.BeanFactoryPostProcessor"));
}
@Test
void whenBeanFactoryPostProcessorBeanMethodIsStaticAndHasParametersTaskFailsAndWritesAReport() throws Exception {
prepareTask("bfpp/parameters", (architectureCheck) -> {
assertThatExceptionOfType(GradleException.class).isThrownBy(architectureCheck::checkArchitecture);
assertThat(failureReport(architectureCheck)).isNotEmpty();
});
void whenBeanFactoryPostProcessorBeanMethodIsStaticAndHasParametersTaskFailsAndWritesAReport() throws IOException {
runGradleWithCompiledClasses("bfpp/parameters",
shouldHaveFailureReportWithMessage("methods that are annotated with @Bean and have raw return "
+ "type assignable to org.springframework.beans.factory.config.BeanFactoryPostProcessor"));
}
@Test
void whenBeanFactoryPostProcessorBeanMethodIsStaticAndHasNoParametersTaskSucceedsAndWritesAnEmptyReport()
throws Exception {
prepareTask("bfpp/noparameters", (architectureCheck) -> {
architectureCheck.checkArchitecture();
assertThat(failureReport(architectureCheck)).isEmpty();
});
throws IOException {
runGradleWithCompiledClasses("bfpp/noparameters", shouldHaveEmptyFailureReport());
}
@Test
void whenClassLoadsResourceUsingResourceUtilsTaskFailsAndWritesReport() throws Exception {
prepareTask("resources/loads", (architectureCheck) -> {
assertThatExceptionOfType(GradleException.class).isThrownBy(architectureCheck::checkArchitecture);
assertThat(failureReport(architectureCheck)).isNotEmpty();
});
void whenClassLoadsResourceUsingResourceUtilsTaskFailsAndWritesReport() throws IOException {
runGradleWithCompiledClasses("resources/loads", shouldHaveFailureReportWithMessage(
"no classes should call method where target owner type org.springframework.util.ResourceUtils and target name 'getURL'"));
}
@Test
void whenClassUsesResourceUtilsWithoutLoadingResourcesTaskSucceedsAndWritesAnEmptyReport() throws Exception {
prepareTask("resources/noloads", (architectureCheck) -> {
architectureCheck.checkArchitecture();
assertThat(failureReport(architectureCheck)).isEmpty();
});
void whenClassUsesResourceUtilsWithoutLoadingResourcesTaskSucceedsAndWritesAnEmptyReport() throws IOException {
runGradleWithCompiledClasses("resources/noloads", shouldHaveEmptyFailureReport());
}
@Test
void whenClassDoesNotCallObjectsRequireNonNullTaskSucceedsAndWritesAnEmptyReport() throws Exception {
prepareTask("objects/noRequireNonNull", (architectureCheck) -> {
architectureCheck.checkArchitecture();
assertThat(failureReport(architectureCheck)).isEmpty();
});
void whenClassDoesNotCallObjectsRequireNonNullTaskSucceedsAndWritesAnEmptyReport() throws IOException {
runGradleWithCompiledClasses("objects/noRequireNonNull", shouldHaveEmptyFailureReport());
}
@Test
void whenClassCallsObjectsRequireNonNullWithMessageTaskFailsAndWritesReport() throws Exception {
prepareTask("objects/requireNonNullWithString", (architectureCheck) -> {
assertThatExceptionOfType(GradleException.class).isThrownBy(architectureCheck::checkArchitecture);
assertThat(failureReport(architectureCheck)).isNotEmpty();
});
void whenClassCallsObjectsRequireNonNullWithMessageTaskFailsAndWritesReport() throws IOException {
runGradleWithCompiledClasses("objects/requireNonNullWithString", shouldHaveFailureReportWithMessage(
"no classes should call method Objects.requireNonNull(Object, String)"));
}
@Test
void whenClassCallsObjectsRequireNonNullWithSupplierTaskFailsAndWritesReport() throws Exception {
prepareTask("objects/requireNonNullWithSupplier", (architectureCheck) -> {
assertThatExceptionOfType(GradleException.class).isThrownBy(architectureCheck::checkArchitecture);
assertThat(failureReport(architectureCheck)).isNotEmpty();
});
void whenClassCallsObjectsRequireNonNullWithSupplierTaskFailsAndWritesReport() throws IOException {
runGradleWithCompiledClasses("objects/requireNonNullWithSupplier", shouldHaveFailureReportWithMessage(
"no classes should call method Objects.requireNonNull(Object, Supplier)"));
}
@Test
void whenClassCallsStringToUpperCaseWithoutLocaleFailsAndWritesReport() throws Exception {
prepareTask("string/toUpperCase", (architectureCheck) -> {
assertThatExceptionOfType(GradleException.class).isThrownBy(architectureCheck::checkArchitecture);
assertThat(failureReport(architectureCheck)).isNotEmpty()
.content()
.contains("because String.toUpperCase(Locale.ROOT) should be used instead");
});
void whenClassCallsStringToUpperCaseWithoutLocaleFailsAndWritesReport() throws IOException {
runGradleWithCompiledClasses("string/toUpperCase",
shouldHaveFailureReportWithMessage("because String.toUpperCase(Locale.ROOT) should be used instead"));
}
@Test
void whenClassCallsStringToLowerCaseWithoutLocaleFailsAndWritesReport() throws Exception {
prepareTask("string/toLowerCase", (architectureCheck) -> {
assertThatExceptionOfType(GradleException.class).isThrownBy(architectureCheck::checkArchitecture);
assertThat(failureReport(architectureCheck)).isNotEmpty()
.content()
.contains("because String.toLowerCase(Locale.ROOT) should be used instead");
});
void whenClassCallsStringToLowerCaseWithoutLocaleFailsAndWritesReport() throws IOException {
runGradleWithCompiledClasses("string/toLowerCase",
shouldHaveFailureReportWithMessage("because String.toLowerCase(Locale.ROOT) should be used instead"));
}
@Test
void whenClassCallsStringToLowerCaseWithLocaleShouldNotFail() throws Exception {
prepareTask("string/toLowerCaseWithLocale", (architectureCheck) -> {
architectureCheck.checkArchitecture();
assertThat(failureReport(architectureCheck)).isEmpty();
});
void whenClassCallsStringToLowerCaseWithLocaleShouldNotFail() throws IOException {
runGradleWithCompiledClasses("string/toLowerCaseWithLocale", shouldHaveEmptyFailureReport());
}
@Test
void whenClassCallsStringToUpperCaseWithLocaleShouldNotFail() throws Exception {
prepareTask("string/toUpperCaseWithLocale", (architectureCheck) -> {
architectureCheck.checkArchitecture();
assertThat(failureReport(architectureCheck)).isEmpty();
});
void whenClassCallsStringToUpperCaseWithLocaleShouldNotFail() throws IOException {
runGradleWithCompiledClasses("string/toUpperCaseWithLocale", shouldHaveEmptyFailureReport());
}
private void prepareTask(String classes, ThrowingConsumer<ArchitectureCheck> callback) throws Exception {
File projectDir = new File(this.temp, "project");
projectDir.mkdirs();
copyClasses(classes, projectDir);
Project project = ProjectBuilder.builder().withProjectDir(projectDir).build();
project.getTasks().register("checkArchitecture", ArchitectureCheck.class, (task) -> {
task.setClasses(project.files("classes"));
callback.accept(task);
});
@Test
void whenBeanPostProcessorBeanMethodIsNotStaticWithExternalClass() throws IOException {
Files.writeString(this.buildFile, """
plugins {
id 'java'
id 'org.springframework.boot.architecture'
}
repositories {
mavenCentral()
}
java {
sourceCompatibility = 17
}
dependencies {
implementation("org.springframework.integration:spring-integration-jmx:6.3.9")
}
""");
Path testClass = this.projectDir.resolve("src/main/java/boot/architecture/bpp/external/TestClass.java");
Files.createDirectories(testClass.getParent());
Files.writeString(testClass, """
package org.springframework.boot.build.architecture.bpp.external;
import org.springframework.context.annotation.Bean;
import org.springframework.integration.monitor.IntegrationMBeanExporter;
public class TestClass {
@Bean
IntegrationMBeanExporter integrationMBeanExporter() {
return new IntegrationMBeanExporter();
}
}
""");
runGradle(shouldHaveFailureReportWithMessage("methods that are annotated with @Bean and have raw return "
+ "type assignable to org.springframework.beans.factory.config.BeanPostProcessor "));
}
private void copyClasses(String name, File projectDir) throws IOException {
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
Resource root = resolver.getResource("classpath:org/springframework/boot/build/architecture/" + name);
FileSystemUtils.copyRecursively(root.getFile(),
new File(projectDir, "classes/org/springframework/boot/build/architecture/" + name));
private Consumer<GradleRunner> shouldHaveEmptyFailureReport() {
return (gradleRunner) -> {
assertThat(gradleRunner.build().getOutput()).contains("BUILD SUCCESSFUL")
.contains("Task :checkArchitectureMain");
assertThat(failureReport()).isEmptyFile();
};
}
private Consumer<GradleRunner> shouldHaveFailureReportWithMessage(String message) {
return (gradleRunner) -> {
assertThat(gradleRunner.buildAndFail().getOutput()).contains("BUILD FAILED")
.contains("Task :checkArchitectureMain FAILED");
assertThat(failureReport()).content().contains(message);
};
}
private void runGradleWithCompiledClasses(String path, Consumer<GradleRunner> callback) throws IOException {
ClassPathResource classPathResource = new ClassPathResource(path, getClass());
FileSystemUtils.copyRecursively(classPathResource.getFile().toPath(),
this.projectDir.resolve("classes").resolve(classPathResource.getPath()));
Files.writeString(this.buildFile, """
plugins {
id 'java'
id 'org.springframework.boot.architecture'
}
sourceSets {
main {
output.classesDirs.setFrom(file("classes"))
}
}
""");
runGradle(callback);
}
private void runGradle(Consumer<GradleRunner> callback) {
callback.accept(GradleRunner.create()
.withProjectDir(this.projectDir.toFile())
// .withArguments("checkArchitectureMain", "-Dorg.gradle.debug=true")
.withArguments("checkArchitectureMain")
.withPluginClasspath());
}
private Path failureReport() {
return this.projectDir.resolve("build/checkArchitectureMain/failure-report.txt");
}
}