diff --git a/buildSrc/README.md b/buildSrc/README.md index 260ebcf24e..a9fcab3705 100644 --- a/buildSrc/README.md +++ b/buildSrc/README.md @@ -9,7 +9,8 @@ The `org.springframework.build.conventions` plugin applies all conventions to th * Configuring the Java compiler, see `JavaConventions` * Configuring the Kotlin compiler, see `KotlinConventions` -* Configuring testing in the build with `TestConventions` +* Configuring testing in the build with `TestConventions` +* Configuring the ArchUnit rules for the project, see `org.springframework.build.architecture.ArchitectureRules` This plugin also provides a DSL extension to optionally enable Java preview features for compiling and testing sources in a module. This can be applied with the following in a diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index 5cba7cf86f..dadcb07c4c 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -20,6 +20,7 @@ ext { dependencies { checkstyle "io.spring.javaformat:spring-javaformat-checkstyle:${javaFormatVersion}" implementation "org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}" + implementation "com.tngtech.archunit:archunit:1.3.0" implementation "org.gradle:test-retry-gradle-plugin:1.5.6" implementation "io.spring.javaformat:spring-javaformat-gradle-plugin:${javaFormatVersion}" implementation "io.spring.nohttp:nohttp-gradle:0.0.11" @@ -27,6 +28,10 @@ dependencies { gradlePlugin { plugins { + architecturePlugin { + id = "org.springframework.architecture" + implementationClass = "org.springframework.build.architecture.ArchitecturePlugin" + } conventionsPlugin { id = "org.springframework.build.conventions" implementationClass = "org.springframework.build.ConventionsPlugin" diff --git a/buildSrc/src/main/java/org/springframework/build/ConventionsPlugin.java b/buildSrc/src/main/java/org/springframework/build/ConventionsPlugin.java index 3e63c16ed4..1cd9e43cf0 100644 --- a/buildSrc/src/main/java/org/springframework/build/ConventionsPlugin.java +++ b/buildSrc/src/main/java/org/springframework/build/ConventionsPlugin.java @@ -21,12 +21,15 @@ import org.gradle.api.Project; import org.gradle.api.plugins.JavaBasePlugin; import org.jetbrains.kotlin.gradle.plugin.KotlinBasePlugin; +import org.springframework.build.architecture.ArchitecturePlugin; + /** * Plugin to apply conventions to projects that are part of Spring Framework's build. * Conventions are applied in response to various plugins being applied. * *

When the {@link JavaBasePlugin} is applied, the conventions in {@link CheckstyleConventions}, * {@link TestConventions} and {@link JavaConventions} are applied. + * The {@link ArchitecturePlugin} plugin is also applied. * When the {@link KotlinBasePlugin} is applied, the conventions in {@link KotlinConventions} * are applied. * @@ -37,6 +40,7 @@ public class ConventionsPlugin implements Plugin { @Override public void apply(Project project) { project.getExtensions().create("springFramework", SpringFrameworkExtension.class); + new ArchitecturePlugin().apply(project); new CheckstyleConventions().apply(project); new JavaConventions().apply(project); new KotlinConventions().apply(project); diff --git a/buildSrc/src/main/java/org/springframework/build/architecture/ArchitectureCheck.java b/buildSrc/src/main/java/org/springframework/build/architecture/ArchitectureCheck.java new file mode 100644 index 0000000000..78c2d94e90 --- /dev/null +++ b/buildSrc/src/main/java/org/springframework/build/architecture/ArchitectureCheck.java @@ -0,0 +1,132 @@ +/* + * Copyright 2002-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.build.architecture; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.lang.EvaluationResult; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.StandardOpenOption; +import java.util.List; +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.provider.ListProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.IgnoreEmptyDirectories; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.InputFiles; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.Optional; +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 static org.springframework.build.architecture.ArchitectureRules.*; + +/** + * {@link Task} that checks for architecture problems. + * + * @author Andy Wilkinson + * @author Scott Frederick + */ +public abstract class ArchitectureCheck extends DefaultTask { + + private FileCollection classes; + + public ArchitectureCheck() { + getOutputDirectory().convention(getProject().getLayout().getBuildDirectory().dir(getName())); + getProhibitObjectsRequireNonNull().convention(true); + getRules().addAll(packageInfoShouldBeNullMarked(), + classesShouldNotImportForbiddenTypes(), + javaClassesShouldNotImportKotlinAnnotations(), + allPackagesShouldBeFreeOfTangles(), + noClassesShouldCallStringToLowerCaseWithoutLocale(), + noClassesShouldCallStringToUpperCaseWithoutLocale()); + getRuleDescriptions().set(getRules().map((rules) -> rules.stream().map(ArchRule::getDescription).toList())); + } + + @TaskAction + void checkArchitecture() throws IOException { + JavaClasses javaClasses = new ClassFileImporter() + .importPaths(this.classes.getFiles().stream().map(File::toPath).toList()); + List violations = getRules().get() + .stream() + .map((rule) -> rule.evaluate(javaClasses)) + .filter(EvaluationResult::hasViolation) + .toList(); + File outputFile = getOutputDirectory().file("failure-report.txt").get().getAsFile(); + outputFile.getParentFile().mkdirs(); + if (!violations.isEmpty()) { + StringBuilder report = new StringBuilder(); + for (EvaluationResult violation : violations) { + report.append(violation.getFailureReport()); + report.append(String.format("%n")); + } + Files.writeString(outputFile.toPath(), report.toString(), StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING); + throw new GradleException("Architecture check failed. See '" + outputFile + "' for details."); + } + else { + outputFile.createNewFile(); + } + } + + public void setClasses(FileCollection classes) { + this.classes = classes; + } + + @Internal + public FileCollection getClasses() { + return this.classes; + } + + @InputFiles + @SkipWhenEmpty + @IgnoreEmptyDirectories + @PathSensitive(PathSensitivity.RELATIVE) + final FileTree getInputClasses() { + return this.classes.getAsFileTree(); + } + + @Optional + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + public abstract DirectoryProperty getResourcesDirectory(); + + @OutputDirectory + public abstract DirectoryProperty getOutputDirectory(); + + @Internal + public abstract ListProperty getRules(); + + @Internal + public abstract Property getProhibitObjectsRequireNonNull(); + + @Input + // The rules themselves can't be an input as they aren't serializable so we use + // their descriptions instead + abstract ListProperty getRuleDescriptions(); +} diff --git a/buildSrc/src/main/java/org/springframework/build/architecture/ArchitecturePlugin.java b/buildSrc/src/main/java/org/springframework/build/architecture/ArchitecturePlugin.java new file mode 100644 index 0000000000..22fdcef2b2 --- /dev/null +++ b/buildSrc/src/main/java/org/springframework/build/architecture/ArchitecturePlugin.java @@ -0,0 +1,74 @@ +/* + * Copyright 2002-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.build.architecture; + +import java.util.ArrayList; +import java.util.List; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.Task; +import org.gradle.api.plugins.JavaPlugin; +import org.gradle.api.plugins.JavaPluginExtension; +import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.TaskProvider; +import org.gradle.language.base.plugins.LifecycleBasePlugin; + +/** + * {@link Plugin} for verifying a project's architecture. + * + * @author Andy Wilkinson + */ +public class ArchitecturePlugin implements Plugin { + + @Override + public void apply(Project project) { + project.getPlugins().withType(JavaPlugin.class, (javaPlugin) -> registerTasks(project)); + } + + private void registerTasks(Project project) { + JavaPluginExtension javaPluginExtension = project.getExtensions().getByType(JavaPluginExtension.class); + List> architectureChecks = new ArrayList<>(); + for (SourceSet sourceSet : javaPluginExtension.getSourceSets()) { + if (sourceSet.getName().contains("test")) { + // skip test source sets. + continue; + } + TaskProvider checkArchitecture = project.getTasks() + .register(taskName(sourceSet), ArchitectureCheck.class, + (task) -> { + task.setClasses(sourceSet.getOutput().getClassesDirs()); + task.getResourcesDirectory().set(sourceSet.getOutput().getResourcesDir()); + task.dependsOn(sourceSet.getProcessResourcesTaskName()); + task.setDescription("Checks the architecture of the classes of the " + sourceSet.getName() + + " source set."); + task.setGroup(LifecycleBasePlugin.VERIFICATION_GROUP); + }); + architectureChecks.add(checkArchitecture); + } + if (!architectureChecks.isEmpty()) { + TaskProvider checkTask = project.getTasks().named(LifecycleBasePlugin.CHECK_TASK_NAME); + checkTask.configure((check) -> check.dependsOn(architectureChecks)); + } + } + + private static String taskName(SourceSet sourceSet) { + return "checkArchitecture" + + sourceSet.getName().substring(0, 1).toUpperCase() + + sourceSet.getName().substring(1); + } + +} diff --git a/buildSrc/src/main/java/org/springframework/build/architecture/ArchitectureRules.java b/buildSrc/src/main/java/org/springframework/build/architecture/ArchitectureRules.java new file mode 100644 index 0000000000..e03ae4c98f --- /dev/null +++ b/buildSrc/src/main/java/org/springframework/build/architecture/ArchitectureRules.java @@ -0,0 +1,100 @@ +/* + * Copyright 2002-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.build.architecture; + +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.lang.syntax.ArchRuleDefinition; +import com.tngtech.archunit.library.dependencies.SliceAssignment; +import com.tngtech.archunit.library.dependencies.SliceIdentifier; +import com.tngtech.archunit.library.dependencies.SlicesRuleDefinition; +import java.util.List; + +abstract class ArchitectureRules { + + static ArchRule allPackagesShouldBeFreeOfTangles() { + return SlicesRuleDefinition.slices() + .assignedFrom(new SpringSlices()).should().beFreeOfCycles(); + } + + static ArchRule noClassesShouldCallStringToLowerCaseWithoutLocale() { + return ArchRuleDefinition.noClasses() + .should() + .callMethod(String.class, "toLowerCase") + .because("String.toLowerCase(Locale.ROOT) should be used instead"); + } + + static ArchRule noClassesShouldCallStringToUpperCaseWithoutLocale() { + return ArchRuleDefinition.noClasses() + .should() + .callMethod(String.class, "toUpperCase") + .because("String.toUpperCase(Locale.ROOT) should be used instead"); + } + + static ArchRule packageInfoShouldBeNullMarked() { + return ArchRuleDefinition.classes() + .that().haveSimpleName("package-info") + .should().beAnnotatedWith("org.jspecify.annotations.NullMarked") + .allowEmptyShould(true); + } + + static ArchRule classesShouldNotImportForbiddenTypes() { + return ArchRuleDefinition.noClasses() + .should().dependOnClassesThat() + .haveFullyQualifiedName("reactor.core.support.Assert") + .orShould().dependOnClassesThat() + .haveFullyQualifiedName("org.slf4j.LoggerFactory") + .orShould().dependOnClassesThat() + .haveFullyQualifiedName("org.springframework.lang.NonNull") + .orShould().dependOnClassesThat() + .haveFullyQualifiedName("org.springframework.lang.Nullable"); + } + + static ArchRule javaClassesShouldNotImportKotlinAnnotations() { + return ArchRuleDefinition.noClasses() + .that().haveSimpleNameNotEndingWith("Kt") + .and().haveSimpleNameNotEndingWith("Dsl") + .should().dependOnClassesThat() + .resideInAnyPackage("org.jetbrains.annotations..") + .allowEmptyShould(true); + } + + static class SpringSlices implements SliceAssignment { + + private final List ignoredPackages = List.of("org.springframework.asm", + "org.springframework.cglib", + "org.springframework.javapoet", + "org.springframework.objenesis"); + + @Override + public SliceIdentifier getIdentifierOf(JavaClass javaClass) { + + String packageName = javaClass.getPackageName(); + for (String ignoredPackage : ignoredPackages) { + if (packageName.startsWith(ignoredPackage)) { + return SliceIdentifier.ignore(); + } + } + return SliceIdentifier.of("spring framework"); + } + + @Override + public String getDescription() { + return "Spring Framework Slices"; + } + } +} diff --git a/src/checkstyle/checkstyle-suppressions.xml b/src/checkstyle/checkstyle-suppressions.xml index 6162aeca7b..a95eb7e692 100644 --- a/src/checkstyle/checkstyle-suppressions.xml +++ b/src/checkstyle/checkstyle-suppressions.xml @@ -7,12 +7,9 @@ - - - @@ -25,7 +22,6 @@ - @@ -37,16 +33,13 @@ - - - diff --git a/src/checkstyle/checkstyle.xml b/src/checkstyle/checkstyle.xml index c62d96fc05..8a345036fc 100644 --- a/src/checkstyle/checkstyle.xml +++ b/src/checkstyle/checkstyle.xml @@ -20,16 +20,6 @@ - - - - - - - - - - @@ -143,11 +133,6 @@ - - - - - @@ -220,22 +205,6 @@ - - - - - - - - - - - - - -