Skip to content

Commit 1bd9848

Browse files
committed
Add ArchUnit architecture checks
This commit adds a new custom build Plugin, the `ArchitecturePlugin`. This plugin is using ArchUnit to enforce several rules in the main sources of the project: * All "package-info" should be `@NullMarked` * Classes should not import forbidden types (like "reactor.core.support.Assert" * Java Classes should not import "org.jetbrains.annotations.*" annotations * Classes should not call "toLowerCase"/"toUpperCase" without a Locale * There should not be any package tangle Duplicate rules were removed from checkstyle as a result. Note, these checks only consider the "main" source sets, so test fixtures and tests are not considered. Repackaged sources like JavaPoet, CGLib and ASM are also excluded from the analysis. Closes gh-34276
1 parent 1ab3d89 commit 1bd9848

File tree

8 files changed

+317
-39
lines changed

8 files changed

+317
-39
lines changed

buildSrc/README.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ The `org.springframework.build.conventions` plugin applies all conventions to th
99

1010
* Configuring the Java compiler, see `JavaConventions`
1111
* Configuring the Kotlin compiler, see `KotlinConventions`
12-
* Configuring testing in the build with `TestConventions`
12+
* Configuring testing in the build with `TestConventions`
13+
* Configuring the ArchUnit rules for the project, see `org.springframework.build.architecture.ArchitectureRules`
1314

1415
This plugin also provides a DSL extension to optionally enable Java preview features for
1516
compiling and testing sources in a module. This can be applied with the following in a

buildSrc/build.gradle

+5
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,18 @@ ext {
2020
dependencies {
2121
checkstyle "io.spring.javaformat:spring-javaformat-checkstyle:${javaFormatVersion}"
2222
implementation "org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}"
23+
implementation "com.tngtech.archunit:archunit:1.3.0"
2324
implementation "org.gradle:test-retry-gradle-plugin:1.5.6"
2425
implementation "io.spring.javaformat:spring-javaformat-gradle-plugin:${javaFormatVersion}"
2526
implementation "io.spring.nohttp:nohttp-gradle:0.0.11"
2627
}
2728

2829
gradlePlugin {
2930
plugins {
31+
architecturePlugin {
32+
id = "org.springframework.architecture"
33+
implementationClass = "org.springframework.build.architecture.ArchitecturePlugin"
34+
}
3035
conventionsPlugin {
3136
id = "org.springframework.build.conventions"
3237
implementationClass = "org.springframework.build.ConventionsPlugin"

buildSrc/src/main/java/org/springframework/build/ConventionsPlugin.java

+4
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,15 @@
2121
import org.gradle.api.plugins.JavaBasePlugin;
2222
import org.jetbrains.kotlin.gradle.plugin.KotlinBasePlugin;
2323

24+
import org.springframework.build.architecture.ArchitecturePlugin;
25+
2426
/**
2527
* Plugin to apply conventions to projects that are part of Spring Framework's build.
2628
* Conventions are applied in response to various plugins being applied.
2729
*
2830
* <p>When the {@link JavaBasePlugin} is applied, the conventions in {@link CheckstyleConventions},
2931
* {@link TestConventions} and {@link JavaConventions} are applied.
32+
* The {@link ArchitecturePlugin} plugin is also applied.
3033
* When the {@link KotlinBasePlugin} is applied, the conventions in {@link KotlinConventions}
3134
* are applied.
3235
*
@@ -37,6 +40,7 @@ public class ConventionsPlugin implements Plugin<Project> {
3740
@Override
3841
public void apply(Project project) {
3942
project.getExtensions().create("springFramework", SpringFrameworkExtension.class);
43+
new ArchitecturePlugin().apply(project);
4044
new CheckstyleConventions().apply(project);
4145
new JavaConventions().apply(project);
4246
new KotlinConventions().apply(project);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/*
2+
* Copyright 2002-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.build.architecture;
18+
19+
import com.tngtech.archunit.core.domain.JavaClasses;
20+
import com.tngtech.archunit.core.importer.ClassFileImporter;
21+
import com.tngtech.archunit.lang.ArchRule;
22+
import com.tngtech.archunit.lang.EvaluationResult;
23+
import java.io.File;
24+
import java.io.IOException;
25+
import java.nio.file.Files;
26+
import java.nio.file.StandardOpenOption;
27+
import java.util.List;
28+
import org.gradle.api.DefaultTask;
29+
import org.gradle.api.GradleException;
30+
import org.gradle.api.Task;
31+
import org.gradle.api.file.DirectoryProperty;
32+
import org.gradle.api.file.FileCollection;
33+
import org.gradle.api.file.FileTree;
34+
import org.gradle.api.provider.ListProperty;
35+
import org.gradle.api.provider.Property;
36+
import org.gradle.api.tasks.IgnoreEmptyDirectories;
37+
import org.gradle.api.tasks.Input;
38+
import org.gradle.api.tasks.InputFiles;
39+
import org.gradle.api.tasks.Internal;
40+
import org.gradle.api.tasks.Optional;
41+
import org.gradle.api.tasks.OutputDirectory;
42+
import org.gradle.api.tasks.PathSensitive;
43+
import org.gradle.api.tasks.PathSensitivity;
44+
import org.gradle.api.tasks.SkipWhenEmpty;
45+
import org.gradle.api.tasks.TaskAction;
46+
47+
import static org.springframework.build.architecture.ArchitectureRules.*;
48+
49+
/**
50+
* {@link Task} that checks for architecture problems.
51+
*
52+
* @author Andy Wilkinson
53+
* @author Scott Frederick
54+
*/
55+
public abstract class ArchitectureCheck extends DefaultTask {
56+
57+
private FileCollection classes;
58+
59+
public ArchitectureCheck() {
60+
getOutputDirectory().convention(getProject().getLayout().getBuildDirectory().dir(getName()));
61+
getProhibitObjectsRequireNonNull().convention(true);
62+
getRules().addAll(packageInfoShouldBeNullMarked(),
63+
classesShouldNotImportForbiddenTypes(),
64+
javaClassesShouldNotImportKotlinAnnotations(),
65+
allPackagesShouldBeFreeOfTangles(),
66+
noClassesShouldCallStringToLowerCaseWithoutLocale(),
67+
noClassesShouldCallStringToUpperCaseWithoutLocale());
68+
getRuleDescriptions().set(getRules().map((rules) -> rules.stream().map(ArchRule::getDescription).toList()));
69+
}
70+
71+
@TaskAction
72+
void checkArchitecture() throws IOException {
73+
JavaClasses javaClasses = new ClassFileImporter()
74+
.importPaths(this.classes.getFiles().stream().map(File::toPath).toList());
75+
List<EvaluationResult> violations = getRules().get()
76+
.stream()
77+
.map((rule) -> rule.evaluate(javaClasses))
78+
.filter(EvaluationResult::hasViolation)
79+
.toList();
80+
File outputFile = getOutputDirectory().file("failure-report.txt").get().getAsFile();
81+
outputFile.getParentFile().mkdirs();
82+
if (!violations.isEmpty()) {
83+
StringBuilder report = new StringBuilder();
84+
for (EvaluationResult violation : violations) {
85+
report.append(violation.getFailureReport());
86+
report.append(String.format("%n"));
87+
}
88+
Files.writeString(outputFile.toPath(), report.toString(), StandardOpenOption.CREATE,
89+
StandardOpenOption.TRUNCATE_EXISTING);
90+
throw new GradleException("Architecture check failed. See '" + outputFile + "' for details.");
91+
}
92+
else {
93+
outputFile.createNewFile();
94+
}
95+
}
96+
97+
public void setClasses(FileCollection classes) {
98+
this.classes = classes;
99+
}
100+
101+
@Internal
102+
public FileCollection getClasses() {
103+
return this.classes;
104+
}
105+
106+
@InputFiles
107+
@SkipWhenEmpty
108+
@IgnoreEmptyDirectories
109+
@PathSensitive(PathSensitivity.RELATIVE)
110+
final FileTree getInputClasses() {
111+
return this.classes.getAsFileTree();
112+
}
113+
114+
@Optional
115+
@InputFiles
116+
@PathSensitive(PathSensitivity.RELATIVE)
117+
public abstract DirectoryProperty getResourcesDirectory();
118+
119+
@OutputDirectory
120+
public abstract DirectoryProperty getOutputDirectory();
121+
122+
@Internal
123+
public abstract ListProperty<ArchRule> getRules();
124+
125+
@Internal
126+
public abstract Property<Boolean> getProhibitObjectsRequireNonNull();
127+
128+
@Input
129+
// The rules themselves can't be an input as they aren't serializable so we use
130+
// their descriptions instead
131+
abstract ListProperty<String> getRuleDescriptions();
132+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
* Copyright 2002-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.build.architecture;
18+
19+
import java.util.ArrayList;
20+
import java.util.List;
21+
import org.gradle.api.Plugin;
22+
import org.gradle.api.Project;
23+
import org.gradle.api.Task;
24+
import org.gradle.api.plugins.JavaPlugin;
25+
import org.gradle.api.plugins.JavaPluginExtension;
26+
import org.gradle.api.tasks.SourceSet;
27+
import org.gradle.api.tasks.TaskProvider;
28+
import org.gradle.language.base.plugins.LifecycleBasePlugin;
29+
30+
/**
31+
* {@link Plugin} for verifying a project's architecture.
32+
*
33+
* @author Andy Wilkinson
34+
*/
35+
public class ArchitecturePlugin implements Plugin<Project> {
36+
37+
@Override
38+
public void apply(Project project) {
39+
project.getPlugins().withType(JavaPlugin.class, (javaPlugin) -> registerTasks(project));
40+
}
41+
42+
private void registerTasks(Project project) {
43+
JavaPluginExtension javaPluginExtension = project.getExtensions().getByType(JavaPluginExtension.class);
44+
List<TaskProvider<ArchitectureCheck>> architectureChecks = new ArrayList<>();
45+
for (SourceSet sourceSet : javaPluginExtension.getSourceSets()) {
46+
if (sourceSet.getName().contains("test")) {
47+
// skip test source sets.
48+
continue;
49+
}
50+
TaskProvider<ArchitectureCheck> checkArchitecture = project.getTasks()
51+
.register(taskName(sourceSet), ArchitectureCheck.class,
52+
(task) -> {
53+
task.setClasses(sourceSet.getOutput().getClassesDirs());
54+
task.getResourcesDirectory().set(sourceSet.getOutput().getResourcesDir());
55+
task.dependsOn(sourceSet.getProcessResourcesTaskName());
56+
task.setDescription("Checks the architecture of the classes of the " + sourceSet.getName()
57+
+ " source set.");
58+
task.setGroup(LifecycleBasePlugin.VERIFICATION_GROUP);
59+
});
60+
architectureChecks.add(checkArchitecture);
61+
}
62+
if (!architectureChecks.isEmpty()) {
63+
TaskProvider<Task> checkTask = project.getTasks().named(LifecycleBasePlugin.CHECK_TASK_NAME);
64+
checkTask.configure((check) -> check.dependsOn(architectureChecks));
65+
}
66+
}
67+
68+
private static String taskName(SourceSet sourceSet) {
69+
return "checkArchitecture"
70+
+ sourceSet.getName().substring(0, 1).toUpperCase()
71+
+ sourceSet.getName().substring(1);
72+
}
73+
74+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/*
2+
* Copyright 2002-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.build.architecture;
18+
19+
import com.tngtech.archunit.core.domain.JavaClass;
20+
import com.tngtech.archunit.lang.ArchRule;
21+
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition;
22+
import com.tngtech.archunit.library.dependencies.SliceAssignment;
23+
import com.tngtech.archunit.library.dependencies.SliceIdentifier;
24+
import com.tngtech.archunit.library.dependencies.SlicesRuleDefinition;
25+
import java.util.List;
26+
27+
abstract class ArchitectureRules {
28+
29+
static ArchRule allPackagesShouldBeFreeOfTangles() {
30+
return SlicesRuleDefinition.slices()
31+
.assignedFrom(new SpringSlices()).should().beFreeOfCycles();
32+
}
33+
34+
static ArchRule noClassesShouldCallStringToLowerCaseWithoutLocale() {
35+
return ArchRuleDefinition.noClasses()
36+
.should()
37+
.callMethod(String.class, "toLowerCase")
38+
.because("String.toLowerCase(Locale.ROOT) should be used instead");
39+
}
40+
41+
static ArchRule noClassesShouldCallStringToUpperCaseWithoutLocale() {
42+
return ArchRuleDefinition.noClasses()
43+
.should()
44+
.callMethod(String.class, "toUpperCase")
45+
.because("String.toUpperCase(Locale.ROOT) should be used instead");
46+
}
47+
48+
static ArchRule packageInfoShouldBeNullMarked() {
49+
return ArchRuleDefinition.classes()
50+
.that().haveSimpleName("package-info")
51+
.should().beAnnotatedWith("org.jspecify.annotations.NullMarked")
52+
.allowEmptyShould(true);
53+
}
54+
55+
static ArchRule classesShouldNotImportForbiddenTypes() {
56+
return ArchRuleDefinition.noClasses()
57+
.should().dependOnClassesThat()
58+
.haveFullyQualifiedName("reactor.core.support.Assert")
59+
.orShould().dependOnClassesThat()
60+
.haveFullyQualifiedName("org.slf4j.LoggerFactory")
61+
.orShould().dependOnClassesThat()
62+
.haveFullyQualifiedName("org.springframework.lang.NonNull")
63+
.orShould().dependOnClassesThat()
64+
.haveFullyQualifiedName("org.springframework.lang.Nullable");
65+
}
66+
67+
static ArchRule javaClassesShouldNotImportKotlinAnnotations() {
68+
return ArchRuleDefinition.noClasses()
69+
.that().haveSimpleNameNotEndingWith("Kt")
70+
.and().haveSimpleNameNotEndingWith("Dsl")
71+
.should().dependOnClassesThat()
72+
.resideInAnyPackage("org.jetbrains.annotations..")
73+
.allowEmptyShould(true);
74+
}
75+
76+
static class SpringSlices implements SliceAssignment {
77+
78+
private final List<String> ignoredPackages = List.of("org.springframework.asm",
79+
"org.springframework.cglib",
80+
"org.springframework.javapoet",
81+
"org.springframework.objenesis");
82+
83+
@Override
84+
public SliceIdentifier getIdentifierOf(JavaClass javaClass) {
85+
86+
String packageName = javaClass.getPackageName();
87+
for (String ignoredPackage : ignoredPackages) {
88+
if (packageName.startsWith(ignoredPackage)) {
89+
return SliceIdentifier.ignore();
90+
}
91+
}
92+
return SliceIdentifier.of("spring framework");
93+
}
94+
95+
@Override
96+
public String getDescription() {
97+
return "Spring Framework Slices";
98+
}
99+
}
100+
}

src/checkstyle/checkstyle-suppressions.xml

-7
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,9 @@
77

88
<!-- Global: package-info.java -->
99
<suppress files="(^(?!.+[\\/]src[\\/]main[\\/]java[\\/]).*)|(.*framework-docs.*)" checks="JavadocPackage" />
10-
<suppress files="(^(?!.+[\\/]src[\\/]main[\\/]java[\\/].*package-info\.java))|(.*framework-docs.*)|(.*spring-(context-indexer|instrument|jcl).*)" checks="RegexpSinglelineJava" id="packageLevelNullMarkedAnnotation" />
1110

1211
<!-- Global: tests and test fixtures -->
1312
<suppress files="[\\/]src[\\/](test|testFixtures)[\\/](java|java21)[\\/]" checks="AnnotationLocation|AnnotationUseStyle|AtclauseOrder|AvoidNestedBlocks|FinalClass|HideUtilityClassConstructor|InnerTypeLast|JavadocStyle|JavadocType|JavadocVariable|LeftCurly|MultipleVariableDeclarations|NeedBraces|OneTopLevelClass|OuterTypeFilename|RequireThis|SpringCatch|SpringJavadoc|SpringNoThis"/>
14-
<suppress files="[\\/]src[\\/](test|testFixtures)[\\/](java|java21)[\\/]" checks="RegexpSinglelineJava" id="toLowerCaseWithoutLocale"/>
15-
<suppress files="[\\/]src[\\/](test|testFixtures)[\\/](java|java21)[\\/]" checks="RegexpSinglelineJava" id="toUpperCaseWithoutLocale"/>
1613
<suppress files="[\\/]src[\\/](test|testFixtures)[\\/](java|java21)[\\/]" checks="RegexpSinglelineJava" id="systemOutErrPrint"/>
1714
<suppress files="[\\/]src[\\/](test|testFixtures)[\\/](java|java21)[\\/]org[\\/]springframework[\\/].+(Tests|Suite)" checks="IllegalImport" id="bannedJUnitJupiterImports"/>
1815
<suppress files="[\\/]src[\\/](test|testFixtures)[\\/](java|java21)[\\/]" checks="SpringJUnit5" message="should not be public"/>
@@ -25,7 +22,6 @@
2522

2623
<!-- spring-aop -->
2724
<suppress files="[\\/]src[\\/]main[\\/]java[\\/]org[\\/]aopalliance[\\/]" checks="IllegalImport" id="bannedImports" message="javax"/>
28-
<suppress files="[\\/]src[\\/]main[\\/]java[\\/]org[\\/]aopalliance[\\/]" checks="RegexpSinglelineJava" id="packageLevelNullMarkedAnnotation"/>
2925

3026
<!-- spring-beans -->
3127
<suppress files="TypeMismatchException" checks="MutableException"/>
@@ -37,16 +33,13 @@
3733
<suppress files="RootBeanDefinition" checks="EqualsHashCode"/>
3834

3935
<!-- spring-context -->
40-
<suppress files="[\\/]src[\\/]main[\\/]java[\\/]org[\\/]springframework[\\/]instrument[\\/]" checks="RegexpSinglelineJava" id="packageLevelNullMarkedAnnotation"/>
4136
<suppress files="SpringAtInjectTckTests" checks="IllegalImportCheck" id="bannedJUnit3Imports"/>
4237

4338
<!-- spring-core -->
4439
<suppress files="[\\/]src[\\/]main[\\/]java[\\/]org[\\/]springframework[\\/](asm|cglib|objenesis|javapoet)[\\/]" checks=".*"/>
45-
<suppress files="[\\/]src[\\/]main[\\/]java[\\/]org[\\/]springframework[\\/]aot[\\/]nativex[\\/]" checks="RegexpSinglelineJava" id="packageLevelNullMarkedAnnotation"/>
4640
<suppress files="[\\/]src[\\/]main[\\/]java[\\/]org[\\/]springframework[\\/]aot[\\/]nativex[\\/]feature[\\/]" checks="RegexpSinglelineJava" id="systemOutErrPrint"/>
4741
<suppress files="[\\/]src[\\/]main[\\/]java[\\/]org[\\/]springframework[\\/](core|util)[\\/](SpringProperties|SystemPropertyUtils)" checks="RegexpSinglelineJava" id="systemOutErrPrint"/>
4842
<suppress files="[\\/]src[\\/]main[\\/]java[\\/]org[\\/]springframework[\\/]lang[\\/]" checks="IllegalImport" id="bannedImports" message="javax"/>
49-
<suppress files="[\\/]src[\\/]main[\\/]java[\\/]org[\\/]springframework[\\/]lang[\\/]" checks="RegexpSinglelineJava" id="packageLevelNullMarkedAnnotation" />
5043
<suppress files="[\\/]src[\\/]main[\\/]java[\\/]org[\\/]springframework[\\/]core[\\/]annotation[\\/]" checks="IllegalImport" id="bannedImports" message="javax"/>
5144
<suppress files="[\\/]src[\\/]test[\\/]java[\\/]org[\\/]springframework[\\/]core[\\/]annotation[\\/]" checks="IllegalImport" id="bannedImports" message="javax"/>
5245
<suppress files="ByteArrayEncoder" checks="SpringLambda"/>

0 commit comments

Comments
 (0)