Skip to content

Commit 022e7d7

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 spring-projectsgh-46587
1 parent 1b1437a commit 022e7d7

File tree

15 files changed

+129
-2
lines changed

15 files changed

+129
-2
lines changed

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,14 @@
2525
import java.nio.file.StandardOpenOption;
2626
import java.util.ArrayList;
2727
import java.util.Collections;
28+
import java.util.HashSet;
2829
import java.util.List;
30+
import java.util.Set;
2931
import java.util.concurrent.Callable;
3032
import java.util.function.Supplier;
3133
import java.util.stream.Stream;
3234

35+
import com.tngtech.archunit.core.domain.JavaClass;
3336
import com.tngtech.archunit.core.domain.JavaClasses;
3437
import com.tngtech.archunit.core.importer.ClassFileImporter;
3538
import com.tngtech.archunit.lang.ArchRule;
@@ -100,6 +103,7 @@ private List<String> asDescriptions(List<ArchRule> rules) {
100103
void checkArchitecture() throws Exception {
101104
withCompileClasspath(() -> {
102105
JavaClasses javaClasses = new ClassFileImporter().importPaths(classFilesPaths());
106+
checkNullMarkedAnnotation(javaClasses);
103107
List<EvaluationResult> violations = evaluate(javaClasses).filter(EvaluationResult::hasViolation).toList();
104108
File outputFile = getOutputDirectory().file("failure-report.txt").get().getAsFile();
105109
writeViolationReport(violations, outputFile);
@@ -110,6 +114,30 @@ void checkArchitecture() throws Exception {
110114
});
111115
}
112116

117+
private void checkNullMarkedAnnotation(JavaClasses javaClasses) {
118+
if (!shouldCheckNullMarked()) {
119+
return;
120+
}
121+
Set<String> unmarkedPackages = new HashSet<>();
122+
for (JavaClass javaClass : javaClasses) {
123+
if (!javaClass.getPackage().isAnnotatedWith("org.jspecify.annotations.NullMarked")) {
124+
String packageName = javaClass.getPackage().getName();
125+
unmarkedPackages.add(packageName);
126+
}
127+
}
128+
if (!unmarkedPackages.isEmpty()) {
129+
StringBuilder builder = new StringBuilder("Packages missing @NullMarked found:\n\n");
130+
for (String unmarkedPackage : unmarkedPackages) {
131+
builder.append("- ").append(unmarkedPackage).append("\n");
132+
}
133+
throw new VerificationException(builder.toString());
134+
}
135+
}
136+
137+
private boolean shouldCheckNullMarked() {
138+
return getNullMarked().get();
139+
}
140+
113141
private List<Path> classFilesPaths() {
114142
return this.classes.getFiles().stream().map(File::toPath).toList();
115143
}
@@ -186,4 +214,7 @@ final FileTree getInputClasses() {
186214
@Input // Use descriptions as input since rules aren't serializable
187215
abstract ListProperty<String> getRuleDescriptions();
188216

217+
@Internal
218+
abstract Property<Boolean> getNullMarked();
219+
189220
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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+
/**
30+
* Whether this project uses JSpecify's {@link NullMarked} annotations.
31+
* @return whether this project uses JSpecify's @NullMarked annotations
32+
*/
33+
public abstract Property<Boolean> getNullMarked();
34+
35+
}

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import org.gradle.api.Task;
2525
import org.gradle.api.plugins.JavaPlugin;
2626
import org.gradle.api.plugins.JavaPluginExtension;
27+
import org.gradle.api.provider.Provider;
2728
import org.gradle.api.tasks.SourceSet;
2829
import org.gradle.api.tasks.TaskProvider;
2930
import org.gradle.language.base.plugins.LifecycleBasePlugin;
@@ -39,10 +40,12 @@ public class ArchitecturePlugin implements Plugin<Project> {
3940

4041
@Override
4142
public void apply(Project project) {
42-
project.getPlugins().withType(JavaPlugin.class, (javaPlugin) -> registerTasks(project));
43+
ArchitectureCheckExtension extension = project.getExtensions()
44+
.create("architectureCheck", ArchitectureCheckExtension.class);
45+
project.getPlugins().withType(JavaPlugin.class, (javaPlugin) -> registerTasks(project, extension));
4346
}
4447

45-
private void registerTasks(Project project) {
48+
private void registerTasks(Project project, ArchitectureCheckExtension extension) {
4649
JavaPluginExtension javaPluginExtension = project.getExtensions().getByType(JavaPluginExtension.class);
4750
List<TaskProvider<ArchitectureCheck>> packageTangleChecks = new ArrayList<>();
4851
for (SourceSet sourceSet : javaPluginExtension.getSourceSets()) {
@@ -57,6 +60,7 @@ private void registerTasks(Project project) {
5760
task.setDescription("Checks the architecture of the classes of the " + sourceSet.getName()
5861
+ " source set.");
5962
task.setGroup(LifecycleBasePlugin.VERIFICATION_GROUP);
63+
task.getNullMarked().set(checkNullMarked(sourceSet, extension));
6064
});
6165
packageTangleChecks.add(checkPackageTangles);
6266
}
@@ -66,4 +70,9 @@ private void registerTasks(Project project) {
6670
}
6771
}
6872

73+
private Provider<Boolean> checkNullMarked(SourceSet sourceSet, ArchitectureCheckExtension extension) {
74+
// Default to true only on main source set
75+
return extension.getNullMarked().orElse(sourceSet.getName().equals(SourceSet.MAIN_SOURCE_SET_NAME));
76+
}
77+
6978
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,9 @@ void whenBeanPostProcessorBeanMethodIsNotStaticWithExternalClass() throws IOExce
193193
dependencies {
194194
implementation("org.springframework.integration:spring-integration-jmx:6.3.9")
195195
}
196+
architectureCheck {
197+
nullMarked = false
198+
}
196199
""");
197200
Path testClass = this.projectDir.resolve("src/main/java/boot/architecture/bpp/external/TestClass.java");
198201
Files.createDirectories(testClass.getParent());
@@ -246,6 +249,9 @@ private void runGradleWithCompiledClasses(String path, Consumer<GradleRunner> ca
246249
output.classesDirs.setFrom(file("classes"))
247250
}
248251
}
252+
architectureCheck {
253+
nullMarked = false
254+
}
249255
""");
250256
runGradle(callback);
251257
}

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))

loader/spring-boot-loader-classic/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,7 @@ tasks.configureEach {
3636
prohibitObjectsRequireNonNull = false
3737
}
3838
}
39+
40+
architectureCheck {
41+
nullMarked = false
42+
}

0 commit comments

Comments
 (0)