From 149a6290a306d719f2f3ed657f20df9083c48e11 Mon Sep 17 00:00:00 2001 From: HarrisL2 Date: Wed, 15 Jul 2026 14:41:31 -0400 Subject: [PATCH 1/5] Support NullMarked and NullUnmarked --- .../dotty/tools/dotc/core/Definitions.scala | 11 ++++++ .../tools/dotc/core/ImplicitNullInterop.scala | 38 +++++++++++++++---- .../neg/nullmarked-separate/J_2.java | 28 ++++++++++++++ .../neg/nullmarked-separate/NullMarked_1.java | 10 +++++ .../nullmarked-separate/NullUnmarked_1.java | 9 +++++ .../neg/nullmarked-separate/Nullable_1.java | 9 +++++ .../neg/nullmarked-separate/S_3.scala | 15 ++++++++ tests/explicit-nulls/neg/nullmarked/J.java | 28 ++++++++++++++ .../neg/nullmarked/NullMarked.java | 9 +++++ .../neg/nullmarked/NullUnmarked.java | 9 +++++ .../neg/nullmarked/Nullable.java | 9 +++++ tests/explicit-nulls/neg/nullmarked/S.scala | 13 +++++++ 12 files changed, 181 insertions(+), 7 deletions(-) create mode 100644 tests/explicit-nulls/neg/nullmarked-separate/J_2.java create mode 100644 tests/explicit-nulls/neg/nullmarked-separate/NullMarked_1.java create mode 100644 tests/explicit-nulls/neg/nullmarked-separate/NullUnmarked_1.java create mode 100644 tests/explicit-nulls/neg/nullmarked-separate/Nullable_1.java create mode 100644 tests/explicit-nulls/neg/nullmarked-separate/S_3.scala create mode 100644 tests/explicit-nulls/neg/nullmarked/J.java create mode 100644 tests/explicit-nulls/neg/nullmarked/NullMarked.java create mode 100644 tests/explicit-nulls/neg/nullmarked/NullUnmarked.java create mode 100644 tests/explicit-nulls/neg/nullmarked/Nullable.java create mode 100644 tests/explicit-nulls/neg/nullmarked/S.scala diff --git a/compiler/src/dotty/tools/dotc/core/Definitions.scala b/compiler/src/dotty/tools/dotc/core/Definitions.scala index 97f7d944bcf5..ffba26b73cc3 100644 --- a/compiler/src/dotty/tools/dotc/core/Definitions.scala +++ b/compiler/src/dotty/tools/dotc/core/Definitions.scala @@ -1225,6 +1225,17 @@ class Definitions { "io.reactivex.rxjava3.annotations.Nullable" :: "org.jspecify.annotations.Nullable" :: Nil) + // Scope-level markers (JSpecify) that flip the implicit-nulls default within their scope: + // inside a `@NullMarked` module/package/class/method, unannotated reference types are non-null, + // and only an explicit `@Nullable` reintroduces nullability. `@NullUnmarked` re-enables the + // implicit-nulls default within an enclosing `@NullMarked` scope. As with the other null + // annotations, we don't require these to be on the class path. + @tu lazy val NullMarkedAnnots: List[ClassSymbol] = getClassesIfDefined( + "org.jspecify.annotations.NullMarked" :: Nil) + + @tu lazy val NullUnmarkedAnnots: List[ClassSymbol] = getClassesIfDefined( + "org.jspecify.annotations.NullUnmarked" :: Nil) + // convenient one-parameter method types def methOfAny(tp: Type): MethodType = MethodType(List(AnyType), tp) def methOfAnyVal(tp: Type): MethodType = MethodType(List(AnyValType), tp) diff --git a/compiler/src/dotty/tools/dotc/core/ImplicitNullInterop.scala b/compiler/src/dotty/tools/dotc/core/ImplicitNullInterop.scala index 7350af348ea0..515bfbcc743c 100644 --- a/compiler/src/dotty/tools/dotc/core/ImplicitNullInterop.scala +++ b/compiler/src/dotty/tools/dotc/core/ImplicitNullInterop.scala @@ -81,11 +81,16 @@ object ImplicitNullInterop: || sym.is(Flags.ModuleVal) then return tp + // In a JSpecify `@NullMarked` scope, unannotated reference types are non-null by default, + // so the ambient mode becomes `Skip` (nothing is nullified unless an explicit annotation + // says otherwise). Otherwise the ambient mode is the usual `Default`. + val ambient = if isNullMarked(sym) then NullMode.Skip else NullMode.Default + val currentTypeMode = // Don't nullify Given/implicit parameters if sym.isOneOf(GivenOrImplicitVal) || hasNotNullAnnot(sym) then NullMode.Skip else if hasNullableAnnot(sym) then NullMode.Explicit - else NullMode.Default + else ambient val resultTypeMode = // Don't nullify result type of constructors @@ -94,9 +99,21 @@ object ImplicitNullInterop: ImplicitNullMap( javaDefined = sym.is(JavaDefined), + ambient = ambient, state = NullMapState(resultTypeMode, currentTypeMode) )(tp) + /** Is `sym` in a JSpecify `@NullMarked` scope? We walk the owner chain (starting at `sym` + * itself) and let the nearest scope marking win: `@NullMarked` enables the non-null default, + * `@NullUnmarked` re-enables the implicit-nulls default. We use `unforcedAnnotation` to avoid + * forcing symbols that may still be under construction during classfile loading / unpickling. + */ + private def isNullMarked(sym: Symbol)(using Context): Boolean = + sym.ownersIterator.collectFirst { + case owner if defn.NullMarkedAnnots.exists(owner.unforcedAnnotation(_).isDefined) => true + case owner if defn.NullUnmarkedAnnots.exists(owner.unforcedAnnotation(_).isDefined) => false + }.getOrElse(false) + private def hasNotNullAnnot(sym: Symbol)(using Context): Boolean = defn.NotNullAnnots.exists(sym.unforcedAnnotation(_).isDefined) @@ -115,10 +132,14 @@ object ImplicitNullInterop: ) object NullMapState: - def skipCurrentIf(cond: Boolean)(using Context): NullMapState = + /** Reset to a nested position: the result type mode becomes the ambient default, and the + * current-level mode is `Skip` when `cond` holds, otherwise the ambient default. The ambient + * default is `Default` normally, or `Skip` inside a `@NullMarked` scope. + */ + def skipCurrentIf(cond: Boolean, ambient: NullMode): NullMapState = NullMapState( - resultTypeMode = NullMode.Default, - currentTypeMode = if cond then NullMode.Skip else NullMode.Default + resultTypeMode = ambient, + currentTypeMode = if cond then NullMode.Skip else ambient ) /** A type map that implements the nullification function on types. Given a Java-sourced type or a type @@ -126,11 +147,14 @@ object ImplicitNullInterop: * right places to make nullability explicit in a conservative way (without forcing incomplete symbols). * * @param javaDefined whether the type is from Java source; we always nullify type param refs from Java + * @param ambient the default mode for unannotated positions: `Default` normally, or `Skip` inside + * a JSpecify `@NullMarked` scope (where unannotated reference types are non-null). * @param state mutable nullification state tracking the current mode for the result type * (`resultTypeMode`) and the current nesting level (`currentTypeMode`). */ private class ImplicitNullMap( val javaDefined: Boolean, + val ambient: NullMode, var state: NullMapState )(using Context) extends TypeMap: @@ -201,7 +225,7 @@ object ImplicitNullInterop: case appTp @ AppliedType(tycon, targs) => val savedState = state // If Java-defined tycon, don't nullify outer level of type args (Java classes are fully nullified) - state = NullMapState.skipCurrentIf(tp.classSymbol.is(JavaDefined)) + state = NullMapState.skipCurrentIf(tp.classSymbol.is(JavaDefined), ambient) val targs2 = targs.map(this) state = savedState @@ -213,11 +237,11 @@ object ImplicitNullInterop: val savedState = state // Don't nullify param types for implicit/using sections - state = NullMapState.skipCurrentIf(mtp.isImplicitMethod) + state = NullMapState.skipCurrentIf(mtp.isImplicitMethod, ambient) val paramInfos2 = mtp.paramInfos.map(this) state = NullMapState( - resultTypeMode = NullMode.Default, + resultTypeMode = ambient, currentTypeMode = savedState.resultTypeMode ) val resType2 = this(mtp.resType) diff --git a/tests/explicit-nulls/neg/nullmarked-separate/J_2.java b/tests/explicit-nulls/neg/nullmarked-separate/J_2.java new file mode 100644 index 000000000000..61a55538d131 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-separate/J_2.java @@ -0,0 +1,28 @@ +package org.jspecify.annotations; + +// Lives in the same package as the (package-private) annotation stubs so it can use them. +// This class is compiled to a classfile first and read back by ClassfileParser. +// The whole class is `@NullMarked`, so unannotated reference types are non-null. +@NullMarked +public class J_2 { + + // Unannotated -> non-null String. + public String get() { + return ""; + } + + // Type-use `@Nullable` still reintroduces nullability -> String | Null. + public @Nullable String getNullable() { + return null; + } + + // Unannotated parameter -> non-null String. + public void set(String s) { + } + + // `@NullUnmarked` restores the implicit-nulls default within this method, + // so the parameter is nullable again. + @NullUnmarked + public void unmarkedSet(String s) { + } +} diff --git a/tests/explicit-nulls/neg/nullmarked-separate/NullMarked_1.java b/tests/explicit-nulls/neg/nullmarked-separate/NullMarked_1.java new file mode 100644 index 000000000000..1e3dc9f480e9 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-separate/NullMarked_1.java @@ -0,0 +1,10 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +// Minimal stub of JSpecify's scope marker. Package-private so it can live in a `_1`-suffixed +// file (javac only enforces the public-class/filename match for public types). +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.PACKAGE}) +@Retention(RetentionPolicy.RUNTIME) +@interface NullMarked { +} diff --git a/tests/explicit-nulls/neg/nullmarked-separate/NullUnmarked_1.java b/tests/explicit-nulls/neg/nullmarked-separate/NullUnmarked_1.java new file mode 100644 index 000000000000..2bd6a4a0a73c --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-separate/NullUnmarked_1.java @@ -0,0 +1,9 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +// Minimal stub of JSpecify's scope marker that re-enables implicit nullability. +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.PACKAGE}) +@Retention(RetentionPolicy.RUNTIME) +@interface NullUnmarked { +} diff --git a/tests/explicit-nulls/neg/nullmarked-separate/Nullable_1.java b/tests/explicit-nulls/neg/nullmarked-separate/Nullable_1.java new file mode 100644 index 000000000000..0326cd8ba01a --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-separate/Nullable_1.java @@ -0,0 +1,9 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +// Minimal stub of JSpecify's type-use nullable marker. +@Target({ElementType.TYPE_USE, ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.LOCAL_VARIABLE}) +@Retention(RetentionPolicy.RUNTIME) +@interface Nullable { +} diff --git a/tests/explicit-nulls/neg/nullmarked-separate/S_3.scala b/tests/explicit-nulls/neg/nullmarked-separate/S_3.scala new file mode 100644 index 000000000000..c215d8c57e4e --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-separate/S_3.scala @@ -0,0 +1,15 @@ +// Test that `@NullMarked` / `@NullUnmarked` are read from class files. + +import org.jspecify.annotations.J_2 + +// `get` result is non-null in a `@NullMarked` class. +def a(j: J_2): String = j.get() + +// Type-use `@Nullable` still makes the result `String | Null`. +def b(j: J_2): String = j.getNullable() // error + +// `set` parameter is non-null, so `null` is rejected. +def c(j: J_2): Unit = j.set(null) // error + +// `@NullUnmarked` restores implicit nullability, so `null` is accepted here. +def d(j: J_2): Unit = j.unmarkedSet(null) diff --git a/tests/explicit-nulls/neg/nullmarked/J.java b/tests/explicit-nulls/neg/nullmarked/J.java new file mode 100644 index 000000000000..d3bbb454a759 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked/J.java @@ -0,0 +1,28 @@ +package nullmarked; + +import org.jspecify.annotations.*; + +// The whole class is `@NullMarked`, so unannotated reference types are non-null. +@NullMarked +public class J { + + // Unannotated -> non-null String. + public String get() { + return ""; + } + + // Type-use `@Nullable` still reintroduces nullability -> String | Null. + public @Nullable String getNullable() { + return null; + } + + // Unannotated parameter -> non-null String. + public void set(String s) { + } + + // `@NullUnmarked` restores the implicit-nulls default within this method, + // so the parameter is nullable again. + @NullUnmarked + public void unmarkedSet(String s) { + } +} diff --git a/tests/explicit-nulls/neg/nullmarked/NullMarked.java b/tests/explicit-nulls/neg/nullmarked/NullMarked.java new file mode 100644 index 000000000000..fc5ad2ea4099 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked/NullMarked.java @@ -0,0 +1,9 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +// Minimal stub of JSpecify's scope marker. +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.PACKAGE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface NullMarked { +} diff --git a/tests/explicit-nulls/neg/nullmarked/NullUnmarked.java b/tests/explicit-nulls/neg/nullmarked/NullUnmarked.java new file mode 100644 index 000000000000..d8da8874febd --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked/NullUnmarked.java @@ -0,0 +1,9 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +// Minimal stub of JSpecify's scope marker that re-enables implicit nullability. +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.PACKAGE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface NullUnmarked { +} diff --git a/tests/explicit-nulls/neg/nullmarked/Nullable.java b/tests/explicit-nulls/neg/nullmarked/Nullable.java new file mode 100644 index 000000000000..118ca520ea74 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked/Nullable.java @@ -0,0 +1,9 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +// Minimal stub of JSpecify's type-use nullable marker. +@Target({ElementType.TYPE_USE, ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.LOCAL_VARIABLE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface Nullable { +} diff --git a/tests/explicit-nulls/neg/nullmarked/S.scala b/tests/explicit-nulls/neg/nullmarked/S.scala new file mode 100644 index 000000000000..e7ea6bdc39e7 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked/S.scala @@ -0,0 +1,13 @@ +import nullmarked.J + +// `get` result is non-null in a `@NullMarked` class. +def a(j: J): String = j.get() + +// Type-use `@Nullable` still makes the result `String | Null`. +def b(j: J): String = j.getNullable() // error + +// `set` parameter is non-null, so `null` is rejected. +def c(j: J): Unit = j.set(null) // error + +// `@NullUnmarked` restores implicit nullability, so `null` is accepted here. +def d(j: J): Unit = j.unmarkedSet(null) From 7a9519fcc499a1db1f1f3a96f3181150dc845665 Mon Sep 17 00:00:00 2001 From: HarrisL2 Date: Wed, 15 Jul 2026 16:12:18 -0400 Subject: [PATCH 2/5] Add package-info processing for java source --- .../tools/dotc/core/ImplicitNullInterop.scala | 29 +++++++++++++++---- .../tools/dotc/parsing/JavaParsers.scala | 14 +++++++++ .../nullmarked-packageinfo-separate/J.java | 26 +++++++++++++++++ .../NullMarked.java | 8 +++++ .../NullUnmarked.java | 8 +++++ .../Nullable.java | 8 +++++ .../nullmarked-packageinfo-separate/S_1.scala | 17 +++++++++++ .../package-info.java | 4 +++ .../neg/nullmarked-packageinfo/J.java | 26 +++++++++++++++++ .../nullmarked-packageinfo/NullMarked.java | 8 +++++ .../nullmarked-packageinfo/NullUnmarked.java | 8 +++++ .../neg/nullmarked-packageinfo/Nullable.java | 8 +++++ .../neg/nullmarked-packageinfo/S.scala | 13 +++++++++ .../nullmarked-packageinfo/package-info.java | 4 +++ 14 files changed, 175 insertions(+), 6 deletions(-) create mode 100644 tests/explicit-nulls/neg/nullmarked-packageinfo-separate/J.java create mode 100644 tests/explicit-nulls/neg/nullmarked-packageinfo-separate/NullMarked.java create mode 100644 tests/explicit-nulls/neg/nullmarked-packageinfo-separate/NullUnmarked.java create mode 100644 tests/explicit-nulls/neg/nullmarked-packageinfo-separate/Nullable.java create mode 100644 tests/explicit-nulls/neg/nullmarked-packageinfo-separate/S_1.scala create mode 100644 tests/explicit-nulls/neg/nullmarked-packageinfo-separate/package-info.java create mode 100644 tests/explicit-nulls/neg/nullmarked-packageinfo/J.java create mode 100644 tests/explicit-nulls/neg/nullmarked-packageinfo/NullMarked.java create mode 100644 tests/explicit-nulls/neg/nullmarked-packageinfo/NullUnmarked.java create mode 100644 tests/explicit-nulls/neg/nullmarked-packageinfo/Nullable.java create mode 100644 tests/explicit-nulls/neg/nullmarked-packageinfo/S.scala create mode 100644 tests/explicit-nulls/neg/nullmarked-packageinfo/package-info.java diff --git a/compiler/src/dotty/tools/dotc/core/ImplicitNullInterop.scala b/compiler/src/dotty/tools/dotc/core/ImplicitNullInterop.scala index 515bfbcc743c..2c4142f0aadd 100644 --- a/compiler/src/dotty/tools/dotc/core/ImplicitNullInterop.scala +++ b/compiler/src/dotty/tools/dotc/core/ImplicitNullInterop.scala @@ -4,6 +4,7 @@ package core import Annotations.Annotation import Contexts.* import Flags.* +import Names.typeName import StdNames.nme import Symbols.* import Types.* @@ -105,14 +106,30 @@ object ImplicitNullInterop: /** Is `sym` in a JSpecify `@NullMarked` scope? We walk the owner chain (starting at `sym` * itself) and let the nearest scope marking win: `@NullMarked` enables the non-null default, - * `@NullUnmarked` re-enables the implicit-nulls default. We use `unforcedAnnotation` to avoid - * forcing symbols that may still be under construction during classfile loading / unpickling. + * `@NullUnmarked` re-enables the implicit-nulls default. */ private def isNullMarked(sym: Symbol)(using Context): Boolean = - sym.ownersIterator.collectFirst { - case owner if defn.NullMarkedAnnots.exists(owner.unforcedAnnotation(_).isDefined) => true - case owner if defn.NullUnmarkedAnnots.exists(owner.unforcedAnnotation(_).isDefined) => false - }.getOrElse(false) + sym.ownersIterator.map(ownerNullMarking).collectFirst { case Some(marked) => marked }.getOrElse(false) + + /** The scope marking declared directly on `owner`, if any: `Some(true)` for `@NullMarked`, + * `Some(false)` for `@NullUnmarked`, `None` if `owner` declares neither. + * + * For packages the marker is placed on `package-info` (from `package-info.java` / + * `package-info.class`), which is loaded as a synthetic `package-info` member of the package. + * We force that member to read its annotations (safe: it only depends on the annotation + * classes). For all other owners we use `unforcedAnnotation` to avoid forcing symbols that may + * still be under construction during classfile loading / unpickling. + */ + private def ownerNullMarking(owner: Symbol)(using Context): Option[Boolean] = + if owner.is(Package) then + val packageInfo = owner.info.decl(typeName("package-info")).symbol + if !packageInfo.exists then None + else if defn.NullMarkedAnnots.exists(packageInfo.hasAnnotation(_)) then Some(true) + else if defn.NullUnmarkedAnnots.exists(packageInfo.hasAnnotation(_)) then Some(false) + else None + else if defn.NullMarkedAnnots.exists(owner.unforcedAnnotation(_).isDefined) then Some(true) + else if defn.NullUnmarkedAnnots.exists(owner.unforcedAnnotation(_).isDefined) then Some(false) + else None private def hasNotNullAnnot(sym: Symbol)(using Context): Boolean = defn.NotNullAnnots.exists(sym.unforcedAnnotation(_).isDefined) diff --git a/compiler/src/dotty/tools/dotc/parsing/JavaParsers.scala b/compiler/src/dotty/tools/dotc/parsing/JavaParsers.scala index 67b9f25de98a..71710bf1f887 100644 --- a/compiler/src/dotty/tools/dotc/parsing/JavaParsers.scala +++ b/compiler/src/dotty/tools/dotc/parsing/JavaParsers.scala @@ -1110,10 +1110,13 @@ object JavaParsers { val buf = ListBuffer.empty[Tree] var start = in.offset val leadingAnnots = if (in.token == AT) annotations() else Nil + // Annotations placed before a `package` declaration (only legal in `package-info.java`). + var packageAnnots: List[Tree] = Nil val pkg: RefTree = if in.token == PACKAGE then if leadingAnnots.nonEmpty then start = in.offset + packageAnnots = leadingAnnots accept(PACKAGE) val pkg = qualId() accept(SEMI) @@ -1136,6 +1139,17 @@ object JavaParsers { if buf.isEmpty then while (in.token == IMPORT) buf ++= importDecl() + // Retain package-level annotations (e.g. JSpecify `@NullMarked`) by attaching them to a + // synthetic `package-info` class, mirroring how `package-info.class` represents them. This + // lets downstream logic (nullification) read them via the package's `package-info` member. + // It is added after imports so the annotation names resolve against them. + if packageAnnots.nonEmpty then + buf += atSpan(start) { + TypeDef( + typeName("package-info"), + makeTemplate(List(ObjectTpt()), Nil, Nil, needsDummyConstr = true) + ).withMods(Modifiers(Flags.JavaDefined).withAnnotations(packageAnnots)) + } while (in.token != EOF && in.token != RBRACE) { while (in.token == SEMI) in.nextToken() if (in.token != EOF) { diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/J.java b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/J.java new file mode 100644 index 000000000000..1fe11746d864 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/J.java @@ -0,0 +1,26 @@ +package nmpkg; + +import org.jspecify.annotations.*; + +// No class-level annotation: nullability is decided by the package's `@NullMarked` (package-info). +public class J { + + // Unannotated -> non-null String (package is null-marked). + public String get() { + return ""; + } + + // Type-use `@Nullable` still reintroduces nullability -> String | Null. + public @Nullable String getNullable() { + return null; + } + + // Unannotated parameter -> non-null String. + public void set(String s) { + } + + // `@NullUnmarked` overrides the package marking for this method. + @NullUnmarked + public void unmarkedSet(String s) { + } +} diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/NullMarked.java b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/NullMarked.java new file mode 100644 index 000000000000..94c512851f43 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/NullMarked.java @@ -0,0 +1,8 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.PACKAGE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface NullMarked { +} diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/NullUnmarked.java b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/NullUnmarked.java new file mode 100644 index 000000000000..d792e8c8c1f3 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/NullUnmarked.java @@ -0,0 +1,8 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.PACKAGE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface NullUnmarked { +} diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/Nullable.java b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/Nullable.java new file mode 100644 index 000000000000..b5624d982f0d --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/Nullable.java @@ -0,0 +1,8 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE_USE, ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.LOCAL_VARIABLE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface Nullable { +} diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/S_1.scala b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/S_1.scala new file mode 100644 index 000000000000..8596950238d8 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/S_1.scala @@ -0,0 +1,17 @@ +// Test that a package-level `@NullMarked` (from `package-info.class`) is read from class files. +// The Java sources above are compiled first (round 0); this file is compiled separately and reads +// `nmpkg.J` through ClassfileParser. + +import nmpkg.J + +// `get` result is non-null because the package is `@NullMarked`. +def a(j: J): String = j.get() + +// Type-use `@Nullable` still makes the result `String | Null`. +def b(j: J): String = j.getNullable() // error + +// `set` parameter is non-null, so `null` is rejected. +def c(j: J): Unit = j.set(null) // error + +// `@NullUnmarked` on the method overrides the package marking, so `null` is accepted here. +def d(j: J): Unit = j.unmarkedSet(null) diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/package-info.java b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/package-info.java new file mode 100644 index 000000000000..fd866c77aa9b --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/package-info.java @@ -0,0 +1,4 @@ +// The whole package is `@NullMarked` via `package-info.java`, so unannotated reference types +// in this package are non-null. JavaParsers must read this package-level annotation. +@org.jspecify.annotations.NullMarked +package nmpkg; diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo/J.java b/tests/explicit-nulls/neg/nullmarked-packageinfo/J.java new file mode 100644 index 000000000000..1fe11746d864 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo/J.java @@ -0,0 +1,26 @@ +package nmpkg; + +import org.jspecify.annotations.*; + +// No class-level annotation: nullability is decided by the package's `@NullMarked` (package-info). +public class J { + + // Unannotated -> non-null String (package is null-marked). + public String get() { + return ""; + } + + // Type-use `@Nullable` still reintroduces nullability -> String | Null. + public @Nullable String getNullable() { + return null; + } + + // Unannotated parameter -> non-null String. + public void set(String s) { + } + + // `@NullUnmarked` overrides the package marking for this method. + @NullUnmarked + public void unmarkedSet(String s) { + } +} diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo/NullMarked.java b/tests/explicit-nulls/neg/nullmarked-packageinfo/NullMarked.java new file mode 100644 index 000000000000..94c512851f43 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo/NullMarked.java @@ -0,0 +1,8 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.PACKAGE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface NullMarked { +} diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo/NullUnmarked.java b/tests/explicit-nulls/neg/nullmarked-packageinfo/NullUnmarked.java new file mode 100644 index 000000000000..d792e8c8c1f3 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo/NullUnmarked.java @@ -0,0 +1,8 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.PACKAGE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface NullUnmarked { +} diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo/Nullable.java b/tests/explicit-nulls/neg/nullmarked-packageinfo/Nullable.java new file mode 100644 index 000000000000..b5624d982f0d --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo/Nullable.java @@ -0,0 +1,8 @@ +package org.jspecify.annotations; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE_USE, ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.LOCAL_VARIABLE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface Nullable { +} diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo/S.scala b/tests/explicit-nulls/neg/nullmarked-packageinfo/S.scala new file mode 100644 index 000000000000..48ea350a1801 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo/S.scala @@ -0,0 +1,13 @@ +import nmpkg.J + +// `get` result is non-null because the package is `@NullMarked`. +def a(j: J): String = j.get() + +// Type-use `@Nullable` still makes the result `String | Null`. +def b(j: J): String = j.getNullable() // error + +// `set` parameter is non-null, so `null` is rejected. +def c(j: J): Unit = j.set(null) // error + +// `@NullUnmarked` on the method overrides the package marking, so `null` is accepted here. +def d(j: J): Unit = j.unmarkedSet(null) diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo/package-info.java b/tests/explicit-nulls/neg/nullmarked-packageinfo/package-info.java new file mode 100644 index 000000000000..fd866c77aa9b --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo/package-info.java @@ -0,0 +1,4 @@ +// The whole package is `@NullMarked` via `package-info.java`, so unannotated reference types +// in this package are non-null. JavaParsers must read this package-level annotation. +@org.jspecify.annotations.NullMarked +package nmpkg; From 834ecd8ec6eb58e10c7c4edda5c112cc11fa59b9 Mon Sep 17 00:00:00 2001 From: HarrisL2 Date: Wed, 15 Jul 2026 17:20:08 -0400 Subject: [PATCH 3/5] Don't use flexible types in tests --- .../neg/nullmarked-packageinfo-separate/S_1.scala | 1 + tests/explicit-nulls/neg/nullmarked-packageinfo/S.scala | 2 ++ tests/explicit-nulls/neg/nullmarked-separate/S_3.scala | 1 + tests/explicit-nulls/neg/nullmarked/S.scala | 2 ++ 4 files changed, 6 insertions(+) diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/S_1.scala b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/S_1.scala index 8596950238d8..608fe3711fcc 100644 --- a/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/S_1.scala +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/S_1.scala @@ -1,6 +1,7 @@ // Test that a package-level `@NullMarked` (from `package-info.class`) is read from class files. // The Java sources above are compiled first (round 0); this file is compiled separately and reads // `nmpkg.J` through ClassfileParser. +//> using options -Yno-flexible-types import nmpkg.J diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo/S.scala b/tests/explicit-nulls/neg/nullmarked-packageinfo/S.scala index 48ea350a1801..3d97119210fe 100644 --- a/tests/explicit-nulls/neg/nullmarked-packageinfo/S.scala +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo/S.scala @@ -1,3 +1,5 @@ +//> using options -Yno-flexible-types + import nmpkg.J // `get` result is non-null because the package is `@NullMarked`. diff --git a/tests/explicit-nulls/neg/nullmarked-separate/S_3.scala b/tests/explicit-nulls/neg/nullmarked-separate/S_3.scala index c215d8c57e4e..ce7f80ceb757 100644 --- a/tests/explicit-nulls/neg/nullmarked-separate/S_3.scala +++ b/tests/explicit-nulls/neg/nullmarked-separate/S_3.scala @@ -1,4 +1,5 @@ // Test that `@NullMarked` / `@NullUnmarked` are read from class files. +//> using options -Yno-flexible-types import org.jspecify.annotations.J_2 diff --git a/tests/explicit-nulls/neg/nullmarked/S.scala b/tests/explicit-nulls/neg/nullmarked/S.scala index e7ea6bdc39e7..35094b8604ea 100644 --- a/tests/explicit-nulls/neg/nullmarked/S.scala +++ b/tests/explicit-nulls/neg/nullmarked/S.scala @@ -1,3 +1,5 @@ +//> using options -Yno-flexible-types + import nullmarked.J // `get` result is non-null in a `@NullMarked` class. From 1b1e7f93535e25787618f2c7ff054e68fd224405 Mon Sep 17 00:00:00 2001 From: HarrisL2 Date: Wed, 15 Jul 2026 17:50:19 -0400 Subject: [PATCH 4/5] Fix double annotation edge case and Improve tests Co-authored-by: TheDrawingCoder-Gamer --- .../tools/dotc/core/ImplicitNullInterop.scala | 26 +++++++++------ .../nullmarked-packageinfo-separate/S_1.scala | 14 ++++++++ .../nullmarked-packageinfo-separate/U.java | 32 ++++++++++++++++++ .../package-info.java | 4 ++- .../neg/nullmarked-packageinfo/S.scala | 14 ++++++++ .../neg/nullmarked-packageinfo/U.java | 32 ++++++++++++++++++ .../nullmarked-packageinfo/package-info.java | 4 ++- .../neg/nullmarked-separate/J_2.java | 33 +++++++++++++++++++ .../neg/nullmarked-separate/S_3.scala | 16 +++++++++ tests/explicit-nulls/neg/nullmarked/J.java | 33 +++++++++++++++++++ tests/explicit-nulls/neg/nullmarked/S.scala | 16 +++++++++ 11 files changed, 212 insertions(+), 12 deletions(-) create mode 100644 tests/explicit-nulls/neg/nullmarked-packageinfo-separate/U.java create mode 100644 tests/explicit-nulls/neg/nullmarked-packageinfo/U.java diff --git a/compiler/src/dotty/tools/dotc/core/ImplicitNullInterop.scala b/compiler/src/dotty/tools/dotc/core/ImplicitNullInterop.scala index 2c4142f0aadd..ca8d3c118a0e 100644 --- a/compiler/src/dotty/tools/dotc/core/ImplicitNullInterop.scala +++ b/compiler/src/dotty/tools/dotc/core/ImplicitNullInterop.scala @@ -112,7 +112,9 @@ object ImplicitNullInterop: sym.ownersIterator.map(ownerNullMarking).collectFirst { case Some(marked) => marked }.getOrElse(false) /** The scope marking declared directly on `owner`, if any: `Some(true)` for `@NullMarked`, - * `Some(false)` for `@NullUnmarked`, `None` if `owner` declares neither. + * `Some(false)` for `@NullUnmarked`, `None` if `owner` declares neither. Per JSpecify, a + * declaration carrying *both* markers behaves as if it carried neither, so we return `None` and + * let an enclosing scope decide. * * For packages the marker is placed on `package-info` (from `package-info.java` / * `package-info.class`), which is loaded as a synthetic `package-info` member of the package. @@ -121,15 +123,19 @@ object ImplicitNullInterop: * still be under construction during classfile loading / unpickling. */ private def ownerNullMarking(owner: Symbol)(using Context): Option[Boolean] = - if owner.is(Package) then - val packageInfo = owner.info.decl(typeName("package-info")).symbol - if !packageInfo.exists then None - else if defn.NullMarkedAnnots.exists(packageInfo.hasAnnotation(_)) then Some(true) - else if defn.NullUnmarkedAnnots.exists(packageInfo.hasAnnotation(_)) then Some(false) - else None - else if defn.NullMarkedAnnots.exists(owner.unforcedAnnotation(_).isDefined) then Some(true) - else if defn.NullUnmarkedAnnots.exists(owner.unforcedAnnotation(_).isDefined) then Some(false) - else None + val (carrier, forced) = + // For packages the marker lives on the synthetic `package-info` member, which we force. + if owner.is(Package) then (owner.info.decl(typeName("package-info")).symbol, true) + else (owner, false) + if !carrier.exists then None + else + def has(annots: List[ClassSymbol]): Boolean = + if forced then annots.exists(carrier.hasAnnotation(_)) + else annots.exists(carrier.unforcedAnnotation(_).isDefined) + val marked = has(defn.NullMarkedAnnots) + val unmarked = has(defn.NullUnmarkedAnnots) + if marked == unmarked then None // both or neither present: behave as if neither + else Some(marked) private def hasNotNullAnnot(sym: Symbol)(using Context): Boolean = defn.NotNullAnnots.exists(sym.unforcedAnnotation(_).isDefined) diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/S_1.scala b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/S_1.scala index 608fe3711fcc..b5921d953ba4 100644 --- a/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/S_1.scala +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/S_1.scala @@ -4,6 +4,7 @@ //> using options -Yno-flexible-types import nmpkg.J +import nmpkg.U // `get` result is non-null because the package is `@NullMarked`. def a(j: J): String = j.get() @@ -16,3 +17,16 @@ def c(j: J): Unit = j.set(null) // error // `@NullUnmarked` on the method overrides the package marking, so `null` is accepted here. def d(j: J): Unit = j.unmarkedSet(null) + +// `@NullUnmarked` on class `U` overrides the package `@NullMarked`: `get` is `String | Null`. +def u1(u: U): String = u.get() // error + +// `@NullUnmarked` class: `set` parameter is nullable, so `null` is accepted. +def u2(u: U): Unit = u.set(null) + +// `@NullMarked` re-marks the method inside the unmarked class, so its result is non-null. +def u3(u: U): String = u.markedGet() + +// Both markers behave as if neither: `bothGet` inherits `U`'s `@NullUnmarked`, so its result is +// `String | Null` (it does not become non-null from `@NullMarked`). +def u4(u: U): String = u.bothGet() // error diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/U.java b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/U.java new file mode 100644 index 000000000000..ef0ca64ff427 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/U.java @@ -0,0 +1,32 @@ +package nmpkg; + +import org.jspecify.annotations.*; + +// `@NullUnmarked` on a top-level class overrides the package's `@NullMarked`, +// restoring the implicit-nulls default for its members. +@NullUnmarked +public class U { + + // Unmarked -> String | Null. + public String get() { + return ""; + } + + // Unmarked parameter -> nullable. + public void set(String s) { + } + + // `@NullMarked` re-marks this method, so its result is non-null again. + @NullMarked + public String markedGet() { + return ""; + } + + // Both markers behave as if neither is present, so this inherits `U`'s `@NullUnmarked` + // (its result is `String | Null`), rather than letting `@NullMarked` win. + @NullMarked + @NullUnmarked + public String bothGet() { + return ""; + } +} diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/package-info.java b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/package-info.java index fd866c77aa9b..9408d05cee65 100644 --- a/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/package-info.java +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo-separate/package-info.java @@ -1,4 +1,6 @@ // The whole package is `@NullMarked` via `package-info.java`, so unannotated reference types // in this package are non-null. JavaParsers must read this package-level annotation. -@org.jspecify.annotations.NullMarked +@NullMarked package nmpkg; + +import org.jspecify.annotations.*; \ No newline at end of file diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo/S.scala b/tests/explicit-nulls/neg/nullmarked-packageinfo/S.scala index 3d97119210fe..31ad924f0bc7 100644 --- a/tests/explicit-nulls/neg/nullmarked-packageinfo/S.scala +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo/S.scala @@ -1,6 +1,7 @@ //> using options -Yno-flexible-types import nmpkg.J +import nmpkg.U // `get` result is non-null because the package is `@NullMarked`. def a(j: J): String = j.get() @@ -13,3 +14,16 @@ def c(j: J): Unit = j.set(null) // error // `@NullUnmarked` on the method overrides the package marking, so `null` is accepted here. def d(j: J): Unit = j.unmarkedSet(null) + +// `@NullUnmarked` on class `U` overrides the package `@NullMarked`: `get` is `String | Null`. +def u1(u: U): String = u.get() // error + +// `@NullUnmarked` class: `set` parameter is nullable, so `null` is accepted. +def u2(u: U): Unit = u.set(null) + +// `@NullMarked` re-marks the method inside the unmarked class, so its result is non-null. +def u3(u: U): String = u.markedGet() + +// Both markers behave as if neither: `bothGet` inherits `U`'s `@NullUnmarked`, so its result is +// `String | Null` (it does not become non-null from `@NullMarked`). +def u4(u: U): String = u.bothGet() // error diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo/U.java b/tests/explicit-nulls/neg/nullmarked-packageinfo/U.java new file mode 100644 index 000000000000..ef0ca64ff427 --- /dev/null +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo/U.java @@ -0,0 +1,32 @@ +package nmpkg; + +import org.jspecify.annotations.*; + +// `@NullUnmarked` on a top-level class overrides the package's `@NullMarked`, +// restoring the implicit-nulls default for its members. +@NullUnmarked +public class U { + + // Unmarked -> String | Null. + public String get() { + return ""; + } + + // Unmarked parameter -> nullable. + public void set(String s) { + } + + // `@NullMarked` re-marks this method, so its result is non-null again. + @NullMarked + public String markedGet() { + return ""; + } + + // Both markers behave as if neither is present, so this inherits `U`'s `@NullUnmarked` + // (its result is `String | Null`), rather than letting `@NullMarked` win. + @NullMarked + @NullUnmarked + public String bothGet() { + return ""; + } +} diff --git a/tests/explicit-nulls/neg/nullmarked-packageinfo/package-info.java b/tests/explicit-nulls/neg/nullmarked-packageinfo/package-info.java index fd866c77aa9b..9408d05cee65 100644 --- a/tests/explicit-nulls/neg/nullmarked-packageinfo/package-info.java +++ b/tests/explicit-nulls/neg/nullmarked-packageinfo/package-info.java @@ -1,4 +1,6 @@ // The whole package is `@NullMarked` via `package-info.java`, so unannotated reference types // in this package are non-null. JavaParsers must read this package-level annotation. -@org.jspecify.annotations.NullMarked +@NullMarked package nmpkg; + +import org.jspecify.annotations.*; \ No newline at end of file diff --git a/tests/explicit-nulls/neg/nullmarked-separate/J_2.java b/tests/explicit-nulls/neg/nullmarked-separate/J_2.java index 61a55538d131..341d441afde6 100644 --- a/tests/explicit-nulls/neg/nullmarked-separate/J_2.java +++ b/tests/explicit-nulls/neg/nullmarked-separate/J_2.java @@ -6,6 +6,11 @@ @NullMarked public class J_2 { + // `@NullUnmarked` on a constructor makes its parameters nullable again. + @NullUnmarked + public J_2(String s) { + } + // Unannotated -> non-null String. public String get() { return ""; @@ -25,4 +30,32 @@ public void set(String s) { @NullUnmarked public void unmarkedSet(String s) { } + + // Annotated with both markers -> behaves as if neither is present, so this method inherits + // the class's `@NullMarked` and its parameter stays non-null. + @NullMarked + @NullUnmarked + public void bothSet(String s) { + } + + // `@NullUnmarked` on a nested class restores the implicit-nulls default for its members. + @NullUnmarked + public static class Inner { + + // Unmarked -> String | Null. + public String innerGet() { + return ""; + } + + // Unmarked parameter -> nullable. + public void innerSet(String s) { + } + + // `@NullMarked` re-marks within the unmarked nested class (nearest scope wins), + // so this result is non-null again. + @NullMarked + public String remarkedGet() { + return ""; + } + } } diff --git a/tests/explicit-nulls/neg/nullmarked-separate/S_3.scala b/tests/explicit-nulls/neg/nullmarked-separate/S_3.scala index ce7f80ceb757..68af85113d5b 100644 --- a/tests/explicit-nulls/neg/nullmarked-separate/S_3.scala +++ b/tests/explicit-nulls/neg/nullmarked-separate/S_3.scala @@ -14,3 +14,19 @@ def c(j: J_2): Unit = j.set(null) // error // `@NullUnmarked` restores implicit nullability, so `null` is accepted here. def d(j: J_2): Unit = j.unmarkedSet(null) + +// Both markers on `bothSet` behave as if neither: it inherits the class `@NullMarked`, so the +// parameter is non-null and `null` is rejected. +def dd(j: J_2): Unit = j.bothSet(null) // error + +// `@NullUnmarked` constructor: the parameter is nullable, so `null` is accepted. +def e: J_2 = new J_2(null) + +// `@NullUnmarked` nested class: `innerGet` result is `String | Null` again. +def f(i: J_2.Inner): String = i.innerGet() // error + +// `@NullUnmarked` nested class: `innerSet` parameter is nullable, so `null` is accepted. +def g(i: J_2.Inner): Unit = i.innerSet(null) + +// `@NullMarked` re-marks inside the unmarked nested class, so this result is non-null. +def h(i: J_2.Inner): String = i.remarkedGet() diff --git a/tests/explicit-nulls/neg/nullmarked/J.java b/tests/explicit-nulls/neg/nullmarked/J.java index d3bbb454a759..f145db9be3ab 100644 --- a/tests/explicit-nulls/neg/nullmarked/J.java +++ b/tests/explicit-nulls/neg/nullmarked/J.java @@ -6,6 +6,11 @@ @NullMarked public class J { + // `@NullUnmarked` on a constructor makes its parameters nullable again. + @NullUnmarked + public J(String s) { + } + // Unannotated -> non-null String. public String get() { return ""; @@ -25,4 +30,32 @@ public void set(String s) { @NullUnmarked public void unmarkedSet(String s) { } + + // Annotated with both markers -> behaves as if neither is present, so this method inherits + // the class's `@NullMarked` and its parameter stays non-null. + @NullMarked + @NullUnmarked + public void bothSet(String s) { + } + + // `@NullUnmarked` on a nested class restores the implicit-nulls default for its members. + @NullUnmarked + public static class Inner { + + // Unmarked -> String | Null. + public String innerGet() { + return ""; + } + + // Unmarked parameter -> nullable. + public void innerSet(String s) { + } + + // `@NullMarked` re-marks within the unmarked nested class (nearest scope wins), + // so this result is non-null again. + @NullMarked + public String remarkedGet() { + return ""; + } + } } diff --git a/tests/explicit-nulls/neg/nullmarked/S.scala b/tests/explicit-nulls/neg/nullmarked/S.scala index 35094b8604ea..a0dfb4c910a8 100644 --- a/tests/explicit-nulls/neg/nullmarked/S.scala +++ b/tests/explicit-nulls/neg/nullmarked/S.scala @@ -13,3 +13,19 @@ def c(j: J): Unit = j.set(null) // error // `@NullUnmarked` restores implicit nullability, so `null` is accepted here. def d(j: J): Unit = j.unmarkedSet(null) + +// Both markers on `bothSet` behave as if neither: it inherits the class `@NullMarked`, so the +// parameter is non-null and `null` is rejected. +def dd(j: J): Unit = j.bothSet(null) // error + +// `@NullUnmarked` constructor: the parameter is nullable, so `null` is accepted. +def e: J = new J(null) + +// `@NullUnmarked` nested class: `innerGet` result is `String | Null` again. +def f(i: J.Inner): String = i.innerGet() // error + +// `@NullUnmarked` nested class: `innerSet` parameter is nullable, so `null` is accepted. +def g(i: J.Inner): Unit = i.innerSet(null) + +// `@NullMarked` re-marks inside the unmarked nested class, so this result is non-null. +def h(i: J.Inner): String = i.remarkedGet() From 3836a693d1d367688e1d233df028c63aa9d7b4fd Mon Sep 17 00:00:00 2001 From: HarrisL2 Date: Thu, 16 Jul 2026 19:22:34 -0400 Subject: [PATCH 5/5] Factor out package-info --- compiler/src/dotty/tools/dotc/core/Definitions.scala | 7 +++++++ .../src/dotty/tools/dotc/core/ImplicitNullInterop.scala | 3 +-- compiler/src/dotty/tools/dotc/parsing/JavaParsers.scala | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/compiler/src/dotty/tools/dotc/core/Definitions.scala b/compiler/src/dotty/tools/dotc/core/Definitions.scala index ffba26b73cc3..c47caa380589 100644 --- a/compiler/src/dotty/tools/dotc/core/Definitions.scala +++ b/compiler/src/dotty/tools/dotc/core/Definitions.scala @@ -1236,6 +1236,13 @@ class Definitions { @tu lazy val NullUnmarkedAnnots: List[ClassSymbol] = getClassesIfDefined( "org.jspecify.annotations.NullUnmarked" :: Nil) + /** The name of the synthetic member that carries a package's annotations, loaded from + * `package-info.java` / `package-info.class` (`package-info` is not a legal Java identifier, + * so it never collides with a real member). Used by the explicit-nulls interop to read a + * package-level `@NullMarked` / `@NullUnmarked`. + */ + val PackageInfoName: TypeName = typeName("package-info") + // convenient one-parameter method types def methOfAny(tp: Type): MethodType = MethodType(List(AnyType), tp) def methOfAnyVal(tp: Type): MethodType = MethodType(List(AnyValType), tp) diff --git a/compiler/src/dotty/tools/dotc/core/ImplicitNullInterop.scala b/compiler/src/dotty/tools/dotc/core/ImplicitNullInterop.scala index ca8d3c118a0e..2f9d928e7142 100644 --- a/compiler/src/dotty/tools/dotc/core/ImplicitNullInterop.scala +++ b/compiler/src/dotty/tools/dotc/core/ImplicitNullInterop.scala @@ -4,7 +4,6 @@ package core import Annotations.Annotation import Contexts.* import Flags.* -import Names.typeName import StdNames.nme import Symbols.* import Types.* @@ -125,7 +124,7 @@ object ImplicitNullInterop: private def ownerNullMarking(owner: Symbol)(using Context): Option[Boolean] = val (carrier, forced) = // For packages the marker lives on the synthetic `package-info` member, which we force. - if owner.is(Package) then (owner.info.decl(typeName("package-info")).symbol, true) + if owner.is(Package) then (owner.info.decl(defn.PackageInfoName).symbol, true) else (owner, false) if !carrier.exists then None else diff --git a/compiler/src/dotty/tools/dotc/parsing/JavaParsers.scala b/compiler/src/dotty/tools/dotc/parsing/JavaParsers.scala index 71710bf1f887..2ba1ffe8cab3 100644 --- a/compiler/src/dotty/tools/dotc/parsing/JavaParsers.scala +++ b/compiler/src/dotty/tools/dotc/parsing/JavaParsers.scala @@ -1146,7 +1146,7 @@ object JavaParsers { if packageAnnots.nonEmpty then buf += atSpan(start) { TypeDef( - typeName("package-info"), + defn.PackageInfoName, makeTemplate(List(ObjectTpt()), Nil, Nil, needsDummyConstr = true) ).withMods(Modifiers(Flags.JavaDefined).withAnnotations(packageAnnots)) }