diff --git a/spring-core/src/main/java/org/springframework/core/MethodParameter.java b/spring-core/src/main/java/org/springframework/core/MethodParameter.java
index f89f03b855..8070c7b216 100644
--- a/spring-core/src/main/java/org/springframework/core/MethodParameter.java
+++ b/spring-core/src/main/java/org/springframework/core/MethodParameter.java
@@ -18,7 +18,6 @@ package org.springframework.core;
import java.lang.annotation.Annotation;
import java.lang.reflect.AnnotatedElement;
-import java.lang.reflect.AnnotatedType;
import java.lang.reflect.Constructor;
import java.lang.reflect.Executable;
import java.lang.reflect.Field;
@@ -388,39 +387,18 @@ public class MethodParameter {
/**
* Return whether this method indicates a parameter which is not required:
- * either in the form of Java 8's {@link java.util.Optional}, any variant
- * of a parameter-level {@code Nullable} annotation (such as from JSpecify,
- * JSR-305 or Jakarta set of annotations), or a language-level nullable type
+ * either in the form of Java 8's {@link java.util.Optional}, JSpecify annotations,
+ * any variant of a parameter-level {@code @Nullable} annotation (such as from Spring,
+ * JSR-305 or Jakarta set of annotations), a language-level nullable type
* declaration or {@code Continuation} parameter in Kotlin.
* @since 4.3
+ * @see Nullness#forMethodParameter(MethodParameter)
*/
public boolean isOptional() {
- return (getParameterType() == Optional.class || hasNullableAnnotation() ||
+ return (getParameterType() == Optional.class || Nullness.forMethodParameter(this) == Nullness.NULLABLE ||
(KotlinDetector.isKotlinType(getContainingClass()) && KotlinDelegate.isOptional(this)));
}
- /**
- * Check whether this method parameter is annotated with any variant of a
- * {@code Nullable} annotation, for example, {@code org.springframework.lang.Nullable},
- * {@code org.jspecify.annotations.Nullable} or {@code jakarta.annotation.Nullable}.
- */
- private boolean hasNullableAnnotation() {
- for (Annotation ann : getParameterAnnotations()) {
- if ("Nullable".equals(ann.annotationType().getSimpleName())) {
- return true;
- }
- }
- if (this.parameterIndex >= 0) {
- AnnotatedType annotatedType = this.executable.getAnnotatedParameterTypes()[this.parameterIndex];
- for (Annotation ann : annotatedType.getAnnotations()) {
- if ("Nullable".equals(ann.annotationType().getSimpleName())) {
- return true;
- }
- }
- }
- return false;
- }
-
/**
* Return a variant of this {@code MethodParameter} which points to
* the same parameter but one nesting level deeper in case of a
diff --git a/spring-core/src/main/java/org/springframework/core/Nullness.java b/spring-core/src/main/java/org/springframework/core/Nullness.java
new file mode 100644
index 0000000000..e69df973b3
--- /dev/null
+++ b/spring-core/src/main/java/org/springframework/core/Nullness.java
@@ -0,0 +1,210 @@
+/*
+ * Copyright 2002-2025 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.core;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.AnnotatedElement;
+import java.lang.reflect.AnnotatedType;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Executable;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+import java.util.Objects;
+import java.util.function.Predicate;
+
+import kotlin.reflect.KFunction;
+import kotlin.reflect.KParameter;
+import kotlin.reflect.KProperty;
+import kotlin.reflect.jvm.ReflectJvmMapping;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.NullUnmarked;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Constants that indicate the nullness, as well as related utility methods.
+ *
+ *
The nullness applies to a type usage, a field, a method return type or a parameter.
+ * JSpecify annotations are fully supported, as well as
+ * Kotlin null safety and {@code @Nullable} annotations
+ * regardless of their package (from Spring, JSR-305 or Jakarta set of annotations for example).
+ *
+ * @author Sebastien Deleuze
+ * @since 7.0
+ */
+public enum Nullness {
+
+ /**
+ * Unspecified nullness (Java and JSpecify {@code @NullUnmarked} defaults).
+ */
+ UNSPECIFIED,
+
+ /**
+ * Can include null (typically specified with a {@code @Nullable} annotation).
+ */
+ NULLABLE,
+
+ /**
+ * Will not include null (Kotlin and JSpecify {@code @NullMarked} defaults).
+ */
+ NON_NULL;
+
+
+ /**
+ * Return the nullness of the given method return type.
+ * @param method the source for the method return type
+ * @return the corresponding nullness
+ */
+ public static Nullness forMethodReturnType(Method method) {
+ if (KotlinDetector.isKotlinType(method.getDeclaringClass())) {
+ return KotlinDelegate.forMethodReturnType(method);
+ }
+ return (hasNullableAnnotation(method) ? Nullness.NULLABLE :
+ jSpecifyNullness(method, method.getDeclaringClass(), method.getAnnotatedReturnType()));
+ }
+
+ /**
+ * Return the nullness of the given parameter.
+ * @param parameter the parameter descriptor
+ * @return the corresponding nullness
+ */
+ public static Nullness forParameter(Parameter parameter) {
+ if (KotlinDetector.isKotlinType(parameter.getDeclaringExecutable().getDeclaringClass())) {
+ // TODO Optimize when kotlin-reflect provide a more direct Parameter to KParameter resolution
+ MethodParameter methodParameter = MethodParameter.forParameter(parameter);
+ return KotlinDelegate.forParameter(methodParameter.getExecutable(), methodParameter.getParameterIndex());
+ }
+ Executable executable = parameter.getDeclaringExecutable();
+ return (hasNullableAnnotation(parameter) ? Nullness.NULLABLE :
+ jSpecifyNullness(executable, executable.getDeclaringClass(), parameter.getAnnotatedType()));
+ }
+
+ /**
+ * Return the nullness of the given method parameter.
+ * @param methodParameter the method parameter descriptor
+ * @return the corresponding nullness
+ */
+ public static Nullness forMethodParameter(MethodParameter methodParameter) {
+ return (methodParameter.getParameterIndex() < 0 ?
+ forMethodReturnType(Objects.requireNonNull(methodParameter.getMethod())) :
+ forParameter(methodParameter.getParameter()));
+ }
+
+ /**
+ * Return the nullness of the given field.
+ * @param field the field descriptor
+ * @return the corresponding nullness
+ */
+ public static Nullness forField(Field field) {
+ if (KotlinDetector.isKotlinType(field.getDeclaringClass())) {
+ return KotlinDelegate.forField(field);
+ }
+ return (hasNullableAnnotation(field) ? Nullness.NULLABLE :
+ jSpecifyNullness(field, field.getDeclaringClass(), field.getAnnotatedType()));
+ }
+
+
+ // Check method and parameter level @Nullable annotations regardless of the package (including Spring and JSR 305 annotations)
+ private static boolean hasNullableAnnotation(AnnotatedElement element) {
+ for (Annotation annotation : element.getDeclaredAnnotations()) {
+ if ("Nullable".equals(annotation.annotationType().getSimpleName())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static Nullness jSpecifyNullness(AnnotatedElement annotatedElement, Class> declaringClass, AnnotatedType annotatedType) {
+ if (annotatedType.isAnnotationPresent(Nullable.class)) {
+ return Nullness.NULLABLE;
+ }
+ if (annotatedType.isAnnotationPresent(NonNull.class)) {
+ return Nullness.NON_NULL;
+ }
+ Nullness nullness = Nullness.UNSPECIFIED;
+ // Package level
+ Package declaringPackage = declaringClass.getPackage();
+ if (declaringPackage.isAnnotationPresent(NullMarked.class)) {
+ nullness = Nullness.NON_NULL;
+ }
+ // Class level
+ if (declaringClass.isAnnotationPresent(NullMarked.class)) {
+ nullness = Nullness.NON_NULL;
+ }
+ else if (declaringClass.isAnnotationPresent(NullUnmarked.class)) {
+ nullness = Nullness.UNSPECIFIED;
+ }
+ // Annotated element level
+ if (annotatedElement.isAnnotationPresent(NullMarked.class)) {
+ nullness = Nullness.NON_NULL;
+ }
+ else if (annotatedElement.isAnnotationPresent(NullUnmarked.class)) {
+ nullness = Nullness.UNSPECIFIED;
+ }
+ return nullness;
+ }
+
+ /**
+ * Inner class to avoid a hard dependency on Kotlin at runtime.
+ */
+ private static class KotlinDelegate {
+
+
+ public static Nullness forMethodReturnType(Method method) {
+ KFunction> function = ReflectJvmMapping.getKotlinFunction(method);
+ if (function != null && function.getReturnType().isMarkedNullable()) {
+ return Nullness.NULLABLE;
+ }
+ return Nullness.NON_NULL;
+ }
+
+ public static Nullness forParameter(Executable executable, int parameterIndex) {
+ KFunction> function;
+ Predicate predicate;
+ if (executable instanceof Method method) {
+ function = ReflectJvmMapping.getKotlinFunction(method);
+ predicate = p -> KParameter.Kind.VALUE.equals(p.getKind());
+ }
+ else {
+ function = ReflectJvmMapping.getKotlinFunction((Constructor>) executable);
+ predicate = p -> (KParameter.Kind.VALUE.equals(p.getKind()) ||
+ KParameter.Kind.INSTANCE.equals(p.getKind()));
+ }
+ if (function == null) {
+ return Nullness.UNSPECIFIED;
+ }
+ int i = 0;
+ for (KParameter kParameter : function.getParameters()) {
+ if (predicate.test(kParameter) && parameterIndex == i++) {
+ return (kParameter.getType().isMarkedNullable() ? Nullness.NULLABLE : Nullness.NON_NULL);
+ }
+ }
+ return Nullness.UNSPECIFIED;
+ }
+
+ public static Nullness forField(Field field) {
+ KProperty> property = ReflectJvmMapping.getKotlinProperty(field);
+ if (property != null && property.getReturnType().isMarkedNullable()) {
+ return Nullness.NULLABLE;
+ }
+ return Nullness.NON_NULL;
+ }
+
+ }
+
+}
diff --git a/spring-core/src/test/java/org/springframework/core/NullnessTests.java b/spring-core/src/test/java/org/springframework/core/NullnessTests.java
new file mode 100644
index 0000000000..2cc0954deb
--- /dev/null
+++ b/spring-core/src/test/java/org/springframework/core/NullnessTests.java
@@ -0,0 +1,380 @@
+/*
+ * Copyright 2002-2025 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.core;
+
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.core.testfixture.nullness.ClassMarkedJSpecifyProcessor;
+import org.springframework.core.testfixture.nullness.CustomNullableProcessor;
+import org.springframework.core.testfixture.nullness.JSpecifyProcessor;
+import org.springframework.core.testfixture.nullness.NullnessFields;
+import org.springframework.core.testfixture.nullness.marked.PackageMarkedJSpecifyProcessor;
+import org.springframework.core.testfixture.nullness.marked.unmarked.PackageUnmarkedJSpecifyProcessor;
+
+/**
+ * Tests for {@link Nullness}.
+ *
+ * @author Sebastien Deleuze
+ */
+public class NullnessTests {
+
+ // JSpecify without @NullMarked and @NullUnmarked
+
+ @Test
+ void jspecifyUnspecifiedReturnType() throws NoSuchMethodException {
+ var method = JSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class);
+ var nullness = Nullness.forMethodReturnType(method);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.UNSPECIFIED);
+ }
+
+ @Test
+ void jspecifyNullableReturnType() throws NoSuchMethodException {
+ var method = JSpecifyProcessor.class.getMethod("nullableProcess");
+ var nullness = Nullness.forMethodReturnType(method);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE);
+ }
+
+ @Test
+ void jspecifyNonNullReturnType() throws NoSuchMethodException {
+ var method = JSpecifyProcessor.class.getMethod("nonNullProcess");
+ var nullness = Nullness.forMethodReturnType(method);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL);
+ }
+
+ @Test
+ void jspecifyUnspecifiedParameter() throws NoSuchMethodException {
+ var method = JSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class);
+ var nullness = Nullness.forParameter(method.getParameters()[0]);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.UNSPECIFIED);
+ }
+
+ @Test
+ void jspecifyNullableParameter() throws NoSuchMethodException {
+ var method = JSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class);
+ var nullness = Nullness.forParameter(method.getParameters()[1]);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE);
+ }
+
+ @Test
+ void jspecifyNonNullParameter() throws NoSuchMethodException {
+ var method = JSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class);
+ var nullness = Nullness.forParameter(method.getParameters()[2]);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL);
+ }
+
+ // JSpecify with MethodParameter without @NullMarked and @NullUnmarked
+
+ @Test
+ void jspecifyUnspecifiedReturnTypeWithMethodParameter() throws NoSuchMethodException {
+ var method = JSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class);
+ var methodParameter = MethodParameter.forExecutable(method, -1);
+ var nullness = Nullness.forMethodParameter(methodParameter);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.UNSPECIFIED);
+ }
+
+ @Test
+ void jspecifyNullableReturnTypeWithMethodParameter() throws NoSuchMethodException {
+ var method = JSpecifyProcessor.class.getMethod("nullableProcess");
+ var methodParameter = MethodParameter.forExecutable(method, -1);
+ var nullness = Nullness.forMethodParameter(methodParameter);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE);
+ }
+
+ @Test
+ void jspecifyNonNullReturnTypeWithMethodParameter() throws NoSuchMethodException {
+ var method = JSpecifyProcessor.class.getMethod("nonNullProcess");
+ var methodParameter = MethodParameter.forExecutable(method, -1);
+ var nullness = Nullness.forMethodParameter(methodParameter);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL);
+ }
+
+ @Test
+ void jspecifyUnspecifiedParameterWithMethodParameter() throws NoSuchMethodException {
+ var method = JSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class);
+ var methodParameter = MethodParameter.forExecutable(method, 0);
+ var nullness = Nullness.forMethodParameter(methodParameter);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.UNSPECIFIED);
+ }
+
+ @Test
+ void jspecifyNullableParameterWithMethodParameter() throws NoSuchMethodException {
+ var method = JSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class);
+ var methodParameter = MethodParameter.forExecutable(method, 1);
+ var nullness = Nullness.forMethodParameter(methodParameter);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE);
+ }
+
+ @Test
+ void jspecifyNonNullParameterWithMethodParameter() throws NoSuchMethodException {
+ var method = JSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class);
+ var methodParameter = MethodParameter.forExecutable(method, 2);
+ var nullness = Nullness.forMethodParameter(methodParameter);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL);
+ }
+
+ // JSpecify with Field without @NullMarked and @NullUnmarked
+
+ @Test
+ void jspecifyUnspecifiedWithField() throws NoSuchFieldException {
+ var field = NullnessFields.class.getDeclaredField("unannotatedField");
+ var nullness = Nullness.forField(field);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.UNSPECIFIED);
+ }
+
+ @Test
+ void jspecifyNullableWithField() throws NoSuchFieldException {
+ var field = NullnessFields.class.getDeclaredField("jspecifyNullableField");
+ var nullness = Nullness.forField(field);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE);
+ }
+
+ @Test
+ void jspecifyNonNullWithField() throws NoSuchFieldException {
+ var field = NullnessFields.class.getDeclaredField("jspecifyNonNullField");
+ var nullness = Nullness.forField(field);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL);
+ }
+
+ // JSpecify with method-level @NullMarked
+
+ @Test
+ void jspecifyMethodMarkedUnspecifiedReturnType() throws NoSuchMethodException {
+ var method = JSpecifyProcessor.class.getMethod("markedProcess", String.class, String.class, String.class);
+ var nullness = Nullness.forMethodReturnType(method);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL);
+ }
+
+ @Test
+ void jspecifyMethodMarkedNullableReturnType() throws NoSuchMethodException {
+ var method = JSpecifyProcessor.class.getMethod("nullableMarkedProcess");
+ var nullness = Nullness.forMethodReturnType(method);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE);
+ }
+
+ @Test
+ void jspecifyMethodMarkedNonNullReturnType() throws NoSuchMethodException {
+ var method = JSpecifyProcessor.class.getMethod("nonNullMarkedProcess");
+ var nullness = Nullness.forMethodReturnType(method);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL);
+ }
+
+ @Test
+ void jspecifyMethodMarkedUnspecifiedParameter() throws NoSuchMethodException {
+ var method = JSpecifyProcessor.class.getMethod("markedProcess", String.class, String.class, String.class);
+ var nullness = Nullness.forParameter(method.getParameters()[0]);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL);
+ }
+
+ @Test
+ void jspecifyMethodMarkedNullableParameter() throws NoSuchMethodException {
+ var method = JSpecifyProcessor.class.getMethod("markedProcess", String.class, String.class, String.class);
+ var nullness = Nullness.forParameter(method.getParameters()[1]);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE);
+ }
+
+ @Test
+ void jspecifyMethodMarkedNonNullParameter() throws NoSuchMethodException {
+ var method = JSpecifyProcessor.class.getMethod("markedProcess", String.class, String.class, String.class);
+ var nullness = Nullness.forParameter(method.getParameters()[2]);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL);
+ }
+
+ // JSpecify with class-level @NullMarked
+
+ @Test
+ void jspecifyClassMarkedUnspecifiedReturnType() throws NoSuchMethodException {
+ var method = ClassMarkedJSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class);
+ var nullness = Nullness.forMethodReturnType(method);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL);
+ }
+
+ @Test
+ void jspecifyClassMarkedNullableReturnType() throws NoSuchMethodException {
+ var method = ClassMarkedJSpecifyProcessor.class.getMethod("nullableProcess");
+ var nullness = Nullness.forMethodReturnType(method);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE);
+ }
+
+ @Test
+ void jspecifyClassMarkedNonNullReturnType() throws NoSuchMethodException {
+ var method = ClassMarkedJSpecifyProcessor.class.getMethod("nonNullProcess");
+ var nullness = Nullness.forMethodReturnType(method);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL);
+ }
+
+ @Test
+ void jspecifyClassMarkedUnspecifiedParameter() throws NoSuchMethodException {
+ var method = ClassMarkedJSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class);
+ var nullness = Nullness.forParameter(method.getParameters()[0]);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL);
+ }
+
+ @Test
+ void jspecifyClassMarkedNullableParameter() throws NoSuchMethodException {
+ var method = ClassMarkedJSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class);
+ var nullness = Nullness.forParameter(method.getParameters()[1]);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE);
+ }
+
+ @Test
+ void jspecifyClassMarkedNonNullParameter() throws NoSuchMethodException {
+ var method = ClassMarkedJSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class);
+ var nullness = Nullness.forParameter(method.getParameters()[2]);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL);
+ }
+
+ @Test
+ void jspecifyClassMarkedMethodUnmarkedUnspecifiedReturnType() throws NoSuchMethodException {
+ var method = ClassMarkedJSpecifyProcessor.class.getMethod("unmarkedProcess", String.class, String.class, String.class);
+ var nullness = Nullness.forMethodReturnType(method);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.UNSPECIFIED);
+ }
+
+ @Test
+ void jspecifyClassMarkedMethodUnmarkedUnspecifiedParameter() throws NoSuchMethodException {
+ var method = ClassMarkedJSpecifyProcessor.class.getMethod("unmarkedProcess", String.class, String.class, String.class);
+ var nullness = Nullness.forParameter(method.getParameters()[0]);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.UNSPECIFIED);
+ }
+
+ @Test
+ void jspecifyClassMarkedMethodUnmarkedNullableParameter() throws NoSuchMethodException {
+ var method = ClassMarkedJSpecifyProcessor.class.getMethod("unmarkedProcess", String.class, String.class, String.class);
+ var nullness = Nullness.forParameter(method.getParameters()[1]);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE);
+ }
+
+ @Test
+ void jspecifyClassMarkedMethodUnmarkedNonNullParameter() throws NoSuchMethodException {
+ var method = ClassMarkedJSpecifyProcessor.class.getMethod("unmarkedProcess", String.class, String.class, String.class);
+ var nullness = Nullness.forParameter(method.getParameters()[2]);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL);
+ }
+
+ // JSpecify with package-level @NullMarked
+
+ @Test
+ void jspecifyPackageMarkedUnspecifiedReturnType() throws NoSuchMethodException {
+ var method = PackageMarkedJSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class);
+ var nullness = Nullness.forMethodReturnType(method);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL);
+ }
+
+ @Test
+ void jspecifyPackageMarkedNullableReturnType() throws NoSuchMethodException {
+ var method = PackageMarkedJSpecifyProcessor.class.getMethod("nullableProcess");
+ var nullness = Nullness.forMethodReturnType(method);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE);
+ }
+
+ @Test
+ void jspecifyPackageMarkedNonNullReturnType() throws NoSuchMethodException {
+ var method = PackageMarkedJSpecifyProcessor.class.getMethod("nonNullProcess");
+ var nullness = Nullness.forMethodReturnType(method);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL);
+ }
+
+ @Test
+ void jspecifyPackageMarkedUnspecifiedParameter() throws NoSuchMethodException {
+ var method = PackageMarkedJSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class);
+ var nullness = Nullness.forParameter(method.getParameters()[0]);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL);
+ }
+
+ @Test
+ void jspecifyPackageMarkedNullableParameter() throws NoSuchMethodException {
+ var method = PackageMarkedJSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class);
+ var nullness = Nullness.forParameter(method.getParameters()[1]);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE);
+ }
+
+ @Test
+ void jspecifyPackageMarkedNonNullParameter() throws NoSuchMethodException {
+ var method = PackageMarkedJSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class);
+ var nullness = Nullness.forParameter(method.getParameters()[2]);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL);
+ }
+
+ // JSpecify with package-level @NullUnmarked
+
+ @Test
+ void jspecifyPackageUnmarkedUnspecifiedReturnType() throws NoSuchMethodException {
+ var method = PackageUnmarkedJSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class);
+ var nullness = Nullness.forMethodReturnType(method);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.UNSPECIFIED);
+ }
+
+ @Test
+ void jspecifyPackageUnmarkedNullableReturnType() throws NoSuchMethodException {
+ var method = PackageUnmarkedJSpecifyProcessor.class.getMethod("nullableProcess");
+ var nullness = Nullness.forMethodReturnType(method);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE);
+ }
+
+ @Test
+ void jspecifyPackageUnmarkedNonNullReturnType() throws NoSuchMethodException {
+ var method = PackageUnmarkedJSpecifyProcessor.class.getMethod("nonNullProcess");
+ var nullness = Nullness.forMethodReturnType(method);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL);
+ }
+
+ @Test
+ void jspecifyPackageUnmarkedUnspecifiedParameter() throws NoSuchMethodException {
+ var method = PackageUnmarkedJSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class);
+ var nullness = Nullness.forParameter(method.getParameters()[0]);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.UNSPECIFIED);
+ }
+
+ @Test
+ void jspecifyPackageUnmarkedNullableParameter() throws NoSuchMethodException {
+ var method = PackageUnmarkedJSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class);
+ var nullness = Nullness.forParameter(method.getParameters()[1]);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE);
+ }
+
+ @Test
+ void jspecifyPackageUnmarkedNonNullParameter() throws NoSuchMethodException {
+ var method = PackageUnmarkedJSpecifyProcessor.class.getMethod("process", String.class, String.class, String.class);
+ var nullness = Nullness.forParameter(method.getParameters()[2]);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL);
+ }
+
+ // Custom @Nullable
+
+ @Test
+ void customNullableReturnType() throws NoSuchMethodException {
+ var method = CustomNullableProcessor.class.getMethod("process", String.class);
+ var nullness = Nullness.forMethodReturnType(method);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE);
+ }
+
+ @Test
+ void customNullableParameter() throws NoSuchMethodException {
+ var method = CustomNullableProcessor.class.getMethod("process", String.class);
+ var nullness = Nullness.forParameter(method.getParameters()[0]);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE);
+ }
+
+ @Test
+ void customNullableField() throws NoSuchFieldException {
+ var field = NullnessFields.class.getDeclaredField("customNullableField");
+ var nullness = Nullness.forField(field);
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE);
+ }
+
+}
diff --git a/spring-core/src/test/kotlin/org/springframework/core/NullnessKotlinTests.kt b/spring-core/src/test/kotlin/org/springframework/core/NullnessKotlinTests.kt
new file mode 100644
index 0000000000..74db2eae47
--- /dev/null
+++ b/spring-core/src/test/kotlin/org/springframework/core/NullnessKotlinTests.kt
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2002-2025 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.core
+
+import org.assertj.core.api.Assertions
+import org.junit.jupiter.api.Test
+import kotlin.reflect.jvm.javaMethod
+
+/**
+ * Kotlin tests for [Nullness].
+ *
+ * @author Sebastien Deleuze
+ */
+class NullnessKotlinTests {
+
+ val nullableProperty: String? = ""
+ val nonNullProperty: String = ""
+
+ @Test
+ fun nullableReturnType() {
+ val method = ::nullable.javaMethod!!
+ val nullness = Nullness.forMethodReturnType(method)
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE)
+ }
+
+ @Test
+ fun nullableParameter() {
+ val method = ::nullable.javaMethod!!
+ val nullness = Nullness.forParameter(method.parameters[0])
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE)
+ }
+
+ @Test
+ fun nonNullReturnType() {
+ val method = ::nonNull.javaMethod!!
+ val nullness = Nullness.forMethodReturnType(method)
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL)
+ }
+
+ @Test
+ fun nonNullParameter() {
+ val method = ::nonNull.javaMethod!!
+ val nullness = Nullness.forParameter(method.parameters[0])
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL)
+ }
+
+ @Test
+ fun nullableProperty() {
+ val field = javaClass.getDeclaredField("nullableProperty")
+ val nullness = Nullness.forField(field)
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NULLABLE)
+ }
+
+ @Test
+ fun nonNullProperty() {
+ val field = javaClass.getDeclaredField("nonNullProperty")
+ val nullness = Nullness.forField(field)
+ Assertions.assertThat(nullness).isEqualTo(Nullness.NON_NULL)
+ }
+
+ @Suppress("unused_parameter")
+ fun nullable(nullable: String?): String? = "foo"
+
+ @Suppress("unused_parameter")
+ fun nonNull(nonNull: String): String = "foo"
+
+}
\ No newline at end of file
diff --git a/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/ClassMarkedJSpecifyProcessor.java b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/ClassMarkedJSpecifyProcessor.java
new file mode 100644
index 0000000000..e1af26ac4c
--- /dev/null
+++ b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/ClassMarkedJSpecifyProcessor.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2002-2025 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.core.testfixture.nullness;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.NullUnmarked;
+import org.jspecify.annotations.Nullable;
+
+@NullMarked
+public interface ClassMarkedJSpecifyProcessor {
+
+ String process(String unspecified, @Nullable String nullable, @NonNull String nonNull);
+
+ @Nullable String nullableProcess();
+
+ @NonNull String nonNullProcess();
+
+ @NullUnmarked
+ String unmarkedProcess(String unspecified, @Nullable String nullable, @NonNull String nonNull);
+
+}
diff --git a/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/CustomNullableProcessor.java b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/CustomNullableProcessor.java
new file mode 100644
index 0000000000..25d6ca8b3b
--- /dev/null
+++ b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/CustomNullableProcessor.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2002-2025 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.core.testfixture.nullness;
+
+public interface CustomNullableProcessor {
+
+ @org.springframework.core.testfixture.nullness.custom.Nullable
+ String process(@org.springframework.core.testfixture.nullness.custom.Nullable String nullable);
+}
diff --git a/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/JSpecifyProcessor.java b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/JSpecifyProcessor.java
new file mode 100644
index 0000000000..a3032f114d
--- /dev/null
+++ b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/JSpecifyProcessor.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2002-2025 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.core.testfixture.nullness;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+public interface JSpecifyProcessor {
+
+ String process(String unspecified, @Nullable String nullable, @NonNull String nonNull);
+
+ @Nullable String nullableProcess();
+
+ @NonNull String nonNullProcess();
+
+ @NullMarked
+ String markedProcess(String unspecified, @Nullable String nullable, @NonNull String nonNull);
+
+ @NullMarked
+ @Nullable String nullableMarkedProcess();
+
+ @NullMarked
+ @NonNull String nonNullMarkedProcess();
+}
diff --git a/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/NullnessFields.java b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/NullnessFields.java
new file mode 100644
index 0000000000..78d7143368
--- /dev/null
+++ b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/NullnessFields.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2002-2025 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.core.testfixture.nullness;
+
+public class NullnessFields {
+
+ public String unannotatedField = "";
+
+ public @org.jspecify.annotations.Nullable String jspecifyNullableField;
+
+ public @org.jspecify.annotations.NonNull String jspecifyNonNullField = "";
+
+ @org.springframework.core.testfixture.nullness.custom.Nullable
+ public String customNullableField;
+}
diff --git a/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/custom/Nullable.java b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/custom/Nullable.java
new file mode 100644
index 0000000000..9834dcc086
--- /dev/null
+++ b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/custom/Nullable.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2002-2025 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.core.testfixture.nullness.custom;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+@Retention(RetentionPolicy.RUNTIME)
+public @interface Nullable {
+}
diff --git a/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/marked/PackageMarkedJSpecifyProcessor.java b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/marked/PackageMarkedJSpecifyProcessor.java
new file mode 100644
index 0000000000..5bf7705ff1
--- /dev/null
+++ b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/marked/PackageMarkedJSpecifyProcessor.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2002-2025 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.core.testfixture.nullness.marked;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
+public interface PackageMarkedJSpecifyProcessor {
+
+ String process(String unspecified, @Nullable String nullable, @NonNull String nonNull);
+
+ @Nullable String nullableProcess();
+
+ @NonNull String nonNullProcess();
+}
diff --git a/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/marked/package-info.java b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/marked/package-info.java
new file mode 100644
index 0000000000..e748cfa814
--- /dev/null
+++ b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/marked/package-info.java
@@ -0,0 +1,4 @@
+@NullMarked
+package org.springframework.core.testfixture.nullness.marked;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/marked/unmarked/PackageUnmarkedJSpecifyProcessor.java b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/marked/unmarked/PackageUnmarkedJSpecifyProcessor.java
new file mode 100644
index 0000000000..9f4adcc9db
--- /dev/null
+++ b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/marked/unmarked/PackageUnmarkedJSpecifyProcessor.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2002-2025 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.core.testfixture.nullness.marked.unmarked;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
+public interface PackageUnmarkedJSpecifyProcessor {
+
+ String process(String unspecified, @Nullable String nullable, @NonNull String nonNull);
+
+ @Nullable String nullableProcess();
+
+ @NonNull String nonNullProcess();
+}
diff --git a/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/marked/unmarked/package-info.java b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/marked/unmarked/package-info.java
new file mode 100644
index 0000000000..63157590dd
--- /dev/null
+++ b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/nullness/marked/unmarked/package-info.java
@@ -0,0 +1,4 @@
+@NullUnmarked
+package org.springframework.core.testfixture.nullness.marked.unmarked;
+
+import org.jspecify.annotations.NullUnmarked;