JDK astubx generator files an inner class of a generic class under the outer class

Open
#2,159 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
3/5
Estimated time
1-2 days
Newbie friendliness
62/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
java
Domain
tooling

Research direction

Start in jdk-annotations/astubx-generator/src/main/java/com/uber/nullaway/jdkannotations/AstubxGenerator.java, in getAstubxData, where the class key is derived from clazz.type() cut at the first <. Run the two reproducing tests innerClassOfGenericClass and innerClassTypeParamBound in AstubxTest.java to see the missing Outer.Inner record and the wrong Outer2 bound. Done when both tests pass, Outer.Inner gets its own record, and Outer2 no longer receives the inner class's bound.

Written by the indexing model from the issue text.

Description

The JDK astubx generator files what it records for an inner (non-static) class of a generic class under the outer class's name. The inner class's method models are lost when the outer class declares the same method, and a nullable type-parameter bound of the inner class becomes a bound of the outer class.

I added these two cases to jdk-annotations/astubx-generator/src/test/java/com/uber/nullaway/jdkannotations/AstubxTest.java on master (a03b7bb90) and ran ./gradlew :jdk-annotations:astubx-generator:test --tests 'com.uber.nullaway.jdkannotations.AstubxTest':

  @Test
  public void innerClassOfGenericClass() {
    compilationHelper
        .addSourceLines(
            "Outer.java",
            """
            import org.jspecify.annotations.NullMarked;
            import org.jspecify.annotations.Nullable;
            @NullMarked
            public class Outer<T> {
                public @Nullable String get(@Nullable Object key) { return null; }
                public class Inner {
                    public @Nullable String get(Object key) { return null; }
                }
                public static class Nested {
                    public @Nullable String get(Object key) { return null; }
                }
            }
            """)
        .doTest();
    ImmutableMap<String, MethodAnnotationsRecord> expectedMethodRecords =
        ImmutableMap.of(
            "Outer:java.lang.String get(java.lang.Object)",
            MethodAnnotationsRecord.create(
                ImmutableSet.of("Nullable"),
                ImmutableSet.of(),
                ImmutableMap.of(0, ImmutableSet.of("Nullable")),
                ImmutableSetMultimap.of()),
            "Outer.Inner:java.lang.String get(java.lang.Object)",
            MethodAnnotationsRecord.create(
                ImmutableSet.of("Nullable"),
                ImmutableSet.of(),
                ImmutableMap.of(),
                ImmutableSetMultimap.of()),
            "Outer.Nested:java.lang.String get(java.lang.Object)",
            MethodAnnotationsRecord.create(
                ImmutableSet.of("Nullable"),
                ImmutableSet.of(),
                ImmutableMap.of(),
                ImmutableSetMultimap.of()));
    runTest(expectedMethodRecords, ImmutableMap.of(), ImmutableSet.of("Outer"));
  }

  @Test
  public void innerClassTypeParamBound() {
    compilationHelper
        .addSourceLines(
            "Outer2.java",
            """
            import org.jspecify.annotations.NullMarked;
            import org.jspecify.annotations.Nullable;
            @NullMarked
            public class Outer2<T> {
                public class Inner<U extends @Nullable Object> {}
            }
            """)
        .doTest();
    runTest(
        ImmutableMap.of(),
        ImmutableMap.of("Outer2.Inner", ImmutableSet.of(0)),
        ImmutableSet.of("Outer2"));
  }

Both fail. The record for Outer.Inner is missing, while the static Outer.Nested gets its own record:

AstubxTest > innerClassOfGenericClass FAILED
    java.lang.AssertionError: 
    Expected: <{Outer:java.lang.String get(java.lang.Object)=MethodAnnotationsRecord[methodAnnotations=[Nullable], typeParamNullableUpperbounds=[], argumentAnnotations={0=[Nullable]}, nestedAnnotationInfo={}], Outer.Inner:java.lang.String get(java.lang.Object)=MethodAnnotationsRecord[methodAnnotations=[Nullable], typeParamNullableUpperbounds=[], argumentAnnotations={}, nestedAnnotationInfo={}], Outer.Nested:java.lang.String get(java.lang.Object)=MethodAnnotationsRecord[methodAnnotations=[Nullable], typeParamNullableUpperbounds=[], argumentAnnotations={}, nestedAnnotationInfo={}]}>
         but: was <{Outer:java.lang.String get(java.lang.Object)=MethodAnnotationsRecord[methodAnnotations=[Nullable], typeParamNullableUpperbounds=[], argumentAnnotations={0=[Nullable]}, nestedAnnotationInfo={}], Outer.Nested:java.lang.String get(java.lang.Object)=MethodAnnotationsRecord[methodAnnotations=[Nullable], typeParamNullableUpperbounds=[], argumentAnnotations={}, nestedAnnotationInfo={}]}>

Outer2 gets a nullable bound on its type parameter, which only Inner declares:

AstubxTest > innerClassTypeParamBound FAILED
    java.lang.AssertionError: 
    Expected: <{Outer2.Inner=[0]}>
         but: was <{Outer2=[0]}>

The committed nullaway/src/main/resources/jspecify-jdk.astubx has such entries. HashMap, LinkedHashMap, and TreeMap declare no boolean contains(Object) or boolean remove(Object), but their inner key-set, entry-set, and values classes do, such as HashMap.KeySet, LinkedHashMap.LinkedEntrySet, and TreeMap.Values:

strings nullaway/src/main/resources/jspecify-jdk.astubx | grep -E '(HashMap|TreeMap):boolean (contains|remove)\(java\.lang\.Object\)'
4java.util.HashMap:boolean contains(java.lang.Object)
2java.util.HashMap:boolean remove(java.lang.Object)
:java.util.LinkedHashMap:boolean contains(java.lang.Object)
8java.util.LinkedHashMap:boolean remove(java.lang.Object)
4java.util.TreeMap:boolean contains(java.lang.Object)
2java.util.TreeMap:boolean remove(java.lang.Object)

These classes are package-private, so I have not found a JDK member whose NullAway diagnostics change because of it. A public generic outer class in the current file does not get a wrong bound this way either.

Likely cause (from reading the code, not confirmed by a fix): AstubxGenerator.getAstubxData derives the class key from clazz.type() cut at the first <. For an inner class of a generic class that string is Outer<T>.Inner, so the key becomes Outer. A static nested class prints as Outer.Nested, which has no < before the nested name. Method records and bounds are then stored with put, so the record written last for a key wins.

Assisted-by: Claude Code (claude-opus-5-5)

Dominant language
Java
Stars
4.1k
Forks
379
Avg merge
1d 2h
Merged PRs (30d)
106

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from uber/NullAway

All issues in uber/NullAway

Similar issues

More Java issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.