Extend ArchitectureCheck with NullMarkedExtension

Introduce NullMarkedExtension for ArchitectureCheck, which provides
functionality to configure packages to ignore in nullability checks and
to enable or disable the extension.

See gh-47596

Signed-off-by: Dmytro Nosan <dimanosan@gmail.com>
This commit is contained in:
Dmytro Nosan
2025-10-15 08:23:47 +02:00
committed by Stéphane Nicoll
parent a043867ae1
commit bc2ca5b9fc
16 changed files with 158 additions and 42 deletions
@@ -44,6 +44,7 @@ import org.gradle.api.file.FileTree;
import org.gradle.api.provider.ListProperty;
import org.gradle.api.provider.Property;
import org.gradle.api.provider.Provider;
import org.gradle.api.provider.SetProperty;
import org.gradle.api.tasks.Classpath;
import org.gradle.api.tasks.IgnoreEmptyDirectories;
import org.gradle.api.tasks.Input;
@@ -80,8 +81,8 @@ 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()))));
getRules().addAll(and(getNullMarkedEnabled(), isMainSourceSet()).map(whenTrue(() -> Collections.singletonList(
ArchitectureRules.packagesShouldBeAnnotatedWithNullMarked(getNullMarkedIgnoredPackages().get())))));
getRuleDescriptions().set(getRules().map(this::asDescriptions));
}
@@ -196,6 +197,9 @@ public abstract class ArchitectureCheck extends DefaultTask {
abstract ListProperty<String> getRuleDescriptions();
@Internal
abstract Property<Boolean> getNullMarked();
abstract Property<Boolean> getNullMarkedEnabled();
@Internal
abstract SetProperty<String> getNullMarkedIgnoredPackages();
}
@@ -16,8 +16,14 @@
package org.springframework.boot.build.architecture;
import java.util.LinkedHashSet;
import javax.inject.Inject;
import org.gradle.api.Action;
import org.gradle.api.model.ObjectFactory;
import org.gradle.api.provider.Property;
import org.jspecify.annotations.NullMarked;
import org.gradle.api.provider.SetProperty;
/**
* Extension to configure the {@link ArchitecturePlugin}.
@@ -26,14 +32,51 @@ import org.jspecify.annotations.NullMarked;
*/
public abstract class ArchitectureCheckExtension {
public ArchitectureCheckExtension() {
getNullMarked().convention(true);
private final NullMarkedExtension nullMarked;
@Inject
public ArchitectureCheckExtension(ObjectFactory objects) {
this.nullMarked = objects.newInstance(NullMarkedExtension.class);
}
/**
* Whether this project uses JSpecify's {@link NullMarked} annotations.
* @return whether this project uses JSpecify's @NullMarked annotations
* Get the {@code NullMarked} extension.
* @return the {@code NullMarked} extension
*/
public abstract Property<Boolean> getNullMarked();
public NullMarkedExtension getNullMarked() {
return this.nullMarked;
}
/**
* Configure the {@code NullMarked} extension.
* @param action the action to configure the {@code NullMarked} extension with
*/
public void nullMarked(Action<? super NullMarkedExtension> action) {
action.execute(this.nullMarked);
}
/**
* Extension to configure the {@code NullMarked} extension.
*/
public abstract static class NullMarkedExtension {
public NullMarkedExtension() {
getEnabled().convention(true);
getIgnoredPackages().convention(new LinkedHashSet<>());
}
/**
* Whether this project uses JSpecify's {@code NullMarked} annotations.
* @return whether this project uses JSpecify's @NullMarked annotations
*/
public abstract Property<Boolean> getEnabled();
/**
* Packages that should be ignored by the {@code NullMarked} checker.
* @return the ignored packages
*/
public abstract SetProperty<String> getIgnoredPackages();
}
}
@@ -59,7 +59,8 @@ 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());
task.getNullMarkedEnabled().set(extension.getNullMarked().getEnabled());
task.getNullMarkedIgnoredPackages().set(extension.getNullMarked().getIgnoredPackages());
});
packageTangleChecks.add(checkPackageTangles);
}
@@ -82,11 +82,6 @@ final class ArchitectureRules {
private static final String TEST_AUTOCONFIGURATION_ANNOTATION = "org.springframework.boot.test.autoconfigure.TestAutoConfiguration";
private static final Predicate<JavaPackage> NULL_MARKED_PACKAGE_FILTER = (candidate) -> !List
.of("org.springframework.boot.cli.json", "org.springframework.boot.configurationmetadata.json",
"org.springframework.boot.configurationprocessor.json")
.contains(candidate.getName());
private ArchitectureRules() {
}
@@ -262,8 +257,8 @@ final class ArchitectureRules {
.allowEmptyShould(true);
}
static ArchRule packagesShouldBeAnnotatedWithNullMarked() {
return ArchRuleDefinition.all(packages(NULL_MARKED_PACKAGE_FILTER))
static ArchRule packagesShouldBeAnnotatedWithNullMarked(Set<String> ignoredPackages) {
return ArchRuleDefinition.all(packages((javaPackage) -> !ignoredPackages.contains(javaPackage.getName())))
.should(beAnnotatedWithNullMarked())
.allowEmptyShould(true);
}
@@ -26,6 +26,8 @@ import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.function.UnaryOperator;
import java.util.stream.Collectors;
import org.gradle.api.tasks.SourceSet;
import org.gradle.testkit.runner.BuildResult;
@@ -40,6 +42,7 @@ import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.springframework.util.ClassUtils;
import org.springframework.util.CollectionUtils;
import org.springframework.util.FileSystemUtils;
import org.springframework.util.StringUtils;
@@ -65,7 +68,7 @@ class ArchitectureCheckTests {
@BeforeEach
void setup(@TempDir Path projectDir) {
this.gradleBuild = new GradleBuild(projectDir).withNullMarked(false);
this.gradleBuild = new GradleBuild(projectDir).withNullMarkedEnabled(false);
}
@ParameterizedTest(name = "{0}")
@@ -275,14 +278,23 @@ class ArchitectureCheckTests {
@Test
void whenPackageIsNotAnnotatedWithNullMarkedWithMainSourcesShouldFailAndWriteEmptyReport() throws IOException {
prepareTask(Task.CHECK_ARCHITECTURE_MAIN, "nullmarked/notannotated");
buildAndFail(this.gradleBuild.withNullMarked(true), Task.CHECK_ARCHITECTURE_MAIN,
buildAndFail(this.gradleBuild.withNullMarkedEnabled(true), Task.CHECK_ARCHITECTURE_MAIN,
"Package org.springframework.boot.build.architecture.nullmarked.notannotated is not annotated with @NullMarked");
}
@Test
void whenPackageIsIgnoredAndNotAnnotatedWithNullMarkedWithMainSourcesShouldSucceedAndWriteEmptyReport()
throws IOException {
prepareTask(Task.CHECK_ARCHITECTURE_MAIN, "nullmarked/notannotated");
build(this.gradleBuild.withNullMarkedEnabled(true)
.withNullMarkedIgnoredPackages("org.springframework.boot.build.architecture.nullmarked.notannotated"),
Task.CHECK_ARCHITECTURE_MAIN);
}
@Test
void whenPackageIsNotAnnotatedWithNullMarkedWithTestSourcesShouldSucceedAndWriteEmptyReport() throws IOException {
prepareTask(Task.CHECK_ARCHITECTURE_TEST, "nullmarked/notannotated");
build(this.gradleBuild.withNullMarked(true), Task.CHECK_ARCHITECTURE_TEST);
build(this.gradleBuild.withNullMarkedEnabled(true), Task.CHECK_ARCHITECTURE_TEST);
}
@Test
@@ -386,7 +398,7 @@ class ArchitectureCheckTests {
private final Map<Task, Boolean> prohibitObjectsRequireNonNull = new LinkedHashMap<>();
private Boolean nullMarked;
private NullMarkedExtension nullMarkedExtension;
private GradleBuild(Path projectDir) {
this.projectDir = projectDir;
@@ -396,16 +408,29 @@ class ArchitectureCheckTests {
return this.projectDir;
}
GradleBuild withNullMarked(Boolean nullMarked) {
this.nullMarked = nullMarked;
return this;
}
GradleBuild withProhibitObjectsRequireNonNull(Task task, boolean prohibitObjectsRequireNonNull) {
this.prohibitObjectsRequireNonNull.put(task, prohibitObjectsRequireNonNull);
return this;
}
GradleBuild withNullMarkedEnabled(Boolean enabled) {
configureNullMarkedExtension((nullMarked) -> nullMarked.withEnabled(enabled));
return this;
}
GradleBuild withNullMarkedIgnoredPackages(String... ignorePackages) {
configureNullMarkedExtension((nullMarked) -> nullMarked.withIgnoredPackages(ignorePackages));
return this;
}
private void configureNullMarkedExtension(UnaryOperator<NullMarkedExtension> configurer) {
NullMarkedExtension nullMarkedExtension = this.nullMarkedExtension;
if (nullMarkedExtension == null) {
nullMarkedExtension = new NullMarkedExtension(null, null);
}
this.nullMarkedExtension = configurer.apply(nullMarkedExtension);
}
GradleBuild withDependencies(String... dependencies) {
this.dependencies.addAll(Arrays.asList(dependencies));
return this;
@@ -444,11 +469,22 @@ class ArchitectureCheckTests {
.append(" prohibitObjectsRequireNonNull = ")
.append(prohibitObjectsRequireNonNull)
.append("\n}\n\n"));
if (this.nullMarked != null) {
buildFile.append("architectureCheck {\n")
.append(" nullMarked = ")
.append(this.nullMarked)
.append("\n}\n");
NullMarkedExtension nullMarkedExtension = this.nullMarkedExtension;
if (nullMarkedExtension != null) {
buildFile.append("architectureCheck {");
buildFile.append("\n nullMarked {");
if (nullMarkedExtension.enabled() != null) {
buildFile.append("\n enabled = ").append(nullMarkedExtension.enabled());
}
if (!CollectionUtils.isEmpty(nullMarkedExtension.ignoredPackages())) {
buildFile.append("\n ignoredPackages = ")
.append(nullMarkedExtension.ignoredPackages()
.stream()
.map(StringUtils::quote)
.collect(Collectors.joining(",", "[", "]")));
}
buildFile.append("\n }");
buildFile.append("\n}\n\n");
}
Files.writeString(this.projectDir.resolve("build.gradle"), buildFile, StandardCharsets.UTF_8);
return GradleRunner.create()
@@ -457,6 +493,18 @@ class ArchitectureCheckTests {
.withPluginClasspath();
}
private record NullMarkedExtension(Boolean enabled, Set<String> ignoredPackages) {
private NullMarkedExtension withEnabled(Boolean enabled) {
return new NullMarkedExtension(enabled, this.ignoredPackages);
}
private NullMarkedExtension withIgnoredPackages(String... ignoredPackages) {
return new NullMarkedExtension(this.enabled, new LinkedHashSet<>(Arrays.asList(ignoredPackages)));
}
}
}
}
+4 -1
View File
@@ -62,7 +62,10 @@ dependencies {
}
architectureCheck {
nullMarked = false
nullMarked {
enabled = false
ignoredPackages = ['org.springframework.boot.cli.json']
}
}
tasks.register("fullJar", Jar) {
@@ -39,7 +39,9 @@ dependencies {
}
architectureCheck {
nullMarked = false
nullMarked {
enabled = false
}
}
def dependenciesOf(String version) {
@@ -36,5 +36,8 @@ dependencies {
}
architectureCheck {
nullMarked = false
nullMarked {
enabled = false
ignoredPackages = ["org.springframework.boot.configurationmetadata.json"]
}
}
@@ -31,7 +31,10 @@ sourceSets {
}
architectureCheck {
nullMarked = false
nullMarked {
enabled = false
ignoredPackages = ["org.springframework.boot.configurationprocessor.json"]
}
}
dependencies {
@@ -28,5 +28,7 @@ dependencies {
}
architectureCheck {
nullMarked = false
nullMarked {
enabled = false
}
}
+3 -1
View File
@@ -71,7 +71,9 @@ tasks.named('compileKotlin', KotlinCompilationTask.class) {
}
architectureCheck {
nullMarked = false
nullMarked {
enabled = false
}
}
plugins.withType(EclipsePlugin) {
+3 -1
View File
@@ -38,5 +38,7 @@ tasks.configureEach {
}
architectureCheck {
nullMarked = false
nullMarked {
enabled = false
}
}
@@ -34,5 +34,7 @@ dependencies {
}
architectureCheck {
nullMarked = false
nullMarked {
enabled = false
}
}
@@ -52,5 +52,7 @@ dependencies {
}
architectureCheck {
nullMarked = false
nullMarked {
enabled = false
}
}
@@ -32,5 +32,7 @@ dependencies {
}
architectureCheck {
nullMarked = false
nullMarked {
enabled = false
}
}
@@ -63,5 +63,7 @@ dependencies {
}
architectureCheck {
nullMarked = false
nullMarked {
enabled = false
}
}