diff --git a/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureCheck.java b/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureCheck.java index 6ad9c3d2ee8..bd0cca19265 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureCheck.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureCheck.java @@ -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 and(Provider provider1, Provider provider2) { + return provider1.zip(provider2, (result1, result2) -> result1 && result2); + } + private Provider> whenMainSources(Supplier> 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 isMainSourceSet() { + return getSourceSet().convention(SourceSet.MAIN_SOURCE_SET_NAME).map(SourceSet.MAIN_SOURCE_SET_NAME::equals); } private Transformer, Boolean> whenTrue(Supplier> rules) { @@ -186,4 +195,7 @@ public abstract class ArchitectureCheck extends DefaultTask { @Input // Use descriptions as input since rules aren't serializable abstract ListProperty getRuleDescriptions(); + @Internal + abstract Property getNullMarked(); + } diff --git a/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureCheckExtension.java b/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureCheckExtension.java new file mode 100644 index 00000000000..bc927f609e0 --- /dev/null +++ b/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureCheckExtension.java @@ -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 getNullMarked(); + +} diff --git a/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitecturePlugin.java b/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitecturePlugin.java index cb2cfdb0d4b..390bc19108e 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitecturePlugin.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitecturePlugin.java @@ -39,10 +39,12 @@ public class ArchitecturePlugin implements Plugin { @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> packageTangleChecks = new ArrayList<>(); for (SourceSet sourceSet : javaPluginExtension.getSourceSets()) { @@ -57,6 +59,7 @@ public class ArchitecturePlugin implements Plugin { 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); } diff --git a/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureRules.java b/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureRules.java index f93af1daa34..e45c24ee6ba 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureRules.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureRules.java @@ -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 notSpecifyOnlyATypeThatIsTheSameAsTheMethodReturnType() { return check("not specify only a type that is the same as the method's return type", (item, events) -> { JavaAnnotation conditionalAnnotation = item @@ -471,6 +480,27 @@ final class ArchitectureRules { return string + " should be used instead"; } + static ClassesTransformer packages() { + return new AbstractClassesTransformer<>("packages") { + @Override + public Iterable doTransform(JavaClasses collection) { + return collection.stream().map(JavaClass::getPackage).collect(Collectors.toSet()); + } + }; + } + + private static ArchCondition 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 extends DescribedPredicate { OverridesPublicMethod() { diff --git a/buildSrc/src/test/java/org/springframework/boot/build/architecture/ArchitectureCheckTests.java b/buildSrc/src/test/java/org/springframework/boot/build/architecture/ArchitectureCheckTests.java index fc59b48cbc5..05c387e3626 100644 --- a/buildSrc/src/test/java/org/springframework/boot/build/architecture/ArchitectureCheckTests.java +++ b/buildSrc/src/test/java/org/springframework/boot/build/architecture/ArchitectureCheckTests.java @@ -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 shouldHaveEmptyFailureReport() { return (gradleRunner) -> { try { @@ -246,6 +275,9 @@ class ArchitectureCheckTests { output.classesDirs.setFrom(file("classes")) } } + architectureCheck { + nullMarked = false + } """); runGradle(callback); } diff --git a/configuration-metadata/spring-boot-configuration-metadata-changelog-generator/build.gradle b/configuration-metadata/spring-boot-configuration-metadata-changelog-generator/build.gradle index a49aedad924..93ae797ca26 100644 --- a/configuration-metadata/spring-boot-configuration-metadata-changelog-generator/build.gradle +++ b/configuration-metadata/spring-boot-configuration-metadata-changelog-generator/build.gradle @@ -38,6 +38,10 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter") } +architectureCheck { + nullMarked = false +} + def dependenciesOf(String version) { if (version.startsWith("4.")) { return [ diff --git a/configuration-metadata/spring-boot-configuration-metadata/build.gradle b/configuration-metadata/spring-boot-configuration-metadata/build.gradle index edf0fe0b9f8..ac3aba5f755 100644 --- a/configuration-metadata/spring-boot-configuration-metadata/build.gradle +++ b/configuration-metadata/spring-boot-configuration-metadata/build.gradle @@ -28,3 +28,7 @@ dependencies { testImplementation("org.assertj:assertj-core") testImplementation("org.springframework:spring-core") } + +architectureCheck { + nullMarked = false +} diff --git a/configuration-metadata/spring-boot-configuration-processor/build.gradle b/configuration-metadata/spring-boot-configuration-processor/build.gradle index 81b89d13dd0..a71e6abbe6f 100644 --- a/configuration-metadata/spring-boot-configuration-processor/build.gradle +++ b/configuration-metadata/spring-boot-configuration-processor/build.gradle @@ -30,6 +30,10 @@ sourceSets { } } +architectureCheck { + nullMarked = false +} + dependencies { testCompileOnly("com.google.code.findbugs:jsr305:3.0.2") diff --git a/core/spring-boot-autoconfigure-processor/build.gradle b/core/spring-boot-autoconfigure-processor/build.gradle index f8dad2b345e..3d6792d1f03 100644 --- a/core/spring-boot-autoconfigure-processor/build.gradle +++ b/core/spring-boot-autoconfigure-processor/build.gradle @@ -26,3 +26,7 @@ dependencies { testImplementation(enforcedPlatform(project(":platform:spring-boot-dependencies"))) testImplementation(project(":test-support:spring-boot-test-support")) } + +architectureCheck { + nullMarked = false +} diff --git a/documentation/spring-boot-docs/build.gradle b/documentation/spring-boot-docs/build.gradle index b0dab5afc06..7c4e8903ba9 100644 --- a/documentation/spring-boot-docs/build.gradle +++ b/documentation/spring-boot-docs/build.gradle @@ -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)) diff --git a/loader/spring-boot-loader-classic/build.gradle b/loader/spring-boot-loader-classic/build.gradle index be1ee5d00c6..80d93406e03 100644 --- a/loader/spring-boot-loader-classic/build.gradle +++ b/loader/spring-boot-loader-classic/build.gradle @@ -36,3 +36,7 @@ tasks.configureEach { prohibitObjectsRequireNonNull = false } } + +architectureCheck { + nullMarked = false +} diff --git a/loader/spring-boot-loader/build.gradle b/loader/spring-boot-loader/build.gradle index abc6b76bcf1..e67b72d4fc0 100644 --- a/loader/spring-boot-loader/build.gradle +++ b/loader/spring-boot-loader/build.gradle @@ -36,3 +36,7 @@ tasks.configureEach { prohibitObjectsRequireNonNull = false } } + +architectureCheck { + nullMarked = false +} diff --git a/smoke-test/spring-boot-smoke-test-webflux-coroutines/build.gradle b/smoke-test/spring-boot-smoke-test-webflux-coroutines/build.gradle index f5439cd9273..b2ca8f106d7 100644 --- a/smoke-test/spring-boot-smoke-test-webflux-coroutines/build.gradle +++ b/smoke-test/spring-boot-smoke-test-webflux-coroutines/build.gradle @@ -31,3 +31,7 @@ dependencies { testImplementation(project(":starter:spring-boot-starter-test")) testImplementation("io.projectreactor:reactor-test") } + +architectureCheck { + nullMarked = false +} diff --git a/test-support/spring-boot-docker-test-support/build.gradle b/test-support/spring-boot-docker-test-support/build.gradle index 619d93622fb..d3bf2b45046 100644 --- a/test-support/spring-boot-docker-test-support/build.gradle +++ b/test-support/spring-boot-docker-test-support/build.gradle @@ -48,3 +48,7 @@ dependencies { optional("org.testcontainers:redpanda") optional("com.redis:testcontainers-redis") } + +architectureCheck { + nullMarked = false +} diff --git a/test-support/spring-boot-gradle-test-support/build.gradle b/test-support/spring-boot-gradle-test-support/build.gradle index 7865549f022..8c5831091c6 100644 --- a/test-support/spring-boot-gradle-test-support/build.gradle +++ b/test-support/spring-boot-gradle-test-support/build.gradle @@ -30,3 +30,7 @@ dependencies { implementation("org.assertj:assertj-core") implementation("org.springframework:spring-core") } + +architectureCheck { + nullMarked = false +} diff --git a/test-support/spring-boot-test-support/build.gradle b/test-support/spring-boot-test-support/build.gradle index 084adc04e3b..5d4d6019402 100644 --- a/test-support/spring-boot-test-support/build.gradle +++ b/test-support/spring-boot-test-support/build.gradle @@ -61,3 +61,7 @@ dependencies { testRuntimeOnly("org.hibernate.validator:hibernate-validator") } + +architectureCheck { + nullMarked = false +}