Skip to content

[Xamarin.Android.Tools.Bytecode] Support JSpecify nullness annotations#1483

Open
jonathanpeppers wants to merge 1 commit into
mainfrom
jonathanpeppers-jspecify-nullness
Open

[Xamarin.Android.Tools.Bytecode] Support JSpecify nullness annotations#1483
jonathanpeppers wants to merge 1 commit into
mainfrom
jonathanpeppers-jspecify-nullness

Conversation

@jonathanpeppers

Copy link
Copy Markdown
Member

Fixes #1335.

Why

JSpecify is becoming the standard way Java libraries (AndroidX, Guava, etc.) declare nullness. Unlike the older @NonNull/@Nullable declaration annotations we already recognize, JSpecify uses TYPE_USE annotations plus a scope-default model (@NullMarked on a package or class means "every reference here is non-null unless marked @Nullable"). The bytecode parser was missing two pieces required to consume any of this: it never read JSR 308 type annotations, and it had no notion of a scope default. As a result, generated bindings for JSpecify-annotated Java libraries were emitting reference types without not-null="true", which downstream becomes nullable C# references.

Approach

This change adds the minimum useful slice so that most APIs in a JSpecify-annotated library get correct C# nullable reference type annotations:

  • JSR 308 type-annotation parsing. New TypeAnnotation type and RuntimeVisible/InvisibleTypeAnnotationsAttribute classes that fully parse the target_info discriminated union (every variant is read past, even ones we don't act on yet, so the stream stays aligned) and skip past type_path while remembering its length.
  • Scope detection. ClassPath now retains package-info.class files and exposes GetPackageInfo(packageName). XmlClassDeclarationBuilder computes isNullMarked from the class's own @NullMarked/@NullUnmarked plus the package-info fallback, reading from both RuntimeVisibleAnnotations and RuntimeInvisibleAnnotations (JSpecify scope annotations are @Retention(RUNTIME)).
  • Nullness resolution. New GetMethodReturnNullness/GetParameterNullness/GetFieldNullness helpers combine declaration-level @NonNull, top-level TYPE_USE @Nullable/@NonNull annotations, and the scope default. Reference-typed slots in a null-marked scope default to not-null="true"; explicit @Nullable opts back out.

Trade-offs and deliberately deferred

To keep the change small enough to land, the following are out of scope and tracked as known limitations (one test, PackageMarked_NullUnmarkedMethod_RevertsToUnknown, pins down the current behavior so it's easy to find when extending):

  • Method-level @NullUnmarked is not honored (only class and package scope).
  • Module-level @NullMarked is not honored.
  • Inner-class scope inheritance from the enclosing class is not implemented.
  • Type-path-aware nullness (e.g. Map<@Nullable K, V> for inner generic type arguments) is intentionally only respected at the top of the type. The parser reads type_path correctly so @Nullable String[] vs String @Nullable[] are distinguished by AppliesToTopLevelType (type_path_length == 0), which is the case that matters for surface API nullness.

Tests

10 new tests in JSpecifyAnnotationTests.cs covering package-level @NullMarked, class-level @NullMarked, primitive exclusion, @Nullable opt-out at each slot kind, control unmarked classes, and direct verification that the new type-annotation attribute parses. All 92 Xamarin.Android.Tools.Bytecode-Tests pass.

Context: #1335

AndroidX `core` 1.16 switched from `androidx.annotation.NonNull` to
JSpecify nullness annotations (`@NullMarked`, `@NullUnmarked`,
`@Nullable`, `@NonNull`).  Two structural mismatches kept the existing
class-parse nullness pipeline from picking these up:

  1. JSpecify is scope-based: `@NullMarked` on a class or `package-info`
     means "every reference type in this scope is non-null unless
     annotated `@Nullable`".  class-parse previously only honored
     per-member declaration annotations.

  2. JSpecify `@Nullable` and `@NonNull` are `@Target(TYPE_USE)`, so
     they live in `RuntimeInvisibleTypeAnnotations` (JSR 308) which
     class-parse did not parse.

This commit adds the minimum-useful subset that correctly annotates the
bulk of `@NullMarked` APIs:

* Parse `RuntimeVisible/InvisibleTypeAnnotations`. The new parser
  understands every JSR 308 `target_info` variant well enough to read
  past it, but only `Field`, `MethodReturn`, and `MethodFormalParameter`
  feed into nullness.  Annotations with a non-empty `type_path` are
  ignored (they describe inner types like `Map<@nullable K, V>` which
  the API XML schema cannot represent).
* Also parse `RuntimeVisibleParameterAnnotations` for completeness.
* `ClassFile.IsPackageInfo` plus `ClassPath.GetPackageInfo()` keep
  `package-info.class` available without emitting it as a regular class
  in the API XML.
* `XmlClassDeclarationBuilder` computes "is this scope null-marked?"
  from class + package-info `@NullMarked`/`@NullUnmarked` (in both the
  Visible and Invisible annotation tables, because `@NullMarked` is
  `@Retention(RUNTIME)`).  Inside a null-marked scope, reference-typed
  returns / parameters / fields receive `not-null="true"` unless a
  top-level TYPE_USE `@Nullable` overrides.

Out of scope for this minimum slice (issues to track separately):

* Module-level `@NullMarked`.
* Inner-class scope inheritance from outer classes.
* Method-level `@NullUnmarked` opt-outs (only class- and package-info
  level scopes are consulted today — a test pins down this limitation).
* `type_path`-aware nullness for inner generic type arguments.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings June 24, 2026 20:06
@jonathanpeppers

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Jun 24, 2026

Copy link
Copy Markdown

Java.Interop PR Reviewer completed successfully!

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends Xamarin.Android.Tools.Bytecode to understand JSpecify nullness by parsing JSR-308 type annotations and applying @NullMarked scope defaults (class/package) when generating API XML, improving downstream C# nullable reference type output.

Changes:

  • Added parsing for RuntimeVisible/InvisibleTypeAnnotations (JSR-308) via new TypeAnnotation model and attribute readers.
  • Implemented JSpecify scope-default detection using package-info.class and class-level annotations, and resolved nullness for returns/parameters/fields accordingly.
  • Added a focused test suite and Java fixtures to validate parsing and nullness emission behavior.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/Xamarin.Android.Tools.Bytecode-Tests/Xamarin.Android.Tools.Bytecode-Tests.csproj Embeds new compiled class fixtures for JSpecify scenarios.
tests/Xamarin.Android.Tools.Bytecode-Tests/JSpecifyAnnotationTests.cs Adds coverage for package/class scope defaults, opt-outs, and type-annotation parsing.
tests/Xamarin.Android.Tools.Bytecode-Tests/java/org/jspecify/annotations/NullUnmarked.java Test stub for scope-default opt-out annotation.
tests/Xamarin.Android.Tools.Bytecode-Tests/java/org/jspecify/annotations/NullMarked.java Test stub for scope-default opt-in annotation.
tests/Xamarin.Android.Tools.Bytecode-Tests/java/org/jspecify/annotations/Nullable.java Test stub for TYPE_USE nullable annotation.
tests/Xamarin.Android.Tools.Bytecode-Tests/java/org/jspecify/annotations/NonNull.java Test stub for TYPE_USE non-null annotation.
tests/Xamarin.Android.Tools.Bytecode-Tests/java/com/jspecifyunmarked/JSpecifyUnmarked.java Test fixture for “no scope default” behavior.
tests/Xamarin.Android.Tools.Bytecode-Tests/java/com/jspecifytest/package-info.java Test fixture for package-level @NullMarked.
tests/Xamarin.Android.Tools.Bytecode-Tests/java/com/jspecifytest/JSpecifyPackageMarked.java Test fixture for package-marked nullness defaults and overrides.
tests/Xamarin.Android.Tools.Bytecode-Tests/java/com/jspecifytest/JSpecifyClassMarked.java Test fixture for class-level @NullMarked path.
src/Xamarin.Android.Tools.Bytecode/XmlClassDeclarationBuilder.cs Computes JSpecify scope defaults and resolves slot nullness using declaration + TYPE_USE annotations.
src/Xamarin.Android.Tools.Bytecode/TypeAnnotation.cs Introduces a parser/model for JVMS type annotations (target_info + type_path + annotation).
src/Xamarin.Android.Tools.Bytecode/ClassPath.cs Retains/uses package-info.class during XML generation to apply package-level defaults.
src/Xamarin.Android.Tools.Bytecode/ClassFile.cs Adds IsPackageInfo helper to identify package-info.class.
src/Xamarin.Android.Tools.Bytecode/AttributeInfo.cs Adds support for visible parameter/type annotations and corresponding attribute readers.

Comment on lines +616 to +620
bool? GetParameterNullness (MethodInfo method, IList<ParameterAnnotation>? annotations, int parameterIndex)
{
var ann = annotations?.FirstOrDefault (a => a.ParameterIndex == parameterIndex)?.Annotations;
if (ann?.Any (a => IsNotNullAnnotation (a)) == true)
return new XAttribute ("not-null", "true");
return true;
Comment on lines 387 to +392
.Select (p => new XElement ("package",
new XAttribute ("name", p),
new XAttribute ("jni-name", p.Replace ('.', '/')),
packagesDictionary [p].OrderBy (c => c.ThisClass.Name.Value, StringComparer.OrdinalIgnoreCase)
.Select (c => new XmlClassDeclarationBuilder (c).ToXElement ()))));
packagesDictionary [p].Where (c => !c.IsPackageInfo)
.OrderBy (c => c.ThisClass.Name.Value, StringComparer.OrdinalIgnoreCase)
.Select (c => new XmlClassDeclarationBuilder (c, GetPackageInfo (p)).ToXElement ()))));

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Needs Changes

Nicely structured addition. The JSR 308 target_info parser carefully reads past every variant (even ones not acted on) to keep the stream aligned, the package-info scope plumbing through ClassPath.GetPackageInfo is clean, and the new tests cover package/class scope, primitive exclusion, and @Nullable opt-out at each slot kind. Documenting the deferred limitations and pinning one (PackageMarked_NullUnmarkedMethod_RevertsToUnknown) with a test is exactly the right discipline. A few refinements before merge:

⚠️ Warnings (3)

  • Test uses the banned null-forgiving !. The new test file enables #nullable enable then sprinkles p! / typeAnn! to silence warnings — repo-banned, and it turns a missing element into an NRE instead of a clean assert failure. Assert non-null first.
  • IsNullableAnnotation is asymmetric with IsNotNullAnnotation (4 providers vs 16). A top-level TYPE_USE @Nullable from an unlisted provider (e.g. RecentlyNullable, whose RecentlyNonNull counterpart is listed) is silently treated as non-null inside a @NullMarked scope, emitting an incorrect not-null="true".
  • Erased type-variable slots are over-marked. <T> T get() (descriptor ()Ljava/lang/Object;) and T params/fields are marked not-null in a null-marked scope; JSpecify gives type-variable usages parametric nullness. This generics gap isn't in the documented deferred list — handle via the generic Signature or document + pin it.

💡 Suggestions (1)

  • NonNull.java is added but unused — the explicit TYPE_USE @NonNull resolution path has no test. Exercise it in an unmarked class.

CI: the dotnet.java-interop Azure Pipelines build was still queued at review time, so a green build isn't yet confirmed — please make sure it passes before merging.

Overall a solid, well-scoped first slice; the items above are mostly about hardening the nullable-resolution edges and closing a test gap.

Generated by Java.Interop PR Reviewer for issue #1483 · 1.1K AIC · ⌖ 50.1 AIC · ⊞ 34.6K
Comment /review to run again

yield return a;
}

static bool IsNullableAnnotation (Annotation annotation)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 ⚠️ Generator codegenIsNullableAnnotation recognizes only 4 providers, while IsNotNullAnnotation just above recognizes 16. The lists are asymmetric in a way that affects emitted nullness: e.g. androidx.annotation.RecentlyNonNull is in the not-null list but RecentlyNullable is absent here, and the same goes for the jetbrains/checkerframework/javax @Nullable variants. Inside a @NullMarked scope, a top-level TYPE_USE @Nullable from any unlisted provider isn't matched, so GetTypeUseNullness returns null, the slot falls through to the scope default, and it's wrongly emitted as not-null="true" — i.e. a nullable slot becomes a non-null C# reference. Consider mirroring the established not-null provider list so the opt-out is symmetric.

Rule: Disambiguate/return-type consistency; avoid incorrect not-null metadata

"unannotated reference return in @NullMarked package must be not-null");

var p = m.Element ("parameter");
Assert.AreEqual ("true", Attr (p!, "not-null"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 ⚠️ Nullable — This file turns on #nullable enable and then leans on the postfix null-forgiving ! to silence the resulting warnings: Attr (p!, ...) here and at lines 82, 110, 128, 146, plus typeAnn! at 164/168. The repo bans the postfix ! operator, and it's also a worse test: if m.Element ("parameter") ever returns null, this throws a NullReferenceException instead of failing with a useful message. Assert first, then use the local — e.g. var p = m.Element ("parameter"); Assert.NotNull (p); and reference p afterward (or change Attr to take XElement?).

Rule: Never use the null-forgiving ! operator

ta => ta.TargetType == TypeAnnotationTargetType.MethodReturn);
if (typeNullness.HasValue)
return typeNullness;
if (isNullMarked && IsReferenceTypeDescriptor (GetReturnDescriptor (method.Descriptor)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 ⚠️ Generator codegen — In a null-marked scope the non-null decision here comes purely from the erased descriptor, so an unannotated type-variable slot is marked not-null="true": <T> T get() has descriptor ()Ljava/lang/Object;, and a T parameter/field (same pattern at lines 631 and 645) erases to Ljava/lang/Object;. Per JSpecify an unannotated type-variable usage has parametric nullness — it follows the type argument and is not automatically non-null — so e.g. List<@nullable String>.get() would be emitted as non-null incorrectly. This generics gap isn't in the PR's documented "deliberately deferred" list. Consider detecting type-variable usages via the generic Signature, or at minimum document it and pin the current behavior with a test (as you did for method-level @NullUnmarked).

Rule: Confidently-wrong domain facts — verify JSpecify semantics


@Target(ElementType.TYPE_USE)
@Retention(RetentionPolicy.CLASS)
public @interface NonNull {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 💡 Testing — This @NonNull annotation is defined but never referenced by any test class, so the explicit TYPE_USE @NonNull resolution path (GetTypeUseNullnessIsNotNullAnnotationreturn true) is never exercised: every not-null="true" assertion in the suite comes from the scope default, and every opt-out from @Nullable. Add a member annotated with @NonNull to an unmarked class (e.g. JSpecifyUnmarked) and assert it gains not-null="true", so the type-use NonNull branch is covered.

Rule: Bug fixes / new code paths need regression tests

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support JSpecify "nullness" annotations.

2 participants