From bd9b137e0b5ff5ce7823e11eae4be59b47d4de81 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 02:25:31 +0000 Subject: [PATCH 01/11] Add multi-bound type variable regression test showing current bug Agent-Logs-Url: https://github.com/eisop/jspecify-reference-checker/sessions/04ca0898-9105-46aa-bb3f-2accf3d46455 Co-authored-by: wmdietl <6699136+wmdietl@users.noreply.github.com> --- tests/regression/MultiBoundTest.java | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/regression/MultiBoundTest.java diff --git a/tests/regression/MultiBoundTest.java b/tests/regression/MultiBoundTest.java new file mode 100644 index 0000000..d38312a --- /dev/null +++ b/tests/regression/MultiBoundTest.java @@ -0,0 +1,34 @@ +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +class MultiBoundTest { + interface A {} + interface B {} + + // T extends @Nullable A & B: B is non-null, so T must be non-null. + // isNullExclusiveUnderEveryParameterization(T) should be TRUE. + // Returning T as Object! (non-null) should be FINE - no error. + Object nullableFirstNonNullSecond(T t) { + return t; // should NOT be an error + } + + // T extends A & @Nullable B: A is non-null, so T must be non-null. + // Returning T as Object! should be FINE - no error. + Object nonNullFirstNullableSecond(T t) { + return t; // should NOT be an error + } + + // T extends @Nullable A & @Nullable B: both nullable - T can be null. + // Returning T as Object! should be an error. + Object bothNullable(T t) { + // :: error: jspecify_nullness_mismatch + return t; + } + + // T extends A & B: both non-null (default in NullMarked). + // Returning T as Object! should be FINE - no error. + Object bothNonNull(T t) { + return t; // should NOT be an error + } +} From 637e60ac77b595fc2837a31d438d2b47044ce372 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 03:05:34 +0000 Subject: [PATCH 02/11] Fix false positives for multi-bound type vars with mixed nullness (e.g. @Nullable A & B) Agent-Logs-Url: https://github.com/eisop/jspecify-reference-checker/sessions/04ca0898-9105-46aa-bb3f-2accf3d46455 Co-authored-by: wmdietl <6699136+wmdietl@users.noreply.github.com> --- .../NullSpecAnnotatedTypeFactory.java | 115 ++++++++++++++++-- tests/regression/MultiBoundTest.java | 41 +++++-- 2 files changed, 135 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/google/jspecify/nullness/NullSpecAnnotatedTypeFactory.java b/src/main/java/com/google/jspecify/nullness/NullSpecAnnotatedTypeFactory.java index fa25f35..efda538 100644 --- a/src/main/java/com/google/jspecify/nullness/NullSpecAnnotatedTypeFactory.java +++ b/src/main/java/com/google/jspecify/nullness/NullSpecAnnotatedTypeFactory.java @@ -27,6 +27,7 @@ import static javax.lang.model.element.ElementKind.ENUM_CONSTANT; import static javax.lang.model.type.TypeKind.ARRAY; import static javax.lang.model.type.TypeKind.DECLARED; +import static javax.lang.model.type.TypeKind.INTERSECTION; import static javax.lang.model.type.TypeKind.NULL; import static javax.lang.model.type.TypeKind.TYPEVAR; import static javax.lang.model.type.TypeKind.WILDCARD; @@ -68,6 +69,8 @@ import javax.lang.model.element.ExecutableElement; import javax.lang.model.element.Name; import javax.lang.model.element.TypeElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.IntersectionType; import javax.lang.model.type.TypeMirror; import javax.lang.model.util.Elements; import javax.lang.model.util.Types; @@ -677,10 +680,11 @@ && isNullnessSubtype(subtype, ((AnnotatedTypeVariable) supertype).getLowerBound( && isSubtype(superTV.getLowerBound(), subTV.getLowerBound()); } } - return isNullInclusiveUnderEveryParameterization(supertype) - || isNullExclusiveUnderEveryParameterization(subtype) - || (nullnessEstablishingPathExists(subtype, supertype) - && !supertype.hasAnnotation(minusNull)); + boolean nullInclSupertype = isNullInclusiveUnderEveryParameterization(supertype); + boolean nullExclSubtype = isNullExclusiveUnderEveryParameterization(subtype); + boolean pathExists = nullnessEstablishingPathExists(subtype, supertype); + boolean supertypeNotMinusNull = !supertype.hasAnnotation(minusNull); + return nullInclSupertype || nullExclSubtype || (pathExists && supertypeNotMinusNull); } } @@ -700,15 +704,36 @@ && isNullInclusiveUnderEveryParameterization( * type have an AnnotationMirror of its own. * * ...well, sort of. As I understand it, what CF does is more that it tries to keep the - * AnnotationMirror of the intersecton type in sync with the AnnotationMirror of each of its + * AnnotationMirror of the intersection type in sync with the AnnotationMirror of each of its * components (which should themselves all match). So the intersection type "has" an * AnnotationMirror, but it provides no *additional* information beyond what is already carried * by its components' AnnotationMirrors. * - * Nevertheless, the result is that we don't need a special case here: The check below is - * redundant with the subsequent check on the intersection's components, but redundancy is - * harmless. + * When components all agree, checking the intersection type's annotation is redundant with + * checking the components, and the redundancy is harmless. However, when bounds have *mixed* + * nullness (e.g., {@code @Nullable A & B}), CF synthesizes an annotation for the intersection + * by copying the annotation of the "widest" (most-nullable) bound to ALL bounds. For example, + * for {@code @Nullable A & B}, CF annotates both the intersection and B as @Nullable, even + * though B itself is non-null. We must not rely on these synthesized CF annotations; instead, + * we check the original source annotations on each bound via the underlying IntersectionType. + * This gives the JSpecify-correct semantics: a value of {@code @Nullable A & B} cannot be null + * if B is non-null (regardless of what CF puts on B after propagation). + * + *

Note: javac implicitly adds {@code java.lang.Object} as the first bound of any + * interface-only intersection type. This implicit Object bound carries no source-level nullness + * information and must be skipped; see {@link #isUnannotatedObjectBound(TypeMirror)}. */ + if (type.getKind() == INTERSECTION) { + for (TypeMirror rawBound : ((IntersectionType) type.getUnderlyingType()).getBounds()) { + if (isUnannotatedObjectBound(rawBound)) { + continue; + } + if (!hasExplicitNullableAnnotation(rawBound)) { + return false; + } + } + return true; + } return type.hasAnnotation(unionNull) || (!isLeastConvenientWorld && type.hasAnnotation(nullnessOperatorUnspecified)); } @@ -767,6 +792,31 @@ private boolean nullnessEstablishingPathExists( return true; } + /* + * For intersection types (e.g., {@code @Nullable A & B}), CF synthesizes an annotation for + * the intersection type by copying the annotation of the "widest" (most-nullable) bound to ALL + * bounds. For {@code @Nullable A & B} in NullMarked code, CF annotates both the intersection + * and B as @Nullable, even though B is non-null. We must not rely on these synthesized CF + * annotations. Instead, we check the original source annotations on each bound via the + * underlying IntersectionType. If any bound is not explicitly @Nullable in the source (and + * therefore non-null by default in NullMarked code), that bound establishes non-nullness. + * + *

Note: javac implicitly adds {@code java.lang.Object} as the first bound of any + * interface-only intersection type. This implicit Object bound carries no source-level nullness + * information and must be skipped; see {@link #isUnannotatedObjectBound(TypeMirror)}. + */ + if (subtype.getKind() == INTERSECTION) { + for (TypeMirror rawBound : ((IntersectionType) subtype.getUnderlyingType()).getBounds()) { + if (isUnannotatedObjectBound(rawBound)) { + continue; + } + if (!hasExplicitNullableAnnotation(rawBound) && supertypeMatcher.test(rawBound)) { + return true; + } + } + return false; + } + if (isUnionNullOrEquivalent(subtype)) { return false; } @@ -839,6 +889,55 @@ private boolean isUnionNullOrEquivalent(AnnotatedTypeMirror type) { || (isLeastConvenientWorld && type.hasAnnotation(nullnessOperatorUnspecified)); } + /** + * Returns true if the given raw {@link TypeMirror} (from an intersection type's underlying + * bounds) bears an explicit {@code @Nullable} annotation in the source code. + * + *

This checks the source-level type annotations on the {@link TypeMirror} directly, unlike + * CF's annotated types, which may have had nullness annotations propagated from other bounds in + * the same intersection type (see {@code AnnotatedIntersectionType.copyIntersectionBoundAnnotations}). + * + *

The raw {@link TypeMirror} carries the original source annotation (e.g., + * {@code @org.jspecify.annotations.Nullable}), not CF's internal alias. So we check both + * CF's internal {@code unionNull} annotation and all recognized nullable annotation names. + */ + private boolean hasExplicitNullableAnnotation(TypeMirror rawType) { + for (AnnotationMirror am : rawType.getAnnotationMirrors()) { + if (areSame(am, unionNull)) { + return true; + } + String qualifiedName = + ((TypeElement) am.getAnnotationType().asElement()).getQualifiedName().toString(); + if (NULLABLE_ANNOTATIONS.contains(qualifiedName)) { + return true; + } + } + return false; + } + + /** + * Returns true if the given raw {@link TypeMirror} is an unannotated {@code java.lang.Object} + * bound that should be skipped when evaluating the nullness of an intersection type. + * + *

When a type variable has only interface bounds (e.g., {@code T extends A & B}), javac + * implicitly inserts {@code java.lang.Object} as the first element of the + * {@link IntersectionType}'s bound list. This implicit bound carries no source-level nullness + * intent, so it must be excluded from the checks in {@link #isNullInclusiveUnderEveryParameterization} + * and {@link #nullnessEstablishingPathExists}. Without this exclusion, an unannotated Object + * bound would be mistakenly treated as a non-null bound, causing {@code T extends @Nullable A & + * @Nullable B} to be considered non-null (incorrect). + */ + private boolean isUnannotatedObjectBound(TypeMirror rawType) { + if (rawType.getKind() != DECLARED) { + return false; + } + if (!rawType.getAnnotationMirrors().isEmpty()) { + return false; + } + TypeElement typeElement = (TypeElement) ((DeclaredType) rawType).asElement(); + return typeElement.getQualifiedName().contentEquals("java.lang.Object"); + } + private final class NullSpecEqualityComparer extends StructuralEqualityComparer { NullSpecEqualityComparer(StructuralEqualityVisitHistory typeargVisitHistory) { super(typeargVisitHistory); diff --git a/tests/regression/MultiBoundTest.java b/tests/regression/MultiBoundTest.java index d38312a..7c6cded 100644 --- a/tests/regression/MultiBoundTest.java +++ b/tests/regression/MultiBoundTest.java @@ -1,34 +1,49 @@ +// Copyright 2025 The JSpecify 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 +// +// http://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. + +import java.io.Serializable; +import java.io.Closeable; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; +// Tests for multi-bound type variable handling. +// In NullMarked context, Serializable and Closeable are non-null by default. +// T extends @Nullable Serializable & Closeable: Closeable is non-null so T must be non-null. @NullMarked class MultiBoundTest { - interface A {} - interface B {} - - // T extends @Nullable A & B: B is non-null, so T must be non-null. - // isNullExclusiveUnderEveryParameterization(T) should be TRUE. + // T extends @Nullable Serializable & Closeable: Closeable (non-null) establishes T as non-null. // Returning T as Object! (non-null) should be FINE - no error. - Object nullableFirstNonNullSecond(T t) { + Object nullableFirstNonNullSecond(T t) { return t; // should NOT be an error } - // T extends A & @Nullable B: A is non-null, so T must be non-null. + // T extends Closeable & @Nullable Serializable: Closeable (non-null) establishes T as non-null. // Returning T as Object! should be FINE - no error. - Object nonNullFirstNullableSecond(T t) { + Object nonNullFirstNullableSecond(T t) { return t; // should NOT be an error } - // T extends @Nullable A & @Nullable B: both nullable - T can be null. + // T extends @Nullable Serializable & @Nullable Closeable: both nullable - T can be null. // Returning T as Object! should be an error. - Object bothNullable(T t) { + Object bothNullable(T t) { // :: error: jspecify_nullness_mismatch return t; } - - // T extends A & B: both non-null (default in NullMarked). + + // T extends Serializable & Closeable: both non-null (default in NullMarked). // Returning T as Object! should be FINE - no error. - Object bothNonNull(T t) { + Object bothNonNull(T t) { return t; // should NOT be an error } } From a35a16e6ff6285d2a4716d67caa5bf728cededfc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 04:12:13 +0000 Subject: [PATCH 03/11] Changes before error encountered Agent-Logs-Url: https://github.com/eisop/jspecify-reference-checker/sessions/1d8d53d9-c28b-41fd-bba2-2e523e2ce7b0 Co-authored-by: wmdietl <6699136+wmdietl@users.noreply.github.com> --- tests/regression/CaptureTest.java | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 tests/regression/CaptureTest.java diff --git a/tests/regression/CaptureTest.java b/tests/regression/CaptureTest.java new file mode 100644 index 0000000..09cc50d --- /dev/null +++ b/tests/regression/CaptureTest.java @@ -0,0 +1,31 @@ +// Test for captured wildcard return type issue +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +class CaptureTest { + // This PASSES already (from BoundedTypeVariableReturn) + // F_cap from unbounded ? with @Nullable Foo bound + Foo passing(NullableFooSupplier supplier) { + // :: error: return.type.incompatible + return supplier.get(); + } + + // This should also fail but DOESN'T + // T_cap from ? extends @Nullable Lib with NullableBounded + Object failing(NullableBounded x) { + // :: error: return.type.incompatible + return x.get(); + } + + interface NullableFooSupplier { + F get(); + } + + interface NullableBounded { + T get(); + } + + interface Foo {} + interface Lib {} +} From 079011c40f6c6c5a2219937eb2db5ea7cbdff310 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 13:03:57 +0000 Subject: [PATCH 04/11] Fix broken CaptureTest.java committed in previous session Agent-Logs-Url: https://github.com/eisop/jspecify-reference-checker/sessions/59e45442-3f4d-4889-acb3-007944e8e5e0 Co-authored-by: wmdietl <6699136+wmdietl@users.noreply.github.com> --- tests/regression/CaptureTest.java | 22 ++++++++++------ tests/regression/MultiBoundSampleLike.java | 29 ++++++++++++++++++++++ 2 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 tests/regression/MultiBoundSampleLike.java diff --git a/tests/regression/CaptureTest.java b/tests/regression/CaptureTest.java index 09cc50d..bcc56b4 100644 --- a/tests/regression/CaptureTest.java +++ b/tests/regression/CaptureTest.java @@ -1,23 +1,31 @@ -// Test for captured wildcard return type issue +// Test for captured wildcard nullness handling. +// In NullMarked code, a captured wildcard is nullable iff ALL its effective upper bounds are +// nullable. The checker should detect when a nullable captured value is returned as non-null. import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @NullMarked class CaptureTest { - // This PASSES already (from BoundedTypeVariableReturn) - // F_cap from unbounded ? with @Nullable Foo bound - Foo passing(NullableFooSupplier supplier) { + // T extends @Nullable Foo, wildcard is unbounded (?). + // In NullMarked, ? defaults to @Nullable, so F_cap has nullable upper bound -> error. + Foo nullableUnbounded(NullableFooSupplier supplier) { // :: error: return.type.incompatible return supplier.get(); } - // This should also fail but DOESN'T - // T_cap from ? extends @Nullable Lib with NullableBounded - Object failing(NullableBounded x) { + // T extends @Nullable Object, wildcard is ? extends @Nullable Lib. + // ALL upper bounds are nullable (@Nullable Lib, @Nullable Object) -> T_cap is nullable -> error. + Object nullableExplicitBound(NullableBounded x) { // :: error: return.type.incompatible return x.get(); } + // T extends @Nullable Object, wildcard is ? extends Lib (non-null). + // Lib is non-null -> T_cap has non-null bound -> no error. + Object nonNullExplicitBound(NullableBounded x) { + return x.get(); // should NOT be an error + } + interface NullableFooSupplier { F get(); } diff --git a/tests/regression/MultiBoundSampleLike.java b/tests/regression/MultiBoundSampleLike.java new file mode 100644 index 0000000..85894aa --- /dev/null +++ b/tests/regression/MultiBoundSampleLike.java @@ -0,0 +1,29 @@ +// Test that mimics jspecify's MultiBoundTypeVariableToObject.java samples +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +class MultiBoundSampleLike { + // x0: T extends Object & Lib -> both non-null -> no error + Object x0(T x) { + return x; + } + + // x2: T extends Object & @Nullable Lib -> Object is non-null -> no error + Object x2(T x) { + return x; + } + + // x6: T extends @Nullable Object & Lib -> Lib is non-null -> no error + Object x6(T x) { + return x; + } + + // x8: T extends @Nullable Object & @Nullable Lib -> both nullable -> error + Object x8(T x) { + // :: error: jspecify_nullness_mismatch + return x; + } + + interface Lib {} +} From bf6de37b07953cb7c3d101d79ba5b33e8be12d8b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 13:06:09 +0000 Subject: [PATCH 05/11] Fix false positive for T extends Object & @Nullable Lib: use TypeParameterElement.getBounds() Agent-Logs-Url: https://github.com/eisop/jspecify-reference-checker/sessions/59e45442-3f4d-4889-acb3-007944e8e5e0 Co-authored-by: wmdietl <6699136+wmdietl@users.noreply.github.com> --- .../NullSpecAnnotatedTypeFactory.java | 72 +++++++++++++++++++ tests/regression/CaptureTest.java | 16 ++++- tests/regression/MultiBoundSampleLike.java | 32 ++++++++- 3 files changed, 118 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/google/jspecify/nullness/NullSpecAnnotatedTypeFactory.java b/src/main/java/com/google/jspecify/nullness/NullSpecAnnotatedTypeFactory.java index efda538..45e6cdc 100644 --- a/src/main/java/com/google/jspecify/nullness/NullSpecAnnotatedTypeFactory.java +++ b/src/main/java/com/google/jspecify/nullness/NullSpecAnnotatedTypeFactory.java @@ -25,6 +25,7 @@ import static java.util.Collections.singletonList; import static java.util.Collections.unmodifiableList; import static javax.lang.model.element.ElementKind.ENUM_CONSTANT; +import static javax.lang.model.element.ElementKind.TYPE_PARAMETER; import static javax.lang.model.type.TypeKind.ARRAY; import static javax.lang.model.type.TypeKind.DECLARED; import static javax.lang.model.type.TypeKind.INTERSECTION; @@ -69,9 +70,11 @@ import javax.lang.model.element.ExecutableElement; import javax.lang.model.element.Name; import javax.lang.model.element.TypeElement; +import javax.lang.model.element.TypeParameterElement; import javax.lang.model.type.DeclaredType; import javax.lang.model.type.IntersectionType; import javax.lang.model.type.TypeMirror; +import javax.lang.model.type.TypeVariable; import javax.lang.model.util.Elements; import javax.lang.model.util.Types; import org.checkerframework.common.basetype.BaseTypeChecker; @@ -689,6 +692,40 @@ && isNullnessSubtype(subtype, ((AnnotatedTypeVariable) supertype).getLowerBound( } boolean isNullInclusiveUnderEveryParameterization(AnnotatedTypeMirror type) { + /* + * For declared (non-captured) type variables with multiple bounds, use the + * source-declared bounds from TypeParameterElement.getBounds(). This avoids + * two pitfalls with the generic TYPEVAR lower-bound check below: + * + * 1. javac implicitly inserts java.lang.Object as the first bound for + * interface-only intersections (e.g., {@code T extends @Nullable A & @Nullable B}). + * The source-level element bounds don't include this implicit Object. + * + * 2. CF's copyIntersectionBoundAnnotations propagates @Nullable from any nullable + * bound to ALL bounds, so CF-annotated bounds are unreliable for mixed-nullness + * intersections (e.g., {@code T extends @Nullable Object & Lib}). + * + * Using TypeParameterElement.getBounds() gives us only the programmer-written bounds + * with their original source annotations, free of both issues. + */ + if (type.getKind() == TYPEVAR + && !type.hasAnnotation(minusNull) + && !isCapturedTypeVariable(type.getUnderlyingType())) { + Element element = ((TypeVariable) type.getUnderlyingType()).asElement(); + if (element.getKind() == TYPE_PARAMETER) { + List sourceBounds = ((TypeParameterElement) element).getBounds(); + if (sourceBounds.size() > 1) { + // Multi-bound: all source-declared bounds must be explicitly @Nullable for T to be + // null-inclusive under every parameterization. + for (TypeMirror sourceBound : sourceBounds) { + if (!hasExplicitNullableAnnotation(sourceBound)) { + return false; + } + } + return true; + } + } + } // We put the third case from the spec first because it's a mouthful. // (As discussed in the spec, we probably don't strictly need this case at all....) if (type.getKind() == TYPEVAR @@ -821,6 +858,41 @@ private boolean nullnessEstablishingPathExists( return false; } + /* + * For declared (non-captured) type variables with multiple bounds, use the + * source-declared bounds from TypeParameterElement.getBounds(). This avoids two pitfalls + * with going through getUpperBounds() + the INTERSECTION check above: + * + * 1. javac implicitly inserts java.lang.Object as the first bound for interface-only + * intersections (e.g., {@code T extends @Nullable A & @Nullable B}). The isUnannotatedObjectBound + * check in the INTERSECTION block above would skip this implicit Object, but it also + * skips an *explicit* unannotated Object (e.g., {@code T extends Object & @Nullable Lib}), + * which should be treated as a non-null bound in NullMarked code. + * + * 2. CF's copyIntersectionBoundAnnotations propagates @Nullable from any bound to ALL + * bounds, so the CF-annotated bounds passed to getUpperBounds() are unreliable for + * mixed-nullness intersections. + * + * TypeParameterElement.getBounds() returns only the programmer-written bounds with their + * original source annotations, free of both issues. If any source-declared bound is not + * explicitly @Nullable and matches the supertype, a non-null path is established. + */ + if (subtype.getKind() == TYPEVAR + && !isCapturedTypeVariable(subtype.getUnderlyingType())) { + Element element = ((TypeVariable) subtype.getUnderlyingType()).asElement(); + if (element.getKind() == TYPE_PARAMETER) { + List sourceBounds = ((TypeParameterElement) element).getBounds(); + if (sourceBounds.size() > 1) { + for (TypeMirror sourceBound : sourceBounds) { + if (!hasExplicitNullableAnnotation(sourceBound) && supertypeMatcher.test(sourceBound)) { + return true; + } + } + return false; + } + } + } + if (supertypeMatcher.test(subtype.getUnderlyingType())) { return true; } diff --git a/tests/regression/CaptureTest.java b/tests/regression/CaptureTest.java index bcc56b4..3f182e6 100644 --- a/tests/regression/CaptureTest.java +++ b/tests/regression/CaptureTest.java @@ -1,4 +1,18 @@ -// Test for captured wildcard nullness handling. +// Copyright 2025 The JSpecify 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 +// +// http://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. + +// Tests for captured wildcard nullness handling. // In NullMarked code, a captured wildcard is nullable iff ALL its effective upper bounds are // nullable. The checker should detect when a nullable captured value is returned as non-null. import org.jspecify.annotations.NullMarked; diff --git a/tests/regression/MultiBoundSampleLike.java b/tests/regression/MultiBoundSampleLike.java index 85894aa..a8db7cd 100644 --- a/tests/regression/MultiBoundSampleLike.java +++ b/tests/regression/MultiBoundSampleLike.java @@ -1,4 +1,20 @@ -// Test that mimics jspecify's MultiBoundTypeVariableToObject.java samples +// Copyright 2025 The JSpecify 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 +// +// http://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. + +// Tests mimicking jspecify's MultiBoundTypeVariableToObject.java and +// MultiBoundTypeVariableToOther.java samples. These cover cases with explicit +// java.lang.Object as the first intersection bound alongside @Nullable bounds. import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -25,5 +41,19 @@ Object x0(T x) { return x; } + // Same tests but returning as Lib (non-Object supertype) + Lib x2ToOther(T x) { + return x; + } + + Lib x6ToOther(T x) { + return x; + } + + Lib x8ToOther(T x) { + // :: error: jspecify_nullness_mismatch + return x; + } + interface Lib {} } From 980e45c67b8de6d945c8e36eb45a318edc962b45 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 13:39:52 +0000 Subject: [PATCH 06/11] Remove CaptureTest.java from multi-bound PR (belongs in separate captured-wildcard PR) Agent-Logs-Url: https://github.com/eisop/jspecify-reference-checker/sessions/b69f487a-07bc-4e88-8a86-544f177116d4 Co-authored-by: wmdietl <6699136+wmdietl@users.noreply.github.com> --- tests/regression/CaptureTest.java | 53 ------------------------------- 1 file changed, 53 deletions(-) delete mode 100644 tests/regression/CaptureTest.java diff --git a/tests/regression/CaptureTest.java b/tests/regression/CaptureTest.java deleted file mode 100644 index 3f182e6..0000000 --- a/tests/regression/CaptureTest.java +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright 2025 The JSpecify 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 -// -// http://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. - -// Tests for captured wildcard nullness handling. -// In NullMarked code, a captured wildcard is nullable iff ALL its effective upper bounds are -// nullable. The checker should detect when a nullable captured value is returned as non-null. -import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; - -@NullMarked -class CaptureTest { - // T extends @Nullable Foo, wildcard is unbounded (?). - // In NullMarked, ? defaults to @Nullable, so F_cap has nullable upper bound -> error. - Foo nullableUnbounded(NullableFooSupplier supplier) { - // :: error: return.type.incompatible - return supplier.get(); - } - - // T extends @Nullable Object, wildcard is ? extends @Nullable Lib. - // ALL upper bounds are nullable (@Nullable Lib, @Nullable Object) -> T_cap is nullable -> error. - Object nullableExplicitBound(NullableBounded x) { - // :: error: return.type.incompatible - return x.get(); - } - - // T extends @Nullable Object, wildcard is ? extends Lib (non-null). - // Lib is non-null -> T_cap has non-null bound -> no error. - Object nonNullExplicitBound(NullableBounded x) { - return x.get(); // should NOT be an error - } - - interface NullableFooSupplier { - F get(); - } - - interface NullableBounded { - T get(); - } - - interface Foo {} - interface Lib {} -} From dbd81bddc8c6427a75111848d8f573b107c34e92 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 13:40:23 +0000 Subject: [PATCH 07/11] Add CaptureTest.java regression tests for captured wildcard nullness handling Agent-Logs-Url: https://github.com/eisop/jspecify-reference-checker/sessions/b69f487a-07bc-4e88-8a86-544f177116d4 Co-authored-by: wmdietl <6699136+wmdietl@users.noreply.github.com> --- tests/regression/CaptureTest.java | 53 +++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/regression/CaptureTest.java diff --git a/tests/regression/CaptureTest.java b/tests/regression/CaptureTest.java new file mode 100644 index 0000000..3f182e6 --- /dev/null +++ b/tests/regression/CaptureTest.java @@ -0,0 +1,53 @@ +// Copyright 2025 The JSpecify 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 +// +// http://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. + +// Tests for captured wildcard nullness handling. +// In NullMarked code, a captured wildcard is nullable iff ALL its effective upper bounds are +// nullable. The checker should detect when a nullable captured value is returned as non-null. +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +class CaptureTest { + // T extends @Nullable Foo, wildcard is unbounded (?). + // In NullMarked, ? defaults to @Nullable, so F_cap has nullable upper bound -> error. + Foo nullableUnbounded(NullableFooSupplier supplier) { + // :: error: return.type.incompatible + return supplier.get(); + } + + // T extends @Nullable Object, wildcard is ? extends @Nullable Lib. + // ALL upper bounds are nullable (@Nullable Lib, @Nullable Object) -> T_cap is nullable -> error. + Object nullableExplicitBound(NullableBounded x) { + // :: error: return.type.incompatible + return x.get(); + } + + // T extends @Nullable Object, wildcard is ? extends Lib (non-null). + // Lib is non-null -> T_cap has non-null bound -> no error. + Object nonNullExplicitBound(NullableBounded x) { + return x.get(); // should NOT be an error + } + + interface NullableFooSupplier { + F get(); + } + + interface NullableBounded { + T get(); + } + + interface Foo {} + interface Lib {} +} From 48fac67405875d0086173018ddfcb3327ff9b689 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 13:41:34 +0000 Subject: [PATCH 08/11] Revert accidental re-addition of CaptureTest.java Agent-Logs-Url: https://github.com/eisop/jspecify-reference-checker/sessions/b69f487a-07bc-4e88-8a86-544f177116d4 Co-authored-by: wmdietl <6699136+wmdietl@users.noreply.github.com> --- tests/regression/CaptureTest.java | 53 ------------------------------- 1 file changed, 53 deletions(-) delete mode 100644 tests/regression/CaptureTest.java diff --git a/tests/regression/CaptureTest.java b/tests/regression/CaptureTest.java deleted file mode 100644 index 3f182e6..0000000 --- a/tests/regression/CaptureTest.java +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright 2025 The JSpecify 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 -// -// http://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. - -// Tests for captured wildcard nullness handling. -// In NullMarked code, a captured wildcard is nullable iff ALL its effective upper bounds are -// nullable. The checker should detect when a nullable captured value is returned as non-null. -import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; - -@NullMarked -class CaptureTest { - // T extends @Nullable Foo, wildcard is unbounded (?). - // In NullMarked, ? defaults to @Nullable, so F_cap has nullable upper bound -> error. - Foo nullableUnbounded(NullableFooSupplier supplier) { - // :: error: return.type.incompatible - return supplier.get(); - } - - // T extends @Nullable Object, wildcard is ? extends @Nullable Lib. - // ALL upper bounds are nullable (@Nullable Lib, @Nullable Object) -> T_cap is nullable -> error. - Object nullableExplicitBound(NullableBounded x) { - // :: error: return.type.incompatible - return x.get(); - } - - // T extends @Nullable Object, wildcard is ? extends Lib (non-null). - // Lib is non-null -> T_cap has non-null bound -> no error. - Object nonNullExplicitBound(NullableBounded x) { - return x.get(); // should NOT be an error - } - - interface NullableFooSupplier { - F get(); - } - - interface NullableBounded { - T get(); - } - - interface Foo {} - interface Lib {} -} From 51afdf6e5965da563edd338e732265e07d5b540c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 13:42:14 +0000 Subject: [PATCH 09/11] Add CaptureTest.java regression tests for captured wildcard nullness handling Agent-Logs-Url: https://github.com/eisop/jspecify-reference-checker/sessions/b69f487a-07bc-4e88-8a86-544f177116d4 Co-authored-by: wmdietl <6699136+wmdietl@users.noreply.github.com> --- tests/regression/CaptureTest.java | 53 +++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/regression/CaptureTest.java diff --git a/tests/regression/CaptureTest.java b/tests/regression/CaptureTest.java new file mode 100644 index 0000000..3f182e6 --- /dev/null +++ b/tests/regression/CaptureTest.java @@ -0,0 +1,53 @@ +// Copyright 2025 The JSpecify 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 +// +// http://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. + +// Tests for captured wildcard nullness handling. +// In NullMarked code, a captured wildcard is nullable iff ALL its effective upper bounds are +// nullable. The checker should detect when a nullable captured value is returned as non-null. +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +class CaptureTest { + // T extends @Nullable Foo, wildcard is unbounded (?). + // In NullMarked, ? defaults to @Nullable, so F_cap has nullable upper bound -> error. + Foo nullableUnbounded(NullableFooSupplier supplier) { + // :: error: return.type.incompatible + return supplier.get(); + } + + // T extends @Nullable Object, wildcard is ? extends @Nullable Lib. + // ALL upper bounds are nullable (@Nullable Lib, @Nullable Object) -> T_cap is nullable -> error. + Object nullableExplicitBound(NullableBounded x) { + // :: error: return.type.incompatible + return x.get(); + } + + // T extends @Nullable Object, wildcard is ? extends Lib (non-null). + // Lib is non-null -> T_cap has non-null bound -> no error. + Object nonNullExplicitBound(NullableBounded x) { + return x.get(); // should NOT be an error + } + + interface NullableFooSupplier { + F get(); + } + + interface NullableBounded { + T get(); + } + + interface Foo {} + interface Lib {} +} From d44ee3e4b997a2b235dc8537548bbbf3be4b3a87 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 13:45:06 +0000 Subject: [PATCH 10/11] Remove CaptureTest.java - captured wildcard tests belong in separate PR Agent-Logs-Url: https://github.com/eisop/jspecify-reference-checker/sessions/b69f487a-07bc-4e88-8a86-544f177116d4 Co-authored-by: wmdietl <6699136+wmdietl@users.noreply.github.com> --- tests/regression/CaptureTest.java | 53 ------------------------------- 1 file changed, 53 deletions(-) delete mode 100644 tests/regression/CaptureTest.java diff --git a/tests/regression/CaptureTest.java b/tests/regression/CaptureTest.java deleted file mode 100644 index 3f182e6..0000000 --- a/tests/regression/CaptureTest.java +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright 2025 The JSpecify 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 -// -// http://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. - -// Tests for captured wildcard nullness handling. -// In NullMarked code, a captured wildcard is nullable iff ALL its effective upper bounds are -// nullable. The checker should detect when a nullable captured value is returned as non-null. -import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; - -@NullMarked -class CaptureTest { - // T extends @Nullable Foo, wildcard is unbounded (?). - // In NullMarked, ? defaults to @Nullable, so F_cap has nullable upper bound -> error. - Foo nullableUnbounded(NullableFooSupplier supplier) { - // :: error: return.type.incompatible - return supplier.get(); - } - - // T extends @Nullable Object, wildcard is ? extends @Nullable Lib. - // ALL upper bounds are nullable (@Nullable Lib, @Nullable Object) -> T_cap is nullable -> error. - Object nullableExplicitBound(NullableBounded x) { - // :: error: return.type.incompatible - return x.get(); - } - - // T extends @Nullable Object, wildcard is ? extends Lib (non-null). - // Lib is non-null -> T_cap has non-null bound -> no error. - Object nonNullExplicitBound(NullableBounded x) { - return x.get(); // should NOT be an error - } - - interface NullableFooSupplier { - F get(); - } - - interface NullableBounded { - T get(); - } - - interface Foo {} - interface Lib {} -} From 6bc8ca2d8357957dde62502931de47725f0dc698 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 14:45:07 +0000 Subject: [PATCH 11/11] Add regression tests for captured wildcard nullness (root cause 2) Agent-Logs-Url: https://github.com/eisop/jspecify-reference-checker/sessions/c2b18ea9-3720-4acb-bcb3-4ec8a61327b0 Co-authored-by: wmdietl <6699136+wmdietl@users.noreply.github.com> --- tests/regression/CaptureTest.java | 53 +++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/regression/CaptureTest.java diff --git a/tests/regression/CaptureTest.java b/tests/regression/CaptureTest.java new file mode 100644 index 0000000..3f182e6 --- /dev/null +++ b/tests/regression/CaptureTest.java @@ -0,0 +1,53 @@ +// Copyright 2025 The JSpecify 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 +// +// http://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. + +// Tests for captured wildcard nullness handling. +// In NullMarked code, a captured wildcard is nullable iff ALL its effective upper bounds are +// nullable. The checker should detect when a nullable captured value is returned as non-null. +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +class CaptureTest { + // T extends @Nullable Foo, wildcard is unbounded (?). + // In NullMarked, ? defaults to @Nullable, so F_cap has nullable upper bound -> error. + Foo nullableUnbounded(NullableFooSupplier supplier) { + // :: error: return.type.incompatible + return supplier.get(); + } + + // T extends @Nullable Object, wildcard is ? extends @Nullable Lib. + // ALL upper bounds are nullable (@Nullable Lib, @Nullable Object) -> T_cap is nullable -> error. + Object nullableExplicitBound(NullableBounded x) { + // :: error: return.type.incompatible + return x.get(); + } + + // T extends @Nullable Object, wildcard is ? extends Lib (non-null). + // Lib is non-null -> T_cap has non-null bound -> no error. + Object nonNullExplicitBound(NullableBounded x) { + return x.get(); // should NOT be an error + } + + interface NullableFooSupplier { + F get(); + } + + interface NullableBounded { + T get(); + } + + interface Foo {} + interface Lib {} +}