Skip to content

Commit c211b88

Browse files
committed
Check for @NullMarked on packages
Projects which don't have JSpecify nullability annotations can opt out by using architectureCheck { nullMarked = false } in their build.gradle script. See gh-46587
1 parent 1a1ad23 commit c211b88

File tree

16 files changed

+167
-5
lines changed

16 files changed

+167
-5
lines changed

buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureCheck.java

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
* @author Ivan Malutin
6868
* @author Phillip Webb
6969
* @author Dmytro Nosan
70+
* @author Moritz Halbritter
7071
*/
7172
public abstract class ArchitectureCheck extends DefaultTask {
7273

@@ -79,13 +80,21 @@ public ArchitectureCheck() {
7980
getRules().addAll(ArchitectureRules.standard());
8081
getRules().addAll(whenMainSources(
8182
() -> Collections.singletonList(ArchitectureRules.allBeanMethodsShouldReturnNonPrivateType())));
83+
getRules().addAll(and(getNullMarked(), isMainSourceSet()).map(whenTrue(
84+
() -> Collections.singletonList(ArchitectureRules.packagesShouldBeAnnotatedWithNullMarked()))));
8285
getRuleDescriptions().set(getRules().map(this::asDescriptions));
8386
}
8487

88+
private Provider<Boolean> and(Provider<Boolean> provider1, Provider<Boolean> provider2) {
89+
return provider1.zip(provider2, (result1, result2) -> result1 && result2);
90+
}
91+
8592
private Provider<List<ArchRule>> whenMainSources(Supplier<List<ArchRule>> rules) {
86-
return getSourceSet().convention(SourceSet.MAIN_SOURCE_SET_NAME)
87-
.map(SourceSet.MAIN_SOURCE_SET_NAME::equals)
88-
.map(whenTrue(rules));
93+
return isMainSourceSet().map(whenTrue(rules));
94+
}
95+
96+
private Provider<Boolean> isMainSourceSet() {
97+
return getSourceSet().convention(SourceSet.MAIN_SOURCE_SET_NAME).map(SourceSet.MAIN_SOURCE_SET_NAME::equals);
8998
}
9099

91100
private Transformer<List<ArchRule>, Boolean> whenTrue(Supplier<List<ArchRule>> rules) {
@@ -186,4 +195,7 @@ final FileTree getInputClasses() {
186195
@Input // Use descriptions as input since rules aren't serializable
187196
abstract ListProperty<String> getRuleDescriptions();
188197

198+
@Internal
199+
abstract Property<Boolean> getNullMarked();
200+
189201
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright 2012-present 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.boot.build.architecture;
18+
19+
import org.gradle.api.provider.Property;
20+
import org.jspecify.annotations.NullMarked;
21+
22+
/**
23+
* Extension to configure the {@link ArchitecturePlugin}.
24+
*
25+
* @author Moritz Halbritter
26+
*/
27+
public abstract class ArchitectureCheckExtension {
28+
29+
public ArchitectureCheckExtension() {
30+
getNullMarked().convention(true);
31+
}
32+
33+
/**
34+
* Whether this project uses JSpecify's {@link NullMarked} annotations.
35+
* @return whether this project uses JSpecify's @NullMarked annotations
36+
*/
37+
public abstract Property<Boolean> getNullMarked();
38+
39+
}

buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitecturePlugin.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,12 @@ public class ArchitecturePlugin implements Plugin<Project> {
3939

4040
@Override
4141
public void apply(Project project) {
42-
project.getPlugins().withType(JavaPlugin.class, (javaPlugin) -> registerTasks(project));
42+
ArchitectureCheckExtension extension = project.getExtensions()
43+
.create("architectureCheck", ArchitectureCheckExtension.class);
44+
project.getPlugins().withType(JavaPlugin.class, (javaPlugin) -> registerTasks(project, extension));
4345
}
4446

45-
private void registerTasks(Project project) {
47+
private void registerTasks(Project project, ArchitectureCheckExtension extension) {
4648
JavaPluginExtension javaPluginExtension = project.getExtensions().getByType(JavaPluginExtension.class);
4749
List<TaskProvider<ArchitectureCheck>> packageTangleChecks = new ArrayList<>();
4850
for (SourceSet sourceSet : javaPluginExtension.getSourceSets()) {
@@ -57,6 +59,7 @@ private void registerTasks(Project project) {
5759
task.setDescription("Checks the architecture of the classes of the " + sourceSet.getName()
5860
+ " source set.");
5961
task.setGroup(LifecycleBasePlugin.VERIFICATION_GROUP);
62+
task.getNullMarked().set(extension.getNullMarked());
6063
});
6164
packageTangleChecks.add(checkPackageTangles);
6265
}

buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureRules.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,13 @@
3535
import com.tngtech.archunit.core.domain.JavaCall;
3636
import com.tngtech.archunit.core.domain.JavaClass;
3737
import com.tngtech.archunit.core.domain.JavaClass.Predicates;
38+
import com.tngtech.archunit.core.domain.JavaClasses;
3839
import com.tngtech.archunit.core.domain.JavaConstructor;
3940
import com.tngtech.archunit.core.domain.JavaField;
4041
import com.tngtech.archunit.core.domain.JavaMember;
4142
import com.tngtech.archunit.core.domain.JavaMethod;
4243
import com.tngtech.archunit.core.domain.JavaModifier;
44+
import com.tngtech.archunit.core.domain.JavaPackage;
4345
import com.tngtech.archunit.core.domain.JavaParameter;
4446
import com.tngtech.archunit.core.domain.JavaType;
4547
import com.tngtech.archunit.core.domain.properties.CanBeAnnotated;
@@ -48,8 +50,10 @@
4850
import com.tngtech.archunit.core.domain.properties.HasOwner;
4951
import com.tngtech.archunit.core.domain.properties.HasOwner.Predicates.With;
5052
import com.tngtech.archunit.core.domain.properties.HasParameterTypes;
53+
import com.tngtech.archunit.lang.AbstractClassesTransformer;
5154
import com.tngtech.archunit.lang.ArchCondition;
5255
import com.tngtech.archunit.lang.ArchRule;
56+
import com.tngtech.archunit.lang.ClassesTransformer;
5357
import com.tngtech.archunit.lang.ConditionEvents;
5458
import com.tngtech.archunit.lang.SimpleConditionEvent;
5559
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition;
@@ -70,6 +74,7 @@
7074
* @author Ivan Malutin
7175
* @author Phillip Webb
7276
* @author Ngoc Nhan
77+
* @author Moritz Halbritter
7378
*/
7479
final class ArchitectureRules {
7580

@@ -244,6 +249,10 @@ private static ArchRule conditionalOnMissingBeanShouldNotSpecifyOnlyATypeThatIsT
244249
.allowEmptyShould(true);
245250
}
246251

252+
static ArchRule packagesShouldBeAnnotatedWithNullMarked() {
253+
return ArchRuleDefinition.all(packages()).should(beAnnotatedWithNullMarked()).allowEmptyShould(true);
254+
}
255+
247256
private static ArchCondition<? super JavaMethod> notSpecifyOnlyATypeThatIsTheSameAsTheMethodReturnType() {
248257
return check("not specify only a type that is the same as the method's return type", (item, events) -> {
249258
JavaAnnotation<JavaMethod> conditionalAnnotation = item
@@ -471,6 +480,27 @@ private static String shouldUse(String string) {
471480
return string + " should be used instead";
472481
}
473482

483+
static ClassesTransformer<JavaPackage> packages() {
484+
return new AbstractClassesTransformer<>("packages") {
485+
@Override
486+
public Iterable<JavaPackage> doTransform(JavaClasses collection) {
487+
return collection.stream().map(JavaClass::getPackage).collect(Collectors.toSet());
488+
}
489+
};
490+
}
491+
492+
private static ArchCondition<JavaPackage> beAnnotatedWithNullMarked() {
493+
return new ArchCondition<>("be annotated with @NullMarked") {
494+
@Override
495+
public void check(JavaPackage item, ConditionEvents events) {
496+
if (!item.isAnnotatedWith("org.jspecify.annotations.NullMarked")) {
497+
String message = String.format("Package %s is not annotated with @NullMarked", item.getName());
498+
events.add(SimpleConditionEvent.violated(item, message));
499+
}
500+
}
501+
};
502+
}
503+
474504
private static class OverridesPublicMethod<T extends JavaMember> extends DescribedPredicate<T> {
475505

476506
OverridesPublicMethod() {

buildSrc/src/test/java/org/springframework/boot/build/architecture/ArchitectureCheckTests.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
* @author Scott Frederick
4141
* @author Ivan Malutin
4242
* @author Dmytro Nosan
43+
* @author Moritz Halbritter
4344
*/
4445
class ArchitectureCheckTests {
4546

@@ -193,6 +194,9 @@ void whenBeanPostProcessorBeanMethodIsNotStaticWithExternalClass() throws IOExce
193194
dependencies {
194195
implementation("org.springframework.integration:spring-integration-jmx:6.3.9")
195196
}
197+
architectureCheck {
198+
nullMarked = false
199+
}
196200
""");
197201
Path testClass = this.projectDir.resolve("src/main/java/boot/architecture/bpp/external/TestClass.java");
198202
Files.createDirectories(testClass.getParent());
@@ -211,6 +215,31 @@ IntegrationMBeanExporter integrationMBeanExporter() {
211215
+ "type assignable to org.springframework.beans.factory.config.BeanPostProcessor "));
212216
}
213217

218+
@Test
219+
void shouldFailIfPackageIsNotAnnotatedWithNullMarked() throws IOException {
220+
Files.writeString(this.buildFile, """
221+
plugins {
222+
id 'java'
223+
id 'org.springframework.boot.architecture'
224+
}
225+
repositories {
226+
mavenCentral()
227+
}
228+
java {
229+
sourceCompatibility = 17
230+
}
231+
""");
232+
Path testClass = this.projectDir.resolve("src/main/java/boot/architecture/nullmarked/external/TestClass.java");
233+
Files.createDirectories(testClass.getParent());
234+
Files.writeString(testClass, """
235+
package org.springframework.boot.build.architecture.nullmarked.external;
236+
public class TestClass {
237+
}
238+
""");
239+
runGradle(shouldHaveFailureReportWithMessages(
240+
"Package org.springframework.boot.build.architecture.nullmarked.external is not annotated with @NullMarked"));
241+
}
242+
214243
private Consumer<GradleRunner> shouldHaveEmptyFailureReport() {
215244
return (gradleRunner) -> {
216245
try {
@@ -246,6 +275,9 @@ private void runGradleWithCompiledClasses(String path, Consumer<GradleRunner> ca
246275
output.classesDirs.setFrom(file("classes"))
247276
}
248277
}
278+
architectureCheck {
279+
nullMarked = false
280+
}
249281
""");
250282
runGradle(callback);
251283
}

configuration-metadata/spring-boot-configuration-metadata-changelog-generator/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ dependencies {
3838
testImplementation("org.junit.jupiter:junit-jupiter")
3939
}
4040

41+
architectureCheck {
42+
nullMarked = false
43+
}
44+
4145
def dependenciesOf(String version) {
4246
if (version.startsWith("4.")) {
4347
return [

configuration-metadata/spring-boot-configuration-metadata/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,7 @@ dependencies {
2828
testImplementation("org.assertj:assertj-core")
2929
testImplementation("org.springframework:spring-core")
3030
}
31+
32+
architectureCheck {
33+
nullMarked = false
34+
}

configuration-metadata/spring-boot-configuration-processor/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ sourceSets {
3030
}
3131
}
3232

33+
architectureCheck {
34+
nullMarked = false
35+
}
36+
3337
dependencies {
3438
testCompileOnly("com.google.code.findbugs:jsr305:3.0.2")
3539

core/spring-boot-autoconfigure-processor/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,7 @@ dependencies {
2626
testImplementation(enforcedPlatform(project(":platform:spring-boot-dependencies")))
2727
testImplementation(project(":test-support:spring-boot-test-support"))
2828
}
29+
30+
architectureCheck {
31+
nullMarked = false
32+
}

documentation/spring-boot-docs/build.gradle

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
* limitations under the License.
1515
*/
1616

17+
1718
import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask
19+
1820
import org.springframework.boot.build.docs.ConfigureJavadocLinks
1921

2022
plugins {
@@ -67,6 +69,10 @@ tasks.named('compileKotlin', KotlinCompilationTask.class) {
6769
javaSources.from = []
6870
}
6971

72+
architectureCheck {
73+
nullMarked = false
74+
}
75+
7076
plugins.withType(EclipsePlugin) {
7177
eclipse.classpath { classpath ->
7278
classpath.plusConfigurations.add(configurations.getByName(sourceSets.main.runtimeClasspathConfigurationName))

0 commit comments

Comments
 (0)