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
This commit is contained in:
Moritz Halbritter 2025-08-12 15:19:00 +02:00
parent 1a1ad23d60
commit c211b88594
16 changed files with 167 additions and 5 deletions

View File

@ -67,6 +67,7 @@ import org.gradle.api.tasks.VerificationException;
* @author Ivan Malutin
* @author Phillip Webb
* @author Dmytro Nosan
* @author Moritz Halbritter
*/
public abstract class ArchitectureCheck extends DefaultTask {
@ -79,13 +80,21 @@ public abstract class ArchitectureCheck extends DefaultTask {
getRules().addAll(ArchitectureRules.standard());
getRules().addAll(whenMainSources(
() -> Collections.singletonList(ArchitectureRules.allBeanMethodsShouldReturnNonPrivateType())));
getRules().addAll(and(getNullMarked(), isMainSourceSet()).map(whenTrue(
() -> Collections.singletonList(ArchitectureRules.packagesShouldBeAnnotatedWithNullMarked()))));
getRuleDescriptions().set(getRules().map(this::asDescriptions));
}
private Provider<Boolean> and(Provider<Boolean> provider1, Provider<Boolean> provider2) {
return provider1.zip(provider2, (result1, result2) -> result1 && result2);
}
private Provider<List<ArchRule>> whenMainSources(Supplier<List<ArchRule>> rules) {
return getSourceSet().convention(SourceSet.MAIN_SOURCE_SET_NAME)
.map(SourceSet.MAIN_SOURCE_SET_NAME::equals)
.map(whenTrue(rules));
return isMainSourceSet().map(whenTrue(rules));
}
private Provider<Boolean> isMainSourceSet() {
return getSourceSet().convention(SourceSet.MAIN_SOURCE_SET_NAME).map(SourceSet.MAIN_SOURCE_SET_NAME::equals);
}
private Transformer<List<ArchRule>, Boolean> whenTrue(Supplier<List<ArchRule>> rules) {
@ -186,4 +195,7 @@ public abstract class ArchitectureCheck extends DefaultTask {
@Input // Use descriptions as input since rules aren't serializable
abstract ListProperty<String> getRuleDescriptions();
@Internal
abstract Property<Boolean> getNullMarked();
}

View File

@ -0,0 +1,39 @@
/*
* Copyright 2012-present 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.architecture;
import org.gradle.api.provider.Property;
import org.jspecify.annotations.NullMarked;
/**
* Extension to configure the {@link ArchitecturePlugin}.
*
* @author Moritz Halbritter
*/
public abstract class ArchitectureCheckExtension {
public ArchitectureCheckExtension() {
getNullMarked().convention(true);
}
/**
* Whether this project uses JSpecify's {@link NullMarked} annotations.
* @return whether this project uses JSpecify's @NullMarked annotations
*/
public abstract Property<Boolean> getNullMarked();
}

View File

@ -39,10 +39,12 @@ public class ArchitecturePlugin implements Plugin<Project> {
@Override
public void apply(Project project) {
project.getPlugins().withType(JavaPlugin.class, (javaPlugin) -> registerTasks(project));
ArchitectureCheckExtension extension = project.getExtensions()
.create("architectureCheck", ArchitectureCheckExtension.class);
project.getPlugins().withType(JavaPlugin.class, (javaPlugin) -> registerTasks(project, extension));
}
private void registerTasks(Project project) {
private void registerTasks(Project project, ArchitectureCheckExtension extension) {
JavaPluginExtension javaPluginExtension = project.getExtensions().getByType(JavaPluginExtension.class);
List<TaskProvider<ArchitectureCheck>> packageTangleChecks = new ArrayList<>();
for (SourceSet sourceSet : javaPluginExtension.getSourceSets()) {
@ -57,6 +59,7 @@ public class ArchitecturePlugin implements Plugin<Project> {
task.setDescription("Checks the architecture of the classes of the " + sourceSet.getName()
+ " source set.");
task.setGroup(LifecycleBasePlugin.VERIFICATION_GROUP);
task.getNullMarked().set(extension.getNullMarked());
});
packageTangleChecks.add(checkPackageTangles);
}

View File

@ -35,11 +35,13 @@ import com.tngtech.archunit.core.domain.JavaAnnotation;
import com.tngtech.archunit.core.domain.JavaCall;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaClass.Predicates;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.domain.JavaConstructor;
import com.tngtech.archunit.core.domain.JavaField;
import com.tngtech.archunit.core.domain.JavaMember;
import com.tngtech.archunit.core.domain.JavaMethod;
import com.tngtech.archunit.core.domain.JavaModifier;
import com.tngtech.archunit.core.domain.JavaPackage;
import com.tngtech.archunit.core.domain.JavaParameter;
import com.tngtech.archunit.core.domain.JavaType;
import com.tngtech.archunit.core.domain.properties.CanBeAnnotated;
@ -48,8 +50,10 @@ import com.tngtech.archunit.core.domain.properties.HasName;
import com.tngtech.archunit.core.domain.properties.HasOwner;
import com.tngtech.archunit.core.domain.properties.HasOwner.Predicates.With;
import com.tngtech.archunit.core.domain.properties.HasParameterTypes;
import com.tngtech.archunit.lang.AbstractClassesTransformer;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.lang.ClassesTransformer;
import com.tngtech.archunit.lang.ConditionEvents;
import com.tngtech.archunit.lang.SimpleConditionEvent;
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition;
@ -70,6 +74,7 @@ import org.springframework.util.ResourceUtils;
* @author Ivan Malutin
* @author Phillip Webb
* @author Ngoc Nhan
* @author Moritz Halbritter
*/
final class ArchitectureRules {
@ -244,6 +249,10 @@ final class ArchitectureRules {
.allowEmptyShould(true);
}
static ArchRule packagesShouldBeAnnotatedWithNullMarked() {
return ArchRuleDefinition.all(packages()).should(beAnnotatedWithNullMarked()).allowEmptyShould(true);
}
private static ArchCondition<? super JavaMethod> notSpecifyOnlyATypeThatIsTheSameAsTheMethodReturnType() {
return check("not specify only a type that is the same as the method's return type", (item, events) -> {
JavaAnnotation<JavaMethod> conditionalAnnotation = item
@ -471,6 +480,27 @@ final class ArchitectureRules {
return string + " should be used instead";
}
static ClassesTransformer<JavaPackage> packages() {
return new AbstractClassesTransformer<>("packages") {
@Override
public Iterable<JavaPackage> doTransform(JavaClasses collection) {
return collection.stream().map(JavaClass::getPackage).collect(Collectors.toSet());
}
};
}
private static ArchCondition<JavaPackage> beAnnotatedWithNullMarked() {
return new ArchCondition<>("be annotated with @NullMarked") {
@Override
public void check(JavaPackage item, ConditionEvents events) {
if (!item.isAnnotatedWith("org.jspecify.annotations.NullMarked")) {
String message = String.format("Package %s is not annotated with @NullMarked", item.getName());
events.add(SimpleConditionEvent.violated(item, message));
}
}
};
}
private static class OverridesPublicMethod<T extends JavaMember> extends DescribedPredicate<T> {
OverridesPublicMethod() {

View File

@ -40,6 +40,7 @@ import static org.assertj.core.api.Assertions.assertThat;
* @author Scott Frederick
* @author Ivan Malutin
* @author Dmytro Nosan
* @author Moritz Halbritter
*/
class ArchitectureCheckTests {
@ -193,6 +194,9 @@ class ArchitectureCheckTests {
dependencies {
implementation("org.springframework.integration:spring-integration-jmx:6.3.9")
}
architectureCheck {
nullMarked = false
}
""");
Path testClass = this.projectDir.resolve("src/main/java/boot/architecture/bpp/external/TestClass.java");
Files.createDirectories(testClass.getParent());
@ -211,6 +215,31 @@ class ArchitectureCheckTests {
+ "type assignable to org.springframework.beans.factory.config.BeanPostProcessor "));
}
@Test
void shouldFailIfPackageIsNotAnnotatedWithNullMarked() throws IOException {
Files.writeString(this.buildFile, """
plugins {
id 'java'
id 'org.springframework.boot.architecture'
}
repositories {
mavenCentral()
}
java {
sourceCompatibility = 17
}
""");
Path testClass = this.projectDir.resolve("src/main/java/boot/architecture/nullmarked/external/TestClass.java");
Files.createDirectories(testClass.getParent());
Files.writeString(testClass, """
package org.springframework.boot.build.architecture.nullmarked.external;
public class TestClass {
}
""");
runGradle(shouldHaveFailureReportWithMessages(
"Package org.springframework.boot.build.architecture.nullmarked.external is not annotated with @NullMarked"));
}
private Consumer<GradleRunner> shouldHaveEmptyFailureReport() {
return (gradleRunner) -> {
try {
@ -246,6 +275,9 @@ class ArchitectureCheckTests {
output.classesDirs.setFrom(file("classes"))
}
}
architectureCheck {
nullMarked = false
}
""");
runGradle(callback);
}

View File

@ -38,6 +38,10 @@ dependencies {
testImplementation("org.junit.jupiter:junit-jupiter")
}
architectureCheck {
nullMarked = false
}
def dependenciesOf(String version) {
if (version.startsWith("4.")) {
return [

View File

@ -28,3 +28,7 @@ dependencies {
testImplementation("org.assertj:assertj-core")
testImplementation("org.springframework:spring-core")
}
architectureCheck {
nullMarked = false
}

View File

@ -30,6 +30,10 @@ sourceSets {
}
}
architectureCheck {
nullMarked = false
}
dependencies {
testCompileOnly("com.google.code.findbugs:jsr305:3.0.2")

View File

@ -26,3 +26,7 @@ dependencies {
testImplementation(enforcedPlatform(project(":platform:spring-boot-dependencies")))
testImplementation(project(":test-support:spring-boot-test-support"))
}
architectureCheck {
nullMarked = false
}

View File

@ -14,7 +14,9 @@
* limitations under the License.
*/
import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask
import org.springframework.boot.build.docs.ConfigureJavadocLinks
plugins {
@ -67,6 +69,10 @@ tasks.named('compileKotlin', KotlinCompilationTask.class) {
javaSources.from = []
}
architectureCheck {
nullMarked = false
}
plugins.withType(EclipsePlugin) {
eclipse.classpath { classpath ->
classpath.plusConfigurations.add(configurations.getByName(sourceSets.main.runtimeClasspathConfigurationName))

View File

@ -36,3 +36,7 @@ tasks.configureEach {
prohibitObjectsRequireNonNull = false
}
}
architectureCheck {
nullMarked = false
}

View File

@ -36,3 +36,7 @@ tasks.configureEach {
prohibitObjectsRequireNonNull = false
}
}
architectureCheck {
nullMarked = false
}

View File

@ -31,3 +31,7 @@ dependencies {
testImplementation(project(":starter:spring-boot-starter-test"))
testImplementation("io.projectreactor:reactor-test")
}
architectureCheck {
nullMarked = false
}

View File

@ -48,3 +48,7 @@ dependencies {
optional("org.testcontainers:redpanda")
optional("com.redis:testcontainers-redis")
}
architectureCheck {
nullMarked = false
}

View File

@ -30,3 +30,7 @@ dependencies {
implementation("org.assertj:assertj-core")
implementation("org.springframework:spring-core")
}
architectureCheck {
nullMarked = false
}

View File

@ -61,3 +61,7 @@ dependencies {
testRuntimeOnly("org.hibernate.validator:hibernate-validator")
}
architectureCheck {
nullMarked = false
}