From 0bcf876c876dea2fc1bbd72e1a4a9e80cae18f5a Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Tue, 15 Sep 2026 16:31:19 -0700 Subject: [PATCH 01/58] Add ainfer tests for a checker that declares `@RelevantJavaTypes` No existing whole-program-inference test uses a checker that restricts the Java types on which its qualifiers may be written, so nothing tests the interaction of `@RelevantJavaTypes` with inference. Add `AinferRelevanceTestChecker`, which uses the same type system and visitor as `AinferTestChecker` but lists only `CharSequence`, `int`, and `Map.Entry` as relevant. Add the `ainferRelevanceAjavaTest` Gradle task and test inputs that cover relevant types, irrelevant types, and the several ways a program can refer to a type (a nested type, a type variable that shadows a class, a type in the same package that shadows one in `java.lang`, and so on). The generation pass compares its output to `.ajava.goal` files. The validation pass alone cannot detect an annotation that inference writes on an irrelevant type, because such an annotation does not change the result of type-checking. The goal files record current behavior: inference writes annotations even on types that the checker treats as irrelevant. Co-Authored-By: Claude Opus 5 --- .gitignore | 2 + checker/build.gradle | 76 ++++++++++++ .../AinferRelevanceAjavaGenerationTest.java | 113 ++++++++++++++++++ .../AinferRelevanceAjavaValidationTest.java | 40 +++++++ .../ainfer/AinferRelevanceTestChecker.java | 30 +++++ .../AinferTestAnnotatedTypeFactory.java | 3 + .../IShouldBeSibling1.ajava.goal | 24 ++++ .../IrrelevantTypes.ajava.goal | 32 +++++ checker/tests/ainfer-relevance/README | 1 + .../non-annotated/IShouldBeSibling1.java | 18 +++ .../non-annotated/IrrelevantTypes.java | 26 ++++ .../non-annotated/LexicallyNestedType.java | 29 +++++ .../ainfer-relevance/non-annotated/List.java | 6 + .../non-annotated/Number.java | 11 ++ .../non-annotated/RelevantNestedType.java | 51 ++++++++ .../non-annotated/RelevantSubtype.java | 40 +++++++ .../non-annotated/RelevantTypeVariable.java | 20 ++++ .../non-annotated/RelevantTypes.java | 67 +++++++++++ .../non-annotated/Runnable.java | 6 + .../non-annotated/SamePackageShadows.java | 41 +++++++ .../TypeVariableShadowsClass.java | 26 ++++ 21 files changed, 662 insertions(+) create mode 100644 checker/src/test/java/org/checkerframework/checker/test/junit/ainferrunners/AinferRelevanceAjavaGenerationTest.java create mode 100644 checker/src/test/java/org/checkerframework/checker/test/junit/ainferrunners/AinferRelevanceAjavaValidationTest.java create mode 100644 checker/src/test/java/org/checkerframework/checker/testchecker/ainfer/AinferRelevanceTestChecker.java create mode 100644 checker/tests/ainfer-relevance/IShouldBeSibling1.ajava.goal create mode 100644 checker/tests/ainfer-relevance/IrrelevantTypes.ajava.goal create mode 120000 checker/tests/ainfer-relevance/README create mode 100644 checker/tests/ainfer-relevance/non-annotated/IShouldBeSibling1.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/IrrelevantTypes.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/LexicallyNestedType.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/List.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/Number.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/RelevantNestedType.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/RelevantSubtype.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/RelevantTypeVariable.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/RelevantTypes.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/Runnable.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/SamePackageShadows.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/TypeVariableShadowsClass.java diff --git a/.gitignore b/.gitignore index 55d0b10ee19b..9982bcf72f9b 100644 --- a/.gitignore +++ b/.gitignore @@ -195,6 +195,8 @@ checker-qual-android/src # Some tests produce output into the tests/ directory instead of the build/ directory. checker/tests/ainfer-index/annotated/ checker/tests/ainfer-index/inference-output/ +checker/tests/ainfer-relevance/annotated/ +checker/tests/ainfer-relevance/inference-output/ checker/tests/ainfer-nullness/annotated/ checker/tests/ainfer-nullness/inference-output/ checker/tests/ainfer-testchecker/annotated/ diff --git a/checker/build.gradle b/checker/build.gradle index 1e5d836a4136..2229e897cec8 100644 --- a/checker/build.gradle +++ b/checker/build.gradle @@ -209,6 +209,8 @@ clean { delete("tests/ainfer-nullness/inference-output") delete("tests/ainfer-index/annotated") delete("tests/ainfer-index/inference-output") + delete("tests/ainfer-relevance/annotated") + delete("tests/ainfer-relevance/inference-output") delete("tests/ainfer-resourceleak/annotated") delete("tests/ainfer-resourceleak/inference-output") } @@ -346,6 +348,7 @@ test { excludeCategories("org.checkerframework.checker.test.junit.ainferrunners.AinferNullnessJaifsGenerationTest") excludeCategories("org.checkerframework.checker.test.junit.ainferrunners.AinferNullnessAjavaGenerationTest") excludeCategories("org.checkerframework.checker.test.junit.ainferrunners.AinferIndexAjavaGenerationTest") + excludeCategories("org.checkerframework.checker.test.junit.ainferrunners.AinferRelevanceAjavaGenerationTest") excludeCategories("org.checkerframework.checker.test.junit.ainferrunners.AinferResourceLeakAjavaGenerationTest") } } @@ -681,6 +684,78 @@ tasks.register("ainferIndexAjavaTest") { outputs.upToDateWhen { false } } +tasks.register("ainferRelevanceGenerateAjava", Test) { + description = "Internal task. Users should run ainferRelevanceAjavaTest instead. This type-checks the ainfer-relevance files with -Ainfer=ajava to generate ajava files." + addTestListener(new RerunAdvice(":checker:ainferRelevanceAjavaTest")) + dependsOn(compileTestJava) + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + outputs.upToDateWhen { false } + include("**/AinferRelevanceAjavaGenerationTest.class") + testLogging { + // Always run the tests + outputs.upToDateWhen { false } + + // Show the found unexpected diagnostics and the expected diagnostics not found. + exceptionFormat = "full" + events("passed", "skipped", "failed") + } + // Resolve these directories at configuration time, and delete them with `File.deleteDir()` + // rather than with `Project.delete()`, because the Gradle configuration cache forbids using + // a project object from within a task action. + // The list includes `build/whole-program-inference`, the directory that WPI writes into. + // `doLast` renames that directory away, but `doLast` does not run when the test fails, so a + // failed run would otherwise leave stale output that this task would fold into its own + // results. `AinferRelevanceAjavaGenerationTest` also reads that directory directly, to + // compare the inference output to the goal files. + List ainferRelevanceDirsToDelete = [ + file("tests/ainfer-relevance/annotated"), + layout.buildDirectory.dir("ainfer-relevance").get().asFile, + layout.buildDirectory.dir("whole-program-inference").get().asFile, + ] + doFirst { + ainferRelevanceDirsToDelete.forEach { dirToDelete -> + // Unlike `Project.delete()`, `File.deleteDir()` reports failure by returning false rather + // than by throwing. (It returns true if the directory does not exist, and false if the + // path exists but is not a directory.) Without this test, this task would run against + // stale output after a failed deletion. + if (!dirToDelete.deleteDir()) { + throw new GradleException("Could not delete ${dirToDelete}") + } + } + } + doLast { + copyNonannotatedToAnnotatedDirectory("ainfer-relevance") + } +} + +tasks.register("ainferRelevanceValidateAjava", Test) { + description = "Internal task. Users should run ainferRelevanceAjavaTest instead. This re-type-checks the ainfer-relevance files using the ajava files generated by ainferRelevanceGenerateAjava" + addTestListener(new RerunAdvice(":checker:ainferRelevanceAjavaTest")) + dependsOn(ainferRelevanceGenerateAjava) + + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + outputs.upToDateWhen { false } + include("**/AinferRelevanceAjavaValidationTest.class") + testLogging { + // Always run the tests + outputs.upToDateWhen { false } + + // Show the found unexpected diagnostics and the expected diagnostics not found. + exceptionFormat = "full" + events("passed", "skipped", "failed") + } +} + +tasks.register("ainferRelevanceAjavaTest") { + dependsOn("shadowJar") + description = "Run tests for whole-program inference using ajava files and a checker that declares @RelevantJavaTypes" + group = "Verification" + dependsOn(ainferRelevanceValidateAjava) + outputs.upToDateWhen { false } +} + tasks.register("ainferNullnessGenerateAjava", Test) { description = "Internal task. Users should run ainferNullnessAjavaTest instead. This type-checks the ainfer-nullness files with -Ainfer=ajava to generate ajava files." addTestListener(new RerunAdvice(":checker:ainferNullnessAjavaTest")) @@ -868,6 +943,7 @@ tasks.register("ainferTest") { dependsOn("ainferNullnessJaifTest") dependsOn("ainferNullnessAjavaTest") dependsOn("ainferIndexAjavaTest") + dependsOn("ainferRelevanceAjavaTest") dependsOn("ainferResourceLeakAjavaTest") } diff --git a/checker/src/test/java/org/checkerframework/checker/test/junit/ainferrunners/AinferRelevanceAjavaGenerationTest.java b/checker/src/test/java/org/checkerframework/checker/test/junit/ainferrunners/AinferRelevanceAjavaGenerationTest.java new file mode 100644 index 000000000000..5bba52169330 --- /dev/null +++ b/checker/src/test/java/org/checkerframework/checker/test/junit/ainferrunners/AinferRelevanceAjavaGenerationTest.java @@ -0,0 +1,113 @@ +package org.checkerframework.checker.test.junit.ainferrunners; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; +import org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker; +import org.checkerframework.framework.test.AinferGeneratePerDirectoryTest; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.experimental.categories.Category; +import org.junit.runners.Parameterized.Parameters; + +/** + * Tests whole-program inference with the aid of ajava files, for a checker that declares + * {@code @RelevantJavaTypes}. This test is the first pass on the test data, which generates the + * ajava files. + * + *

IMPORTANT: The errors captured in the tests located in tests/ainfer-relevance/ are not + * relevant. The meaning of this test class is to test if the generated ajava files are similar to + * the expected ones. The errors on .java files must be ignored. + */ +@Category(AinferRelevanceAjavaGenerationTest.class) +public class AinferRelevanceAjavaGenerationTest extends AinferGeneratePerDirectoryTest { + + /** + * @param testFiles the files containing test code, which will be type-checked + */ + public AinferRelevanceAjavaGenerationTest(List testFiles) { + super( + testFiles, + AinferRelevanceTestChecker.class, + "ainfer-relevance/non-annotated", + "-Ainfer=ajava", + "-Awarns"); + } + + @Parameters + public static String[] getTestDirs() { + return new String[] {"ainfer-relevance/non-annotated"}; + } + + /** The directory that contains the goal files. */ + private static final Path goalDir = Path.of("tests", "ainfer-relevance"); + + /** + * The directory into which inference writes the ajava files. (The {@code + * ainferRelevanceGenerateAjava} Gradle task later renames this directory to {@code + * tests/ainfer-relevance/inference-output}.) + */ + private static final Path inferenceOutputDir = Path.of("build", "whole-program-inference"); + + /** The suffix of a goal file's name. */ + private static final String goalSuffix = ".ajava.goal"; + + /** + * Compares each generated ajava file to its goal file, if the goal file exists. A goal file is + * named {@code .ajava.goal}. + * + *

Unlike the second (validation) pass of this test, this comparison detects an annotation that + * inference wrote even though the annotation is irrelevant where it appears. Such an annotation + * clutters the ajava file, but it does not change the result of type-checking, so no diagnostic + * would reveal it. + */ + @AfterClass + public static void compareToGoalFiles() throws IOException { + try (Stream goalFiles = Files.list(goalDir)) { + goalFiles + .filter(goalFile -> goalFile.getFileName().toString().endsWith(goalSuffix)) + .forEach(AinferRelevanceAjavaGenerationTest::compareToGoalFile); + } + } + + /** + * Compares the generated ajava file that corresponds to the given goal file, to the goal file. + * + * @param goalFile a goal file + */ + private static void compareToGoalFile(Path goalFile) { + String goalFileName = goalFile.getFileName().toString(); + String className = goalFileName.substring(0, goalFileName.length() - goalSuffix.length()); + Path ajavaFile = + inferenceOutputDir.resolve( + className + "-" + AinferRelevanceTestChecker.class.getCanonicalName() + ".ajava"); + String message = + String.format( + "%s differs from %s. If the difference is desirable, overwrite the goal file:%n" + + " cp %s %s%n", + ajavaFile.toAbsolutePath(), + goalFile.toAbsolutePath(), + ajavaFile.toAbsolutePath(), + goalFile.toAbsolutePath()); + if (!Files.exists(ajavaFile)) { + // Inference wrote no ajava file for the class: it inferred nothing, or the file has an + // unexpected name. Report this the same way as a difference in contents, rather than + // throwing an uninformative IOException. + Assert.fail(message); + } + String goalContents; + String ajavaContents; + try { + goalContents = Files.readString(goalFile, StandardCharsets.UTF_8); + ajavaContents = Files.readString(ajavaFile, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + Assert.assertEquals(message, goalContents, ajavaContents); + } +} diff --git a/checker/src/test/java/org/checkerframework/checker/test/junit/ainferrunners/AinferRelevanceAjavaValidationTest.java b/checker/src/test/java/org/checkerframework/checker/test/junit/ainferrunners/AinferRelevanceAjavaValidationTest.java new file mode 100644 index 000000000000..2f6dbc7d7768 --- /dev/null +++ b/checker/src/test/java/org/checkerframework/checker/test/junit/ainferrunners/AinferRelevanceAjavaValidationTest.java @@ -0,0 +1,40 @@ +package org.checkerframework.checker.test.junit.ainferrunners; + +import java.io.File; +import java.util.List; +import org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker; +import org.checkerframework.framework.test.AinferValidatePerDirectoryTest; +import org.junit.experimental.categories.Category; +import org.junit.runners.Parameterized.Parameters; + +/** + * Tests whole-program type inference with ajava files, for a checker that declares + * {@code @RelevantJavaTypes}. This test is the second pass, which ensures that with the ajava files + * in place, the errors that those annotations remove are no longer issued. + * + *

This test fails if inference discards an annotation that is written on a relevant type, such + * as {@code int} (which the checker lists) or {@code String} (which is relevant because it is a + * subtype of the listed type {@code CharSequence}). + */ +@Category(AinferRelevanceAjavaGenerationTest.class) +public class AinferRelevanceAjavaValidationTest extends AinferValidatePerDirectoryTest { + + /** + * @param testFiles the files containing test code, which will be type-checked + */ + public AinferRelevanceAjavaValidationTest(List testFiles) { + super( + testFiles, + AinferRelevanceTestChecker.class, + "relevance", + "ainfer-relevance/annotated", + AinferRelevanceAjavaGenerationTest.class, + ajavaArgFromFiles(testFiles, "relevance"), + "-Awarns"); + } + + @Parameters + public static String[] getTestDirs() { + return new String[] {"ainfer-relevance/annotated/"}; + } +} diff --git a/checker/src/test/java/org/checkerframework/checker/testchecker/ainfer/AinferRelevanceTestChecker.java b/checker/src/test/java/org/checkerframework/checker/testchecker/ainfer/AinferRelevanceTestChecker.java new file mode 100644 index 000000000000..704ed10a4cf3 --- /dev/null +++ b/checker/src/test/java/org/checkerframework/checker/testchecker/ainfer/AinferRelevanceTestChecker.java @@ -0,0 +1,30 @@ +package org.checkerframework.checker.testchecker.ainfer; + +import java.util.Map; +import org.checkerframework.common.basetype.BaseTypeChecker; +import org.checkerframework.common.basetype.BaseTypeVisitor; +import org.checkerframework.framework.qual.RelevantJavaTypes; + +/** + * Like {@link AinferTestChecker}, but only some Java types are relevant. It uses the same type + * system and the same visitor. + * + *

This checker exists so that the tests exercise whole-program inference for a checker that + * restricts the Java types on which its qualifiers may be written. {@code int} and {@code + * CharSequence} are relevant because they are listed. {@code String} is relevant because of + * subtyping. Arrays are irrelevant, because {@code Object[].class} is not listed. + * + *

{@code Map.Entry} is listed because it is a nested type and it has type parameters, so a + * program may refer to {@code Map.Entry} in several different ways. + */ +@RelevantJavaTypes({CharSequence.class, int.class, Map.Entry.class}) +public class AinferRelevanceTestChecker extends BaseTypeChecker { + + /** Creates an AinferRelevanceTestChecker. */ + public AinferRelevanceTestChecker() {} + + @Override + protected BaseTypeVisitor createSourceVisitor() { + return new AinferTestVisitor(this); + } +} diff --git a/checker/src/test/java/org/checkerframework/checker/testchecker/ainfer/AinferTestAnnotatedTypeFactory.java b/checker/src/test/java/org/checkerframework/checker/testchecker/ainfer/AinferTestAnnotatedTypeFactory.java index 85483bb16fad..2e73d52da634 100644 --- a/checker/src/test/java/org/checkerframework/checker/testchecker/ainfer/AinferTestAnnotatedTypeFactory.java +++ b/checker/src/test/java/org/checkerframework/checker/testchecker/ainfer/AinferTestAnnotatedTypeFactory.java @@ -165,6 +165,9 @@ public Void visitMethod(MethodTree methodTree, AnnotatedTypeMirror type) { WholeProgramInference wpi = atypeFactory.getWholeProgramInference(); if (wpi != null) { ExecutableElement execElt = TreeUtils.elementFromDeclaration(methodTree); + if (execElt.getSimpleName().contentEquals("iShouldBeSibling1")) { + wpi.addMethodDeclarationAnnotation(execElt, SIBLING1); + } int numParams = execElt.getParameters().size(); for (int i = 0; i < numParams; ++i) { VariableElement param = execElt.getParameters().get(i); diff --git a/checker/tests/ainfer-relevance/IShouldBeSibling1.ajava.goal b/checker/tests/ainfer-relevance/IShouldBeSibling1.ajava.goal new file mode 100644 index 000000000000..ec581637c4a9 --- /dev/null +++ b/checker/tests/ainfer-relevance/IShouldBeSibling1.ajava.goal @@ -0,0 +1,24 @@ +// The type factory contains custom inference logic for a class whose name is "IShouldBeSibling1" +// and for a method whose name is "iShouldBeSibling1": the type factory infers a declaration +// annotation for the class and for the method. The inferred annotation is a type qualifier, and +// the .ajava file writes the annotation, with the annotation's fully-qualified name, on the +// declaration. Printing the annotation must not crash, and inference must not discard the +// annotation even though the method's return type is irrelevant: relevance constrains the types +// on which a qualifier may be written, so it says nothing about a declaration annotation. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +@org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 +public class IShouldBeSibling1 { + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 + @org.checkerframework.dataflow.qual.SideEffectFree + void iShouldBeSibling1() { + } + + // Unlike `void`, this method's return type `double` can bear a type qualifier, but the inferred + // annotation is nonetheless a declaration annotation. + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 + @org.checkerframework.dataflow.qual.Pure + @org.checkerframework.checker.testchecker.ainfer.qual.AinferTop double iShouldBeSibling1(double d) { + return d; + } +} diff --git a/checker/tests/ainfer-relevance/IrrelevantTypes.ajava.goal b/checker/tests/ainfer-relevance/IrrelevantTypes.ajava.goal new file mode 100644 index 000000000000..bf17d18ef0df --- /dev/null +++ b/checker/tests/ainfer-relevance/IrrelevantTypes.ajava.goal @@ -0,0 +1,32 @@ +// Inference infers annotations for types that this checker treats as irrelevant: `double`, which +// is not listed in the checker's `@RelevantJavaTypes` and is not related to a listed type by +// subtyping; and arrays, because `Object[].class` is not listed. The goal file records that +// inference currently writes those annotations into the .ajava file. They are clutter: because +// the checker treats these types as irrelevant, omitting them would not change the result of +// type-checking. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class IrrelevantTypes { + + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferTop double doubleField; + + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType String @org.checkerframework.checker.testchecker.ainfer.qual.AinferTop [] arrayField; + + static java.util.@org.checkerframework.checker.testchecker.ainfer.qual.AinferTop Date dateField; + + @org.checkerframework.dataflow.qual.Impure + static void assignFields() { + doubleField = 0.0; + arrayField = new String[] { "" }; + dateField = new java.util.Date(); + } + + @org.checkerframework.dataflow.qual.Pure + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferTop double doubleReturn() { + return doubleField; + } + + @org.checkerframework.dataflow.qual.Pure + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType String @org.checkerframework.checker.testchecker.ainfer.qual.AinferTop [] arrayReturn() { + return arrayField; + } +} diff --git a/checker/tests/ainfer-relevance/README b/checker/tests/ainfer-relevance/README new file mode 120000 index 000000000000..2c3e7d67d74c --- /dev/null +++ b/checker/tests/ainfer-relevance/README @@ -0,0 +1 @@ +../ainfer-README \ No newline at end of file diff --git a/checker/tests/ainfer-relevance/non-annotated/IShouldBeSibling1.java b/checker/tests/ainfer-relevance/non-annotated/IShouldBeSibling1.java new file mode 100644 index 000000000000..4df4b67993d2 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/IShouldBeSibling1.java @@ -0,0 +1,18 @@ +// The type factory contains custom inference logic for a class whose name is "IShouldBeSibling1" +// and for a method whose name is "iShouldBeSibling1": the type factory infers a declaration +// annotation for the class and for the method. The inferred annotation is a type qualifier, and +// the .ajava file writes the annotation, with the annotation's fully-qualified name, on the +// declaration. Printing the annotation must not crash, and inference must not discard the +// annotation even though the method's return type is irrelevant: relevance constrains the types +// on which a qualifier may be written, so it says nothing about a declaration annotation. +@SuppressWarnings("super.invocation") // Intentional. +public class IShouldBeSibling1 { + + void iShouldBeSibling1() {} + + // Unlike `void`, this method's return type `double` can bear a type qualifier, but the inferred + // annotation is nonetheless a declaration annotation. + double iShouldBeSibling1(double d) { + return d; + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/IrrelevantTypes.java b/checker/tests/ainfer-relevance/non-annotated/IrrelevantTypes.java new file mode 100644 index 000000000000..0bd21ee13936 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/IrrelevantTypes.java @@ -0,0 +1,26 @@ +// Inference infers annotations for types that this checker treats as irrelevant: `double`, which +// is not listed in the checker's `@RelevantJavaTypes` and is not related to a listed type by +// subtyping; and arrays, because `Object[].class` is not listed. The goal file records that +// inference currently writes those annotations into the .ajava file. They are clutter: because +// the checker treats these types as irrelevant, omitting them would not change the result of +// type-checking. +public class IrrelevantTypes { + + static double doubleField; + static String[] arrayField; + static java.util.Date dateField; + + static void assignFields() { + doubleField = 0.0; + arrayField = new String[] {""}; + dateField = new java.util.Date(); + } + + static double doubleReturn() { + return doubleField; + } + + static String[] arrayReturn() { + return arrayField; + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/LexicallyNestedType.java b/checker/tests/ainfer-relevance/non-annotated/LexicallyNestedType.java new file mode 100644 index 000000000000..20aba8f1c42e --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/LexicallyNestedType.java @@ -0,0 +1,29 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A nested type declaration shadows a type of the same name that is declared elsewhere, such as in +// `java.lang`. Inference must resolve the name "Number" to the nested type +// `LexicallyNestedType.Number`, which is relevant because it is a subtype of `CharSequence`, rather +// than to `java.lang.Number`, which is irrelevant. If inference resolves the name incorrectly and +// therefore discards the annotation, then the second (validation) pass of this test issues the +// warning that is written below. +public class LexicallyNestedType { + + abstract static class Number implements CharSequence {} + + static Number nestedNumber; + + static void assignField() { + nestedNumber = getSibling1(); + } + + static void useField() { + // :: warning: [argument] + expectsSibling1(nestedNumber); + } + + static void expectsSibling1(@AinferSibling1 Number n) {} + + static @AinferSibling1 Number getSibling1() { + return null; + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/List.java b/checker/tests/ainfer-relevance/non-annotated/List.java new file mode 100644 index 000000000000..3c8f80a6cefc --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/List.java @@ -0,0 +1,6 @@ +// This class exists so that the unnamed package declares a type whose name is also the name of a +// type that `SamePackageShadows.java` imports on demand. +// +// This class is relevant, because it is a subtype of `CharSequence`, which the checker's +// `@RelevantJavaTypes` lists. By contrast, `java.util.List` is irrelevant. +public abstract class List implements CharSequence {} diff --git a/checker/tests/ainfer-relevance/non-annotated/Number.java b/checker/tests/ainfer-relevance/non-annotated/Number.java new file mode 100644 index 000000000000..4a21764aba37 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/Number.java @@ -0,0 +1,11 @@ +// This class exists so that the unnamed package declares a type whose name is also the name of a +// type in `java.lang` and of a nested type in `LexicallyNestedType`. Because this compilation +// unit is in the unnamed package, the simple name "Number" is also this type's fully-qualified +// name. Inference must not resolve every occurrence of the name "Number" to this type; a nested +// type declaration and a type parameter shadow a type of the same name that is declared in the +// same package. (`SamePackageShadows.java` tests the converse: where nothing shadows it, a type +// in the same package takes precedence over an import on demand and over `java.lang`.) +// +// This class is irrelevant, because it is not a subtype of any type in the checker's +// `@RelevantJavaTypes`. +public class Number {} diff --git a/checker/tests/ainfer-relevance/non-annotated/RelevantNestedType.java b/checker/tests/ainfer-relevance/non-annotated/RelevantNestedType.java new file mode 100644 index 000000000000..b334f6b34e73 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/RelevantNestedType.java @@ -0,0 +1,51 @@ +import java.util.AbstractMap; +import java.util.Map; +import java.util.Map.Entry; +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// `Map.Entry` is a nested type and it has type parameters. A program may refer to `Map.Entry` by +// the nested name "Map.Entry", by the simple name "Entry", or by the fully-qualified name +// "java.util.Map.Entry". Inference must retain an annotation on a relevant type no matter how the +// program spells the type's name, and no matter whether the program writes type arguments. +public class RelevantNestedType { + + static Map.Entry nestedName; + static Entry simpleName; + static java.util.Map.Entry qualifiedName; + static Map.Entry rawName; + + // AbstractMap.SimpleEntry is relevant because it is a subtype of Map.Entry, even though the name + // "AbstractMap.SimpleEntry" does not appear in the checker's `@RelevantJavaTypes`. + static AbstractMap.SimpleEntry subtype; + + static void assignFields() { + nestedName = getSibling1(); + simpleName = getSibling1(); + qualifiedName = getSibling1(); + rawName = getSibling1(); + subtype = getSibling1Subtype(); + } + + static void useFields() { + // :: warning: [argument] + expectsSibling1(nestedName); + // :: warning: [argument] + expectsSibling1(simpleName); + // :: warning: [argument] + expectsSibling1(qualifiedName); + // :: warning: [argument] + expectsSibling1Subtype(subtype); + } + + static void expectsSibling1(Map.@AinferSibling1 Entry e) {} + + static void expectsSibling1Subtype(AbstractMap.@AinferSibling1 SimpleEntry e) {} + + static Map.@AinferSibling1 Entry getSibling1() { + return null; + } + + static AbstractMap.@AinferSibling1 SimpleEntry getSibling1Subtype() { + return null; + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/RelevantSubtype.java b/checker/tests/ainfer-relevance/non-annotated/RelevantSubtype.java new file mode 100644 index 000000000000..feffb5ec212c --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/RelevantSubtype.java @@ -0,0 +1,40 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// This class is relevant because it is a subtype of CharSequence, which the checker lists in its +// `@RelevantJavaTypes`. Relevance is a question about subtyping, not about names: the name +// "RelevantSubtype" does not appear in `@RelevantJavaTypes`. +public class RelevantSubtype implements CharSequence { + + @Override + public int length() { + return 0; + } + + @Override + public char charAt(int index) { + return 'a'; + } + + @Override + public CharSequence subSequence(int start, int end) { + return this; + } + + static RelevantSubtype field; + + static void assignField() { + field = getSibling1(); + } + + static void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + static void expectsSibling1(@AinferSibling1 RelevantSubtype r) {} + + @SuppressWarnings("cast.unsafe") // This is how to obtain a @AinferSibling1 RelevantSubtype. + static @AinferSibling1 RelevantSubtype getSibling1() { + return (@AinferSibling1 RelevantSubtype) new RelevantSubtype(); + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/RelevantTypeVariable.java b/checker/tests/ainfer-relevance/non-annotated/RelevantTypeVariable.java new file mode 100644 index 000000000000..6c408496776a --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/RelevantTypeVariable.java @@ -0,0 +1,20 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A use of a type variable is relevant if the type variable's upper bound is relevant. Inference +// must not discard an annotation that it infers for such a use. +public class RelevantTypeVariable { + + T field; + + void assignField(@AinferSibling1 T t) { + // :: warning: [assignment] + field = t; + } + + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + static void expectsSibling1(@AinferSibling1 U u) {} +} diff --git a/checker/tests/ainfer-relevance/non-annotated/RelevantTypes.java b/checker/tests/ainfer-relevance/non-annotated/RelevantTypes.java new file mode 100644 index 000000000000..a58920600e26 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/RelevantTypes.java @@ -0,0 +1,67 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// AinferRelevanceTestChecker declares +// `@RelevantJavaTypes({CharSequence.class, int.class, Map.Entry.class})`. +// Whole-program inference must write, into the .ajava file, every annotation that it infers for a +// relevant type: `int` and `CharSequence`, which are listed; and `String`, which is relevant +// because of subtyping. If inference discards such an annotation, then the second (validation) +// pass of this test issues the warnings that are written below. +public class RelevantTypes { + + static int intField; + static String stringField; + static CharSequence charSequenceField; + + static void assignFields() { + intField = getSibling1Int(); + stringField = getSibling1String(); + charSequenceField = getSibling1String(); + } + + static void useFields() { + // :: warning: [argument] + expectsSibling1Int(intField); + // :: warning: [argument] + expectsSibling1String(stringField); + // :: warning: [argument] + expectsSibling1CharSequence(charSequenceField); + } + + static int intReturn() { + return getSibling1Int(); + } + + static String stringReturn() { + return getSibling1String(); + } + + static void useReturns() { + // :: warning: [argument] + expectsSibling1Int(intReturn()); + // :: warning: [argument] + expectsSibling1String(stringReturn()); + } + + // A leading annotation on a varargs formal parameter is on the parameter's element type, not on + // the array type that `...` creates. + static void varargs(String... args) {} + + static void callVarargs() { + varargs(getSibling1String()); + } + + static void expectsSibling1Int(@AinferSibling1 int i) {} + + static void expectsSibling1String(@AinferSibling1 String s) {} + + static void expectsSibling1CharSequence(@AinferSibling1 CharSequence cs) {} + + static @AinferSibling1 int getSibling1Int() { + return (@AinferSibling1 int) 0; + } + + @SuppressWarnings("cast.unsafe") // This is how to obtain a @AinferSibling1 String. + static @AinferSibling1 String getSibling1String() { + return (@AinferSibling1 String) ""; + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/Runnable.java b/checker/tests/ainfer-relevance/non-annotated/Runnable.java new file mode 100644 index 000000000000..6b39fb1e539d --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/Runnable.java @@ -0,0 +1,6 @@ +// This class exists so that the unnamed package declares a type whose name is also the name of a +// type in `java.lang`. It is used by `SamePackageShadows.java`. +// +// This class is relevant, because it is a subtype of `CharSequence`, which the checker's +// `@RelevantJavaTypes` lists. By contrast, `java.lang.Runnable` is irrelevant. +public abstract class Runnable implements CharSequence {} diff --git a/checker/tests/ainfer-relevance/non-annotated/SamePackageShadows.java b/checker/tests/ainfer-relevance/non-annotated/SamePackageShadows.java new file mode 100644 index 000000000000..e8255d02f17e --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/SamePackageShadows.java @@ -0,0 +1,41 @@ +import java.util.*; +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A type in the compilation unit's own package shadows a type that is imported on demand and a +// type in `java.lang`. This compilation unit is in the unnamed package, whose members are the +// top-level types of every compilation unit that has no package declaration. Inference must +// resolve the name "Runnable" to the top-level class `Runnable` rather than to +// `java.lang.Runnable`, and the name "List" to the top-level class `List` rather than to +// `java.util.List`. Both top-level classes are relevant because they are subtypes of +// `CharSequence`, whereas `java.lang.Runnable` and `java.util.List` are irrelevant. If inference +// resolves either name incorrectly and therefore discards the annotation, then the second +// (validation) pass of this test issues the warning that is written below. +public class SamePackageShadows { + + static Runnable runnableField; + static List listField; + + static void assignFields() { + runnableField = getSibling1Runnable(); + listField = getSibling1List(); + } + + static void useFields() { + // :: warning: [argument] + expectsSibling1Runnable(runnableField); + // :: warning: [argument] + expectsSibling1List(listField); + } + + static void expectsSibling1Runnable(@AinferSibling1 Runnable r) {} + + static void expectsSibling1List(@AinferSibling1 List l) {} + + static @AinferSibling1 Runnable getSibling1Runnable() { + return null; + } + + static @AinferSibling1 List getSibling1List() { + return null; + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/TypeVariableShadowsClass.java b/checker/tests/ainfer-relevance/non-annotated/TypeVariableShadowsClass.java new file mode 100644 index 000000000000..e86e5d9a9412 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/TypeVariableShadowsClass.java @@ -0,0 +1,26 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A type parameter shadows any class of the same name, whether the class is in `java.lang`, in the +// same package, or imported. Inference must resolve the name "Number" to a type parameter -- +// whose upper bound `CharSequence` is relevant -- rather than to `java.lang.Number` or to the +// top-level class `Number`, both of which are irrelevant. If inference resolves the name +// incorrectly and therefore discards the annotation, then the second (validation) pass of this +// test issues the warnings that are written below. +public class TypeVariableShadowsClass { + + Number field; + + void assignField(@AinferSibling1 Number n) { + // :: warning: [assignment] + field = n; + } + + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + // The method's type parameter shadows the class's type parameter and both classes named + // "Number". + static void expectsSibling1(@AinferSibling1 Number n) {} +} From fd94ce87a3f2cfc047e8818e335d3809586617b1 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Tue, 15 Sep 2026 16:32:28 -0700 Subject: [PATCH 02/58] Don't write irrelevant annotations in `.ajava` files When a checker declares `@RelevantJavaTypes`, whole-program inference could write, into an `.ajava` file, an annotation on a Java type that the checker treats as irrelevant. Such an annotation is clutter: omitting it does not change the result of type-checking. Omit such an annotation when writing the `.ajava` file. The test is conservative: it discards an annotation only when the annotation is definitely irrelevant where it appears. Relevance constrains the Java types on which a qualifier may be *written*, so it says nothing about a declaration annotation, even one that is also a type qualifier. JavaParser attaches an annotation that precedes a declaration's type to the declaration rather than to the type, so the two cases cannot be told apart from the AST alone. Mark each annotation that inference adds as a declaration annotation with a JavaParser `DataKey`, and always retain it. Update the `ainfer-relevance` goal files, which previously recorded the old behavior. Co-Authored-By: Claude Opus 5 --- .../IShouldBeSibling1.ajava.goal | 2 +- .../IrrelevantTypes.ajava.goal | 19 +- .../non-annotated/IrrelevantTypes.java | 9 +- docs/CHANGELOG.md | 5 + ...holeProgramInferenceJavaParserStorage.java | 282 ++++++++++++++++-- 5 files changed, 279 insertions(+), 38 deletions(-) diff --git a/checker/tests/ainfer-relevance/IShouldBeSibling1.ajava.goal b/checker/tests/ainfer-relevance/IShouldBeSibling1.ajava.goal index ec581637c4a9..861614bb6047 100644 --- a/checker/tests/ainfer-relevance/IShouldBeSibling1.ajava.goal +++ b/checker/tests/ainfer-relevance/IShouldBeSibling1.ajava.goal @@ -18,7 +18,7 @@ public class IShouldBeSibling1 { // annotation is nonetheless a declaration annotation. @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 @org.checkerframework.dataflow.qual.Pure - @org.checkerframework.checker.testchecker.ainfer.qual.AinferTop double iShouldBeSibling1(double d) { + double iShouldBeSibling1(double d) { return d; } } diff --git a/checker/tests/ainfer-relevance/IrrelevantTypes.ajava.goal b/checker/tests/ainfer-relevance/IrrelevantTypes.ajava.goal index bf17d18ef0df..a2323e2b7c61 100644 --- a/checker/tests/ainfer-relevance/IrrelevantTypes.ajava.goal +++ b/checker/tests/ainfer-relevance/IrrelevantTypes.ajava.goal @@ -1,17 +1,16 @@ -// Inference infers annotations for types that this checker treats as irrelevant: `double`, which -// is not listed in the checker's `@RelevantJavaTypes` and is not related to a listed type by -// subtyping; and arrays, because `Object[].class` is not listed. The goal file records that -// inference currently writes those annotations into the .ajava file. They are clutter: because -// the checker treats these types as irrelevant, omitting them would not change the result of +// Inference must not write, into the .ajava file, an annotation that it infers for an irrelevant +// type: `double`, which is not listed in the checker's `@RelevantJavaTypes` and is not related to +// a listed type by subtyping; or an array, because `Object[].class` is not listed. Because the +// checker treats these types as irrelevant, omitting the annotations does not change the result of // type-checking. @org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") public class IrrelevantTypes { - static @org.checkerframework.checker.testchecker.ainfer.qual.AinferTop double doubleField; + static double doubleField; - static @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType String @org.checkerframework.checker.testchecker.ainfer.qual.AinferTop [] arrayField; + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType String [] arrayField; - static java.util.@org.checkerframework.checker.testchecker.ainfer.qual.AinferTop Date dateField; + static java.util. Date dateField; @org.checkerframework.dataflow.qual.Impure static void assignFields() { @@ -21,12 +20,12 @@ public class IrrelevantTypes { } @org.checkerframework.dataflow.qual.Pure - static @org.checkerframework.checker.testchecker.ainfer.qual.AinferTop double doubleReturn() { + static double doubleReturn() { return doubleField; } @org.checkerframework.dataflow.qual.Pure - static @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType String @org.checkerframework.checker.testchecker.ainfer.qual.AinferTop [] arrayReturn() { + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType String [] arrayReturn() { return arrayField; } } diff --git a/checker/tests/ainfer-relevance/non-annotated/IrrelevantTypes.java b/checker/tests/ainfer-relevance/non-annotated/IrrelevantTypes.java index 0bd21ee13936..31c00223ff95 100644 --- a/checker/tests/ainfer-relevance/non-annotated/IrrelevantTypes.java +++ b/checker/tests/ainfer-relevance/non-annotated/IrrelevantTypes.java @@ -1,8 +1,7 @@ -// Inference infers annotations for types that this checker treats as irrelevant: `double`, which -// is not listed in the checker's `@RelevantJavaTypes` and is not related to a listed type by -// subtyping; and arrays, because `Object[].class` is not listed. The goal file records that -// inference currently writes those annotations into the .ajava file. They are clutter: because -// the checker treats these types as irrelevant, omitting them would not change the result of +// Inference must not write, into the .ajava file, an annotation that it infers for an irrelevant +// type: `double`, which is not listed in the checker's `@RelevantJavaTypes` and is not related to +// a listed type by subtyping; or an array, because `Object[].class` is not listed. Because the +// checker treats these types as irrelevant, omitting the annotations does not change the result of // type-checking. public class IrrelevantTypes { diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b5d4ee08440c..68e501df9fe9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,11 @@ ### User-visible changes +Whole-program inference no longer writes, into an `.ajava` file, an annotation +that is irrelevant where it appears -- that is, one written on a Java type +that the checker's `@RelevantJavaTypes` excludes. Such an annotation was +clutter; omitting it does not change the result of type-checking. + ### Changes for type system implementers `JavaParserUtil`: moved `DEFAULT_LANGUAGE_LEVEL`, `parseCompilationUnit()`, diff --git a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java index 45a41bfd160d..3f8ddc1fc017 100644 --- a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java +++ b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java @@ -1,10 +1,13 @@ package org.checkerframework.common.wholeprograminference; +import com.github.javaparser.ast.ArrayCreationLevel; import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.DataKey; import com.github.javaparser.ast.Node; import com.github.javaparser.ast.NodeList; import com.github.javaparser.ast.PackageDeclaration; import com.github.javaparser.ast.body.AnnotationDeclaration; +import com.github.javaparser.ast.body.AnnotationMemberDeclaration; import com.github.javaparser.ast.body.BodyDeclaration; import com.github.javaparser.ast.body.CallableDeclaration; import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; @@ -19,15 +22,21 @@ import com.github.javaparser.ast.body.TypeDeclaration; import com.github.javaparser.ast.body.VariableDeclarator; import com.github.javaparser.ast.expr.AnnotationExpr; +import com.github.javaparser.ast.expr.ArrayCreationExpr; import com.github.javaparser.ast.expr.CharLiteralExpr; import com.github.javaparser.ast.expr.MarkerAnnotationExpr; import com.github.javaparser.ast.expr.NormalAnnotationExpr; import com.github.javaparser.ast.expr.ObjectCreationExpr; import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr; import com.github.javaparser.ast.expr.StringLiteralExpr; +import com.github.javaparser.ast.nodeTypes.NodeWithAnnotations; +import com.github.javaparser.ast.nodeTypes.NodeWithVariables; +import com.github.javaparser.ast.type.ArrayType; import com.github.javaparser.ast.type.ClassOrInterfaceType; +import com.github.javaparser.ast.type.PrimitiveType; import com.github.javaparser.ast.type.Type; import com.github.javaparser.ast.type.TypeParameter; +import com.github.javaparser.ast.type.VoidType; import com.github.javaparser.ast.visitor.CloneVisitor; import com.github.javaparser.ast.visitor.VoidVisitor; import com.github.javaparser.printer.DefaultPrettyPrinter; @@ -70,11 +79,13 @@ import javax.lang.model.type.TypeKind; import javax.lang.model.type.TypeMirror; import javax.lang.model.util.Elements; +import javax.lang.model.util.Types; import org.checkerframework.afu.scenelib.util.JVMNames; import org.checkerframework.checker.index.qual.Positive; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; import org.checkerframework.checker.nullness.qual.Nullable; import org.checkerframework.checker.signature.qual.BinaryName; +import org.checkerframework.checker.signature.qual.FullyQualifiedName; import org.checkerframework.common.basetype.BaseTypeChecker; import org.checkerframework.common.wholeprograminference.WholeProgramInference.OutputFormat; import org.checkerframework.dataflow.analysis.Analysis; @@ -112,6 +123,14 @@ public class WholeProgramInferenceJavaParserStorage implements WholeProgramInferenceStorage { + /** + * A key for JavaParser {@link Node} data. It marks an annotation that whole-program inference + * added as a declaration annotation rather than as a type qualifier. Such an annotation is + * written to the ajava file even if a type qualifier would be irrelevant where it appears; see + * {@link #annotationIsRelevant}. + */ + private static final DataKey IS_DECLARATION_ANNOTATION = new DataKey() {}; + /** The type factory associated with this. */ protected final AnnotatedTypeFactory atypeFactory; @@ -1050,16 +1069,234 @@ public void writeResultsToFile(OutputFormat outputFormat, BaseTypeChecker checke Path outputPathNoCheckerName = packageDir.resolve(name + ".ajava"); // Avoid re-writing this file for each checker that was run. if (Files.notExists(outputPathNoCheckerName)) { - writeAjavaFile(outputPathNoCheckerName, root); + // This output is supposed to reproduce the original source code, so retain every + // annotation that the programmer wrote. + writeAjavaFile(outputPathNoCheckerName, root, false); } } root.transferAnnotations(checker); - writeAjavaFile(outputPath, root); + writeAjavaFile(outputPath, root, true); } modifiedFiles.clear(); } + /** + * Adds {@code anno} to {@code node}, marking it as a declaration annotation. Use this rather than + * {@code node.addAnnotation(...)} whenever the annotation was inferred for a declaration rather + * than for a type, so that {@link #annotationIsRelevant} does not discard it. + * + * @param node the JavaParser node for a declaration + * @param anno a declaration annotation that was inferred for {@code node} + */ + private static void writeDeclarationAnnotation( + NodeWithAnnotations node, AnnotationMirror anno) { + AnnotationExpr annoExpr = + AnnotationMirrorToAnnotationExprConversion.annotationMirrorToAnnotationExpr(anno); + annoExpr.setData(IS_DECLARATION_ANNOTATION, true); + node.addAnnotation(annoExpr); + } + + /** + * Returns true if the annotation might be relevant (where it appears in the program). + * + * @param anno an annotation + * @return true if the annotation might be relevant + */ + @SuppressWarnings("interning:not.interned") + boolean annotationIsRelevant(AnnotationExpr anno) { + if (!(atypeFactory instanceof GenericAnnotatedTypeFactory gatf)) { + return true; + } + if (gatf.relevantJavaTypes == null) { + return true; + } + + @SuppressWarnings("signature:assignment") // immediately tests + @FullyQualifiedName String aName = anno.getNameAsString(); + if (!atypeFactory.isSupportedQualifier(aName)) { + // The annotation might be a declaration annotation, such as a side effect specification. + return true; + } + + if (anno.containsData(IS_DECLARATION_ANNOTATION)) { + // Inference added the annotation as a declaration annotation, as + // `addMethodDeclarationAnnotation`, `addFieldDeclarationAnnotation`, and + // `addDeclarationAnnotationToFormalParameter` do. Relevance constrains the types on which a + // qualifier may be *written*, so it says nothing about a declaration annotation. + return true; + } + + Node parentNode = anno.getParentNode().orElse(null); + if (parentNode == null) { + return true; + } + + // An annotation that was inferred for a type is attached to a Type node. An annotation that + // was inferred for a declaration is attached to the declaration; it is usually not a type + // qualifier, because a checker should not add a type qualifier as a declaration annotation. + // + // JavaParser attaches an annotation that precedes a declaration's type to the declaration + // rather than to the type, so the two cases cannot be distinguished for a method, a field, or + // a formal parameter. Such an annotation is treated as a type qualifier on the declaration's + // element type -- except when the declaration's type is `void`, on which no type qualifier can + // be written, so the annotation is certainly a declaration annotation. + + if (parentNode instanceof Type type) { + return typeIsRelevant(gatf, type); + } + if (parentNode instanceof ArrayCreationLevel level) { + // The annotation is on an array type, as in `new String @Anno [10]`. + return arrayCreationLevelIsRelevant(gatf, level); + } + if (parentNode instanceof Parameter param) { + // Use reference equality. `NodeList.contains()` would use structural equality, which does + // not distinguish the two annotations in `void m(@Anno String @Anno ... args)`. + if (param.getVarArgsAnnotations().stream().anyMatch(a -> a == anno)) { + // The annotation is on the array type that `...` creates, as in + // `void m(String @Anno ... args)`. + return typeIsRelevant(gatf, param.getType(), 1); + } + // The annotation precedes the type, so it is on the element type; in + // `void m(@Anno String... args)`, `@Anno` is on `String`. + return typeIsRelevant(gatf, innermostComponentType(param.getType())); + } + if (parentNode instanceof ReceiverParameter receiverParam) { + return typeIsRelevant(gatf, innermostComponentType(receiverParam.getType())); + } + if (parentNode instanceof MethodDeclaration method) { + if (method.getType() instanceof VoidType) { + // No type qualifier can be written on `void`, so the annotation is a declaration + // annotation that is also a type qualifier, as `addMethodDeclarationAnnotation` can + // create. Be conservative. + return true; + } + return typeIsRelevant(gatf, innermostComponentType(method.getType())); + } + if (parentNode instanceof AnnotationMemberDeclaration member) { + return typeIsRelevant(gatf, innermostComponentType(member.getType())); + } + if (parentNode instanceof NodeWithVariables declaration) { + // A field declaration or a local variable declaration. All its variables have the same + // element type, even if they have different numbers of array levels as in `int i, a[];`. + NodeList variables = declaration.getVariables(); + if (variables.isEmpty()) { + throw new BugInCF( + "No variables in declaration %s [%s]", declaration, declaration.getClass()); + } + return typeIsRelevant(gatf, innermostComponentType(variables.get(0).getType())); + } + + // The annotation is on some other declaration: a type declaration, a type parameter + // declaration, a constructor, etc. Be conservative. + return true; + } + + /** + * Returns true if a type qualifier that is written on the given type might be relevant. This + * implementation is conservative and only returns false if such a qualifier is definitely not + * relevant. + * + * @param gatf the type factory associated with this + * @param type a JavaParser type + * @return true if a type qualifier written on {@code type} might be relevant + */ + private boolean typeIsRelevant(GenericAnnotatedTypeFactory gatf, Type type) { + return typeIsRelevant(gatf, type, 0); + } + + /** + * Returns true if a type qualifier that is written on the given type might be relevant, where the + * type is {@code componentType} wrapped in {@code arrayLevels} array levels. This implementation + * is conservative and only returns false if such a qualifier is definitely not relevant. + * + * @param gatf the type factory associated with this + * @param componentType a JavaParser type + * @param arrayLevels the number of array levels to wrap {@code componentType} in; may be 0 + * @return true if a type qualifier written on the array type might be relevant + */ + private boolean typeIsRelevant( + GenericAnnotatedTypeFactory gatf, Type componentType, int arrayLevels) { + TypeMirror tm = typeToTypeMirror(componentType); + if (tm == null) { + // The type could not be determined. Be conservative. + return true; + } + Types types = atypeFactory.getProcessingEnv().getTypeUtils(); + for (int i = 0; i < arrayLevels; i++) { + tm = types.getArrayType(tm); + } + return gatf.isRelevant(tm); + } + + /** + * Returns true if the annotation on the given array creation level might be relevant. In {@code + * new String @Anno [10][]}, the annotation is on the type {@code String[][]}. + * + * @param gatf the type factory associated with this + * @param level an array creation level that an annotation is written on + * @return true if the annotation on {@code level} might be relevant + */ + @SuppressWarnings("interning:not.interned") // reference equality of AST nodes + private boolean arrayCreationLevelIsRelevant( + GenericAnnotatedTypeFactory gatf, ArrayCreationLevel level) { + if (!(level.getParentNode().orElse(null) instanceof ArrayCreationExpr creation)) { + // Be conservative. + return true; + } + // Use reference equality; `NodeList.indexOf()` would use structural equality, which does not + // distinguish the two levels in `new String @Anno [10] @Anno [10]`. + NodeList levels = creation.getLevels(); + for (int i = 0; i < levels.size(); i++) { + if (levels.get(i) == level) { + return typeIsRelevant(gatf, creation.getElementType(), levels.size() - i); + } + } + // Be conservative. + return true; + } + + /** + * Returns the TypeMirror for the given JavaParser type, or null if it cannot be determined. + * + * @param type a JavaParser type + * @return the TypeMirror for {@code type}, or null if it cannot be determined + */ + private @Nullable TypeMirror typeToTypeMirror(Type type) { + Types types = atypeFactory.getProcessingEnv().getTypeUtils(); + if (type instanceof ArrayType arrayType) { + TypeMirror componentType = typeToTypeMirror(arrayType.getComponentType()); + return componentType == null ? null : types.getArrayType(componentType); + } + if (type instanceof PrimitiveType primitiveType) { + return types.getPrimitiveType(JavaParserUtil.typeKindForPrimitive(primitiveType)); + } + if (type instanceof VoidType) { + return types.getNoType(TypeKind.VOID); + } + if (type instanceof ClassOrInterfaceType classType) { + TypeElement typeElt = JavaParserUtil.resolveTypeName(elements, classType); + return typeElt == null ? null : typeElt.asType(); + } + // An intersection type, a union type, `var`, a wildcard, a type parameter declaration, etc. + return null; + } + + /** + * Returns the element type of the given type: the type itself if it is not an array type, or its + * innermost component type if it is. + * + * @param type a JavaParser type + * @return the element type of {@code type} + */ + private static Type innermostComponentType(Type type) { + Type componentType = type; + while (componentType instanceof ArrayType arrayType) { + componentType = arrayType.getComponentType(); + } + return componentType; + } + /** * Returns the relative directory that corresponds to a package name. For example, if {@code * separatorChar} is {@code '/'}, then the package name {@code "org.example"} yields {@code @@ -1078,8 +1315,11 @@ public void writeResultsToFile(OutputFormat outputFormat, BaseTypeChecker checke * * @param outputPath the path to which the ajava file should be written * @param root the compilation unit to be written + * @param omitIrrelevantAnnotations if true, do not write annotations that are irrelevant where + * they appear */ - private void writeAjavaFile(Path outputPath, CompilationUnitAnnos root) { + private void writeAjavaFile( + Path outputPath, CompilationUnitAnnos root, boolean omitIrrelevantAnnotations) { try (Writer writer = Files.newBufferedWriter(outputPath, StandardCharsets.UTF_8)) { // This commented implementation uses JavaParser's lexical preserving printing, which @@ -1088,7 +1328,9 @@ private void writeAjavaFile(Path outputPath, CompilationUnitAnnos root) { // and crashes when adding annotations in certain locations. // LexicalPreservingPrinter.print(root.declaration, writer); - // Do not print invisible qualifiers, to avoid cluttering the output. + // To avoid cluttering the output, do not print: + // * invisible qualifiers + // * irrelevant qualifiers, if `omitIrrelevantAnnotations` is true. Set invisibleQualifierNames = getInvisibleQualifierNames(this.atypeFactory); DefaultPrettyPrinter prettyPrinter = new DefaultPrettyPrinter() { @@ -1101,6 +1343,9 @@ public void visit(MarkerAnnotationExpr n, Void arg) { if (invisibleQualifierNames.contains(n.getName().toString())) { return; } + if (omitIrrelevantAnnotations && !annotationIsRelevant(n)) { + return; + } super.visit(n, arg); } @@ -1109,6 +1354,9 @@ public void visit(SingleMemberAnnotationExpr n, Void arg) { if (invisibleQualifierNames.contains(n.getName().toString())) { return; } + if (omitIrrelevantAnnotations && !annotationIsRelevant(n)) { + return; + } super.visit(n, arg); } @@ -1117,6 +1365,9 @@ public void visit(NormalAnnotationExpr n, Void arg) { if (invisibleQualifierNames.contains(n.getName().toString())) { return; } + if (omitIrrelevantAnnotations && !annotationIsRelevant(n)) { + return; + } super.visit(n, arg); } @@ -1426,9 +1677,7 @@ public void transferAnnotations() { if (classAnnotations != null && classDeclaration != null) { for (AnnotationMirror annotation : classAnnotations) { - classDeclaration.addAnnotation( - AnnotationMirrorToAnnotationExprConversion.annotationMirrorToAnnotationExpr( - annotation)); + writeDeclarationAnnotation(classDeclaration, annotation); } } @@ -1808,26 +2057,20 @@ public AnnotatedTypeMirror getPreOrPostconditionsForExpression( public void transferAnnotations() { if (atypeFactory instanceof GenericAnnotatedTypeFactory genericAtf) { for (AnnotationMirror contractAnno : genericAtf.getContractAnnotations(this)) { - declaration.addAnnotation( - AnnotationMirrorToAnnotationExprConversion.annotationMirrorToAnnotationExpr( - contractAnno)); + writeDeclarationAnnotation(declaration, contractAnno); } } if (declarationAnnotations != null) { for (AnnotationMirror annotation : declarationAnnotations) { - declaration.addAnnotation( - AnnotationMirrorToAnnotationExprConversion.annotationMirrorToAnnotationExpr( - annotation)); + writeDeclarationAnnotation(declaration, annotation); } } if (paramsDeclAnnos != null) { for (IPair pair : paramsDeclAnnos) { Parameter param = declaration.getParameter(pair.first - 1); - param.addAnnotation( - AnnotationMirrorToAnnotationExprConversion.annotationMirrorToAnnotationExpr( - pair.second)); + writeDeclarationAnnotation(param, pair.second); } } @@ -2014,13 +2257,8 @@ public void transferAnnotations() { } if (declarationAnnotations != null) { - // Don't add directly to the type of the variable declarator, - // because declaration annotations need to be attached to the FieldDeclaration - // node instead. for (AnnotationMirror annotation : declarationAnnotations) { - decl.addAnnotation( - AnnotationMirrorToAnnotationExprConversion.annotationMirrorToAnnotationExpr( - annotation)); + writeDeclarationAnnotation(decl, annotation); } } From 70d40132bd9ec779b1de89682a9636d90f434939 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Tue, 15 Sep 2026 17:12:51 -0700 Subject: [PATCH 03/58] Compare every generated ajava file to a goal file Goal files existed for only 2 of the 9 ajava files that inference generates for the ainfer-relevance tests. In particular, nothing observed the annotations that inference writes on the irrelevant types in `RelevantSubtype` (`@AinferTop char charAt(...)`) and in `RelevantTypes` (the varargs `String @AinferTop ... args` that `RelevantTypes.java` claims to test): such an annotation does not change the result of type-checking, so the validation pass cannot detect it either. Add the 7 missing goal files, and require a goal file for every generated ajava file and vice versa, so that no inference result goes unexamined. Comment the two test inputs whose goal files now record an annotation on an irrelevant type. Also: * When the goal file or the ajava file is absent, do not advise copying a file that does not exist. * Find the generated ajava files by walking the output directory, and fail with an explanation if one is in a package subdirectory, where the flat goal-file naming scheme could never match it. (Latent today: every test input is in the unnamed package.) * Drop `build/ainfer-relevance` from the directories that `ainferRelevanceGenerateAjava` deletes; nothing creates it. * Say in `AinferRelevanceTestChecker`'s Javadoc that, unlike `AinferTestChecker`, it does not use the Value Checker as a subchecker. Co-Authored-By: Claude Opus 5 --- checker/build.gradle | 1 - .../AinferRelevanceAjavaGenerationTest.java | 140 ++++++++++++++---- .../ainfer/AinferRelevanceTestChecker.java | 5 + .../LexicallyNestedType.ajava.goal | 37 +++++ .../RelevantNestedType.ajava.goal | 65 ++++++++ .../RelevantSubtype.ajava.goal | 49 ++++++ .../RelevantTypeVariable.ajava.goal | 25 ++++ .../ainfer-relevance/RelevantTypes.ajava.goal | 88 +++++++++++ .../SamePackageShadows.ajava.goal | 53 +++++++ .../TypeVariableShadowsClass.ajava.goal | 31 ++++ .../non-annotated/RelevantSubtype.java | 3 + .../non-annotated/RelevantTypes.java | 4 +- 12 files changed, 472 insertions(+), 29 deletions(-) create mode 100644 checker/tests/ainfer-relevance/LexicallyNestedType.ajava.goal create mode 100644 checker/tests/ainfer-relevance/RelevantNestedType.ajava.goal create mode 100644 checker/tests/ainfer-relevance/RelevantSubtype.ajava.goal create mode 100644 checker/tests/ainfer-relevance/RelevantTypeVariable.ajava.goal create mode 100644 checker/tests/ainfer-relevance/RelevantTypes.ajava.goal create mode 100644 checker/tests/ainfer-relevance/SamePackageShadows.ajava.goal create mode 100644 checker/tests/ainfer-relevance/TypeVariableShadowsClass.ajava.goal diff --git a/checker/build.gradle b/checker/build.gradle index 2229e897cec8..61f6b475fedb 100644 --- a/checker/build.gradle +++ b/checker/build.gradle @@ -710,7 +710,6 @@ tasks.register("ainferRelevanceGenerateAjava", Test) { // compare the inference output to the goal files. List ainferRelevanceDirsToDelete = [ file("tests/ainfer-relevance/annotated"), - layout.buildDirectory.dir("ainfer-relevance").get().asFile, layout.buildDirectory.dir("whole-program-inference").get().asFile, ] doFirst { diff --git a/checker/src/test/java/org/checkerframework/checker/test/junit/ainferrunners/AinferRelevanceAjavaGenerationTest.java b/checker/src/test/java/org/checkerframework/checker/test/junit/ainferrunners/AinferRelevanceAjavaGenerationTest.java index 5bba52169330..4b32dd1554ee 100644 --- a/checker/src/test/java/org/checkerframework/checker/test/junit/ainferrunners/AinferRelevanceAjavaGenerationTest.java +++ b/checker/src/test/java/org/checkerframework/checker/test/junit/ainferrunners/AinferRelevanceAjavaGenerationTest.java @@ -7,6 +7,9 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.stream.Collectors; import java.util.stream.Stream; import org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker; import org.checkerframework.framework.test.AinferGeneratePerDirectoryTest; @@ -57,48 +60,127 @@ public static String[] getTestDirs() { /** The suffix of a goal file's name. */ private static final String goalSuffix = ".ajava.goal"; + /** The suffix of a generated ajava file's name. */ + private static final String ajavaSuffix = + "-" + AinferRelevanceTestChecker.class.getCanonicalName() + ".ajava"; + /** - * Compares each generated ajava file to its goal file, if the goal file exists. A goal file is - * named {@code .ajava.goal}. + * Compares each generated ajava file to its goal file. A goal file is named {@code + * .ajava.goal}. Every generated ajava file must have a goal file and vice versa, so + * that no inference result goes unexamined. * *

Unlike the second (validation) pass of this test, this comparison detects an annotation that * inference wrote even though the annotation is irrelevant where it appears. Such an annotation * clutters the ajava file, but it does not change the result of type-checking, so no diagnostic * would reveal it. + * + * @throws IOException if a file cannot be listed or read */ @AfterClass public static void compareToGoalFiles() throws IOException { - try (Stream goalFiles = Files.list(goalDir)) { - goalFiles - .filter(goalFile -> goalFile.getFileName().toString().endsWith(goalSuffix)) - .forEach(AinferRelevanceAjavaGenerationTest::compareToGoalFile); + if (!Files.isDirectory(inferenceOutputDir)) { + Assert.fail( + String.format( + "Inference created no directory %s, so it wrote no ajava file.%n", + inferenceOutputDir.toAbsolutePath())); + } + + // Goal files that no generated ajava file corresponds to. Entries are removed below. + SortedSet goalFilesWithoutAjavaFile = new TreeSet<>(goalFiles()); + + for (Path ajavaFile : generatedAjavaFiles()) { + Path goalFile = goalFileFor(ajavaFile); + goalFilesWithoutAjavaFile.remove(goalFile); + compareToGoalFile(ajavaFile, goalFile); + } + + if (!goalFilesWithoutAjavaFile.isEmpty()) { + Assert.fail( + String.format( + "Inference wrote no ajava file for these goal files: %s%n" + + "Either inference inferred nothing for those classes, or the ajava files have" + + " unexpected names. If inferring nothing is desirable, delete the goal" + + " files.%n", + goalFilesWithoutAjavaFile)); } } /** - * Compares the generated ajava file that corresponds to the given goal file, to the goal file. + * Returns the goal files: the files in {@link #goalDir} whose names end in {@link #goalSuffix}. * - * @param goalFile a goal file + * @return the goal files + * @throws IOException if {@link #goalDir} cannot be listed */ - private static void compareToGoalFile(Path goalFile) { - String goalFileName = goalFile.getFileName().toString(); - String className = goalFileName.substring(0, goalFileName.length() - goalSuffix.length()); - Path ajavaFile = - inferenceOutputDir.resolve( - className + "-" + AinferRelevanceTestChecker.class.getCanonicalName() + ".ajava"); - String message = - String.format( - "%s differs from %s. If the difference is desirable, overwrite the goal file:%n" - + " cp %s %s%n", - ajavaFile.toAbsolutePath(), - goalFile.toAbsolutePath(), - ajavaFile.toAbsolutePath(), - goalFile.toAbsolutePath()); - if (!Files.exists(ajavaFile)) { - // Inference wrote no ajava file for the class: it inferred nothing, or the file has an - // unexpected name. Report this the same way as a difference in contents, rather than - // throwing an uninformative IOException. - Assert.fail(message); + private static List goalFiles() throws IOException { + try (Stream files = Files.list(goalDir)) { + return files + .filter(file -> file.getFileName().toString().endsWith(goalSuffix)) + .collect(Collectors.toList()); + } + } + + /** + * Returns the ajava files that inference wrote for {@link AinferRelevanceTestChecker}. The search + * is recursive, because inference writes a class's ajava file into a subdirectory that + * corresponds to the class's package. + * + * @return the generated ajava files + * @throws IOException if {@link #inferenceOutputDir} cannot be walked + */ + private static List generatedAjavaFiles() throws IOException { + try (Stream files = Files.walk(inferenceOutputDir)) { + return files + .filter(file -> file.getFileName().toString().endsWith(ajavaSuffix)) + .sorted() + .collect(Collectors.toList()); + } + } + + /** + * Returns the goal file that corresponds to the given generated ajava file. The goal file need + * not exist. + * + * @param ajavaFile a generated ajava file + * @return the goal file that corresponds to {@code ajavaFile} + */ + private static Path goalFileFor(Path ajavaFile) { + if (!inferenceOutputDir.equals(ajavaFile.getParent())) { + // Inference writes a class's ajava file into a subdirectory that corresponds to the class's + // package, but the goal files are all in one directory, so a goal file for a class in a + // named package could never match. Every test input is in the unnamed package, so this + // failure means that a new test input declares a package. + Assert.fail( + String.format( + "%s is not directly in %s, so its class is not in the unnamed package. Either put" + + " the test input in the unnamed package, or generalize" + + " AinferRelevanceAjavaGenerationTest to give each goal file a name that" + + " includes the package.%n", + ajavaFile.toAbsolutePath(), inferenceOutputDir.toAbsolutePath())); + } + String ajavaFileName = ajavaFile.getFileName().toString(); + String className = ajavaFileName.substring(0, ajavaFileName.length() - ajavaSuffix.length()); + return goalDir.resolve(className + goalSuffix); + } + + /** + * Compares a generated ajava file to its goal file. + * + * @param ajavaFile a generated ajava file + * @param goalFile the goal file that corresponds to {@code ajavaFile} + */ + private static void compareToGoalFile(Path ajavaFile, Path goalFile) { + String copyCommand = + String.format(" cp %s %s%n", ajavaFile.toAbsolutePath(), goalFile.toAbsolutePath()); + if (!Files.exists(goalFile)) { + // Requiring a goal file for every generated ajava file ensures that no inference result -- + // in particular, no annotation that inference wrote on an irrelevant type -- goes + // unexamined. + Assert.fail( + String.format( + "Goal file %s does not exist, but inference wrote %s. Every generated ajava file" + + " needs a goal file. If the generated file is correct, create the goal" + + " file:%n%s", + goalFile.toAbsolutePath(), ajavaFile.toAbsolutePath(), copyCommand)); } String goalContents; String ajavaContents; @@ -108,6 +190,10 @@ private static void compareToGoalFile(Path goalFile) { } catch (IOException e) { throw new UncheckedIOException(e); } + String message = + String.format( + "%s differs from %s. If the difference is desirable, overwrite the goal file:%n%s", + ajavaFile.toAbsolutePath(), goalFile.toAbsolutePath(), copyCommand); Assert.assertEquals(message, goalContents, ajavaContents); } } diff --git a/checker/src/test/java/org/checkerframework/checker/testchecker/ainfer/AinferRelevanceTestChecker.java b/checker/src/test/java/org/checkerframework/checker/testchecker/ainfer/AinferRelevanceTestChecker.java index 704ed10a4cf3..79e22b315837 100644 --- a/checker/src/test/java/org/checkerframework/checker/testchecker/ainfer/AinferRelevanceTestChecker.java +++ b/checker/src/test/java/org/checkerframework/checker/testchecker/ainfer/AinferRelevanceTestChecker.java @@ -9,6 +9,11 @@ * Like {@link AinferTestChecker}, but only some Java types are relevant. It uses the same type * system and the same visitor. * + *

Unlike {@link AinferTestChecker}, this checker does not use the Value Checker as a subchecker. + * A subchecker would not make these tests test relevance any more thoroughly, and its inference + * results would clutter the goal files that {@code AinferRelevanceAjavaGenerationTest} compares + * against. + * *

This checker exists so that the tests exercise whole-program inference for a checker that * restricts the Java types on which its qualifiers may be written. {@code int} and {@code * CharSequence} are relevant because they are listed. {@code String} is relevant because of diff --git a/checker/tests/ainfer-relevance/LexicallyNestedType.ajava.goal b/checker/tests/ainfer-relevance/LexicallyNestedType.ajava.goal new file mode 100644 index 000000000000..9c24905a55f5 --- /dev/null +++ b/checker/tests/ainfer-relevance/LexicallyNestedType.ajava.goal @@ -0,0 +1,37 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A nested type declaration shadows a type of the same name that is declared elsewhere, such as in +// `java.lang`. Inference must resolve the name "Number" to the nested type +// `LexicallyNestedType.Number`, which is relevant because it is a subtype of `CharSequence`, rather +// than to `java.lang.Number`, which is irrelevant. If inference resolves the name incorrectly and +// therefore discards the annotation, then the second (validation) pass of this test issues the +// warning that is written below. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class LexicallyNestedType { + + abstract static class Number implements CharSequence { + } + + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 Number nestedNumber; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "LexicallyNestedType.class.nestedNumber" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + static void assignField() { + nestedNumber = getSibling1(); + } + + @org.checkerframework.dataflow.qual.Impure + static void useField() { + // :: warning: [argument] + expectsSibling1(nestedNumber); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + static void expectsSibling1(Number n) { + } + + @org.checkerframework.dataflow.qual.Pure + static Number getSibling1() { + return null; + } +} diff --git a/checker/tests/ainfer-relevance/RelevantNestedType.ajava.goal b/checker/tests/ainfer-relevance/RelevantNestedType.ajava.goal new file mode 100644 index 000000000000..16a5e7f5c3b7 --- /dev/null +++ b/checker/tests/ainfer-relevance/RelevantNestedType.ajava.goal @@ -0,0 +1,65 @@ +import java.util.AbstractMap; +import java.util.Map; +import java.util.Map.Entry; +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// `Map.Entry` is a nested type and it has type parameters. A program may refer to `Map.Entry` by +// the nested name "Map.Entry", by the simple name "Entry", or by the fully-qualified name +// "java.util.Map.Entry". Inference must retain an annotation on a relevant type no matter how the +// program spells the type's name, and no matter whether the program writes type arguments. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class RelevantNestedType { + + static Map.@org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 Entry nestedName; + + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 Entry simpleName; + + static java.util.Map.@org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 Entry qualifiedName; + + static Map.@org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 Entry rawName; + + // AbstractMap.SimpleEntry is relevant because it is a subtype of Map.Entry, even though the name + // "AbstractMap.SimpleEntry" does not appear in the checker's `@RelevantJavaTypes`. + static AbstractMap.@org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 SimpleEntry subtype; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "RelevantNestedType.class.subtype" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + static void assignFields() { + nestedName = getSibling1(); + simpleName = getSibling1(); + qualifiedName = getSibling1(); + rawName = getSibling1(); + subtype = getSibling1Subtype(); + } + + @org.checkerframework.dataflow.qual.Impure + static void useFields() { + // :: warning: [argument] + expectsSibling1(nestedName); + // :: warning: [argument] + expectsSibling1(simpleName); + // :: warning: [argument] + expectsSibling1(qualifiedName); + // :: warning: [argument] + expectsSibling1Subtype(subtype); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + static void expectsSibling1(Map.Entry e) { + } + + @org.checkerframework.dataflow.qual.SideEffectFree + static void expectsSibling1Subtype(AbstractMap.SimpleEntry e) { + } + + @org.checkerframework.dataflow.qual.Pure + static Map.Entry getSibling1() { + return null; + } + + @org.checkerframework.framework.qual.RequiresQualifier(expression = { "RelevantNestedType.class.rawName" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Pure + static AbstractMap.SimpleEntry getSibling1Subtype() { + return null; + } +} diff --git a/checker/tests/ainfer-relevance/RelevantSubtype.ajava.goal b/checker/tests/ainfer-relevance/RelevantSubtype.ajava.goal new file mode 100644 index 000000000000..c311becdab3c --- /dev/null +++ b/checker/tests/ainfer-relevance/RelevantSubtype.ajava.goal @@ -0,0 +1,49 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// This class is relevant because it is a subtype of CharSequence, which the checker lists in its +// `@RelevantJavaTypes`. Relevance is a question about subtyping, not about names: the name +// "RelevantSubtype" does not appear in `@RelevantJavaTypes`. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class RelevantSubtype implements CharSequence { + + @org.checkerframework.dataflow.qual.Pure + public @org.checkerframework.checker.testchecker.ainfer.qual.AinferBottom int length(@org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType RelevantSubtype this) { + return 0; + } + + // `char` is irrelevant to this checker: it is not listed in `@RelevantJavaTypes`, and it is not + // related to a listed type by subtyping. The goal file records that inference nonetheless + // writes `@AinferTop` on this return type. + @org.checkerframework.dataflow.qual.Pure + public @org.checkerframework.checker.testchecker.ainfer.qual.AinferTop char charAt(@org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType RelevantSubtype this, @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType int index) { + return 'a'; + } + + @org.checkerframework.dataflow.qual.Pure + public @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType CharSequence subSequence(@org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType RelevantSubtype this, @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType int start, @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType int end) { + return this; + } + + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 RelevantSubtype field; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "RelevantSubtype.class.field" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + static void assignField() { + field = getSibling1(); + } + + @org.checkerframework.dataflow.qual.Impure + static void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + static void expectsSibling1(RelevantSubtype r) { + } + + @org.checkerframework.dataflow.qual.Impure + static RelevantSubtype getSibling1() { + return (RelevantSubtype) new RelevantSubtype(); + } +} diff --git a/checker/tests/ainfer-relevance/RelevantTypeVariable.ajava.goal b/checker/tests/ainfer-relevance/RelevantTypeVariable.ajava.goal new file mode 100644 index 000000000000..2a0690dd6ce6 --- /dev/null +++ b/checker/tests/ainfer-relevance/RelevantTypeVariable.ajava.goal @@ -0,0 +1,25 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A use of a type variable is relevant if the type variable's upper bound is relevant. Inference +// must not discard an annotation that it infers for such a use. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class RelevantTypeVariable { + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 T field; + + @org.checkerframework.dataflow.qual.Impure + void assignField(T t) { + // :: warning: [assignment] + field = t; + } + + @org.checkerframework.dataflow.qual.Impure + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + static void expectsSibling1(U u) { + } +} diff --git a/checker/tests/ainfer-relevance/RelevantTypes.ajava.goal b/checker/tests/ainfer-relevance/RelevantTypes.ajava.goal new file mode 100644 index 000000000000..9f7f2abc8765 --- /dev/null +++ b/checker/tests/ainfer-relevance/RelevantTypes.ajava.goal @@ -0,0 +1,88 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// AinferRelevanceTestChecker declares +// `@RelevantJavaTypes({CharSequence.class, int.class, Map.Entry.class})`. +// Whole-program inference must write, into the .ajava file, every annotation that it infers for a +// relevant type: `int` and `CharSequence`, which are listed; and `String`, which is relevant +// because of subtyping. If inference discards such an annotation, then the second (validation) +// pass of this test issues the warnings that are written below. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class RelevantTypes { + + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 int intField; + + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 String stringField; + + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 CharSequence charSequenceField; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "RelevantTypes.class.charSequenceField" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + static void assignFields() { + intField = getSibling1Int(); + stringField = getSibling1String(); + charSequenceField = getSibling1String(); + } + + @org.checkerframework.dataflow.qual.Impure + static void useFields() { + // :: warning: [argument] + expectsSibling1Int(intField); + // :: warning: [argument] + expectsSibling1String(stringField); + // :: warning: [argument] + expectsSibling1CharSequence(charSequenceField); + } + + @org.checkerframework.dataflow.qual.Impure + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 int intReturn() { + return getSibling1Int(); + } + + @org.checkerframework.dataflow.qual.Impure + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 String stringReturn() { + return getSibling1String(); + } + + @org.checkerframework.dataflow.qual.Impure + static void useReturns() { + // :: warning: [argument] + expectsSibling1Int(intReturn()); + // :: warning: [argument] + expectsSibling1String(stringReturn()); + } + + // A leading annotation on a varargs formal parameter is on the parameter's element type, not on + // the array type that `...` creates. The goal file shows that inference writes an annotation on + // each of the two: `@AinferSibling1 String @AinferTop ...`. The annotation on the array type + // is clutter, because arrays are irrelevant to this checker. + @org.checkerframework.dataflow.qual.SideEffectFree + static void varargs(@org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 String@org.checkerframework.checker.testchecker.ainfer.qual.AinferTop ... args) { + } + + @org.checkerframework.dataflow.qual.Impure + static void callVarargs() { + varargs(getSibling1String()); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + static void expectsSibling1Int(int i) { + } + + @org.checkerframework.dataflow.qual.SideEffectFree + static void expectsSibling1String(String s) { + } + + @org.checkerframework.dataflow.qual.SideEffectFree + static void expectsSibling1CharSequence(CharSequence cs) { + } + + @org.checkerframework.dataflow.qual.Pure + static int getSibling1Int() { + return (int) 0; + } + + @org.checkerframework.dataflow.qual.Pure + static String getSibling1String() { + return (String) ""; + } +} diff --git a/checker/tests/ainfer-relevance/SamePackageShadows.ajava.goal b/checker/tests/ainfer-relevance/SamePackageShadows.ajava.goal new file mode 100644 index 000000000000..f3cb627ee823 --- /dev/null +++ b/checker/tests/ainfer-relevance/SamePackageShadows.ajava.goal @@ -0,0 +1,53 @@ +import java.util.*; +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A type in the compilation unit's own package shadows a type that is imported on demand and a +// type in `java.lang`. This compilation unit is in the unnamed package, whose members are the +// top-level types of every compilation unit that has no package declaration. Inference must +// resolve the name "Runnable" to the top-level class `Runnable` rather than to +// `java.lang.Runnable`, and the name "List" to the top-level class `List` rather than to +// `java.util.List`. Both top-level classes are relevant because they are subtypes of +// `CharSequence`, whereas `java.lang.Runnable` and `java.util.List` are irrelevant. If inference +// resolves either name incorrectly and therefore discards the annotation, then the second +// (validation) pass of this test issues the warning that is written below. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class SamePackageShadows { + + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 Runnable runnableField; + + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 List listField; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "SamePackageShadows.class.listField" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + static void assignFields() { + runnableField = getSibling1Runnable(); + listField = getSibling1List(); + } + + @org.checkerframework.dataflow.qual.Impure + static void useFields() { + // :: warning: [argument] + expectsSibling1Runnable(runnableField); + // :: warning: [argument] + expectsSibling1List(listField); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + static void expectsSibling1Runnable(Runnable r) { + } + + @org.checkerframework.dataflow.qual.SideEffectFree + static void expectsSibling1List(List l) { + } + + @org.checkerframework.dataflow.qual.Pure + static Runnable getSibling1Runnable() { + return null; + } + + @org.checkerframework.framework.qual.RequiresQualifier(expression = { "SamePackageShadows.class.runnableField" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Pure + static List getSibling1List() { + return null; + } +} diff --git a/checker/tests/ainfer-relevance/TypeVariableShadowsClass.ajava.goal b/checker/tests/ainfer-relevance/TypeVariableShadowsClass.ajava.goal new file mode 100644 index 000000000000..336935609095 --- /dev/null +++ b/checker/tests/ainfer-relevance/TypeVariableShadowsClass.ajava.goal @@ -0,0 +1,31 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A type parameter shadows any class of the same name, whether the class is in `java.lang`, in the +// same package, or imported. Inference must resolve the name "Number" to a type parameter -- +// whose upper bound `CharSequence` is relevant -- rather than to `java.lang.Number` or to the +// top-level class `Number`, both of which are irrelevant. If inference resolves the name +// incorrectly and therefore discards the annotation, then the second (validation) pass of this +// test issues the warnings that are written below. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class TypeVariableShadowsClass { + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 Number field; + + @org.checkerframework.dataflow.qual.Impure + void assignField(Number n) { + // :: warning: [assignment] + field = n; + } + + @org.checkerframework.dataflow.qual.Impure + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + // The method's type parameter shadows the class's type parameter and both classes named + // "Number". + @org.checkerframework.dataflow.qual.SideEffectFree + static void expectsSibling1(Number n) { + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/RelevantSubtype.java b/checker/tests/ainfer-relevance/non-annotated/RelevantSubtype.java index feffb5ec212c..2caa12c7e6fd 100644 --- a/checker/tests/ainfer-relevance/non-annotated/RelevantSubtype.java +++ b/checker/tests/ainfer-relevance/non-annotated/RelevantSubtype.java @@ -10,6 +10,9 @@ public int length() { return 0; } + // `char` is irrelevant to this checker: it is not listed in `@RelevantJavaTypes`, and it is not + // related to a listed type by subtyping. The goal file records that inference nonetheless + // writes `@AinferTop` on this return type. @Override public char charAt(int index) { return 'a'; diff --git a/checker/tests/ainfer-relevance/non-annotated/RelevantTypes.java b/checker/tests/ainfer-relevance/non-annotated/RelevantTypes.java index a58920600e26..dafd45dc20c3 100644 --- a/checker/tests/ainfer-relevance/non-annotated/RelevantTypes.java +++ b/checker/tests/ainfer-relevance/non-annotated/RelevantTypes.java @@ -43,7 +43,9 @@ static void useReturns() { } // A leading annotation on a varargs formal parameter is on the parameter's element type, not on - // the array type that `...` creates. + // the array type that `...` creates. The goal file shows that inference writes an annotation on + // each of the two: `@AinferSibling1 String @AinferTop ...`. The annotation on the array type + // is clutter, because arrays are irrelevant to this checker. static void varargs(String... args) {} static void callVarargs() { From bc6efa2ffe548b9ba388f680cf97c8e5386adc35 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Wed, 16 Sep 2026 07:40:10 -0700 Subject: [PATCH 04/58] Update the goal files that the merge of `ainfer-relevance-tests` left stale The `RelevantSubtype` and `RelevantTypes` goal files were generated on the `ainfer-relevance-tests` branch, which did not contain the commit that omits irrelevant annotations, so they record the old behavior: an `@AinferTop` on the return type `char`, and an `@AinferTop` on the array type that a varargs `...` creates. Neither annotation is written any more, so `ainferRelevanceGenerateAjava` failed. Regenerate the goal files, and update the two test inputs' comments, which likewise describe the old behavior. Co-Authored-By: Claude Opus 5 --- checker/tests/ainfer-relevance/RelevantSubtype.ajava.goal | 6 +++--- checker/tests/ainfer-relevance/RelevantTypes.ajava.goal | 8 ++++---- .../ainfer-relevance/non-annotated/RelevantSubtype.java | 4 ++-- .../ainfer-relevance/non-annotated/RelevantTypes.java | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/checker/tests/ainfer-relevance/RelevantSubtype.ajava.goal b/checker/tests/ainfer-relevance/RelevantSubtype.ajava.goal index c311becdab3c..a2d440001fd1 100644 --- a/checker/tests/ainfer-relevance/RelevantSubtype.ajava.goal +++ b/checker/tests/ainfer-relevance/RelevantSubtype.ajava.goal @@ -12,10 +12,10 @@ public class RelevantSubtype implements CharSequence { } // `char` is irrelevant to this checker: it is not listed in `@RelevantJavaTypes`, and it is not - // related to a listed type by subtyping. The goal file records that inference nonetheless - // writes `@AinferTop` on this return type. + // related to a listed type by subtyping. The goal file shows that inference omits the + // `@AinferTop` that it infers for this return type. @org.checkerframework.dataflow.qual.Pure - public @org.checkerframework.checker.testchecker.ainfer.qual.AinferTop char charAt(@org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType RelevantSubtype this, @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType int index) { + public char charAt(@org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType RelevantSubtype this, @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType int index) { return 'a'; } diff --git a/checker/tests/ainfer-relevance/RelevantTypes.ajava.goal b/checker/tests/ainfer-relevance/RelevantTypes.ajava.goal index 9f7f2abc8765..064c1162bab0 100644 --- a/checker/tests/ainfer-relevance/RelevantTypes.ajava.goal +++ b/checker/tests/ainfer-relevance/RelevantTypes.ajava.goal @@ -52,11 +52,11 @@ public class RelevantTypes { } // A leading annotation on a varargs formal parameter is on the parameter's element type, not on - // the array type that `...` creates. The goal file shows that inference writes an annotation on - // each of the two: `@AinferSibling1 String @AinferTop ...`. The annotation on the array type - // is clutter, because arrays are irrelevant to this checker. + // the array type that `...` creates. Inference infers an annotation for each of the two, but + // the goal file contains only `@AinferSibling1 String ...`: the annotation on the array type is + // omitted, because arrays are irrelevant to this checker. @org.checkerframework.dataflow.qual.SideEffectFree - static void varargs(@org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 String@org.checkerframework.checker.testchecker.ainfer.qual.AinferTop ... args) { + static void varargs(@org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 String ... args) { } @org.checkerframework.dataflow.qual.Impure diff --git a/checker/tests/ainfer-relevance/non-annotated/RelevantSubtype.java b/checker/tests/ainfer-relevance/non-annotated/RelevantSubtype.java index 2caa12c7e6fd..dad139884902 100644 --- a/checker/tests/ainfer-relevance/non-annotated/RelevantSubtype.java +++ b/checker/tests/ainfer-relevance/non-annotated/RelevantSubtype.java @@ -11,8 +11,8 @@ public int length() { } // `char` is irrelevant to this checker: it is not listed in `@RelevantJavaTypes`, and it is not - // related to a listed type by subtyping. The goal file records that inference nonetheless - // writes `@AinferTop` on this return type. + // related to a listed type by subtyping. The goal file shows that inference omits the + // `@AinferTop` that it infers for this return type. @Override public char charAt(int index) { return 'a'; diff --git a/checker/tests/ainfer-relevance/non-annotated/RelevantTypes.java b/checker/tests/ainfer-relevance/non-annotated/RelevantTypes.java index dafd45dc20c3..cba65a2a861d 100644 --- a/checker/tests/ainfer-relevance/non-annotated/RelevantTypes.java +++ b/checker/tests/ainfer-relevance/non-annotated/RelevantTypes.java @@ -43,9 +43,9 @@ static void useReturns() { } // A leading annotation on a varargs formal parameter is on the parameter's element type, not on - // the array type that `...` creates. The goal file shows that inference writes an annotation on - // each of the two: `@AinferSibling1 String @AinferTop ...`. The annotation on the array type - // is clutter, because arrays are irrelevant to this checker. + // the array type that `...` creates. Inference infers an annotation for each of the two, but + // the goal file contains only `@AinferSibling1 String ...`: the annotation on the array type is + // omitted, because arrays are irrelevant to this checker. static void varargs(String... args) {} static void callVarargs() { From cd44e44fe77f3690bc1ddf28dc2b1a9937495b1b Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Wed, 16 Sep 2026 07:40:43 -0700 Subject: [PATCH 05/58] Remove an omitted annotation from the AST, not from the pretty-printer When the pretty-printer skipped an annotation, it had already printed the whitespace that separates the annotation from what follows it, so each omitted annotation left a stray space or blank line: `java.util. Date`, `static double`, `String [] []`. This partly defeats the purpose of omitting the annotation, which is to reduce clutter. Instead, remove the annotations that should not be printed -- invisible qualifiers, and irrelevant qualifiers when they are being omitted -- from a clone of the compilation unit, and print that. The pretty-printer then outputs no separator for them. This also fixes the stray whitespace that an invisible qualifier left, which predates the omission of irrelevant qualifiers. Co-Authored-By: Claude Opus 5 --- .../IShouldBeSibling1.ajava.goal | 2 +- .../IrrelevantTypes.ajava.goal | 10 +-- .../RelevantSubtype.ajava.goal | 2 +- .../ainfer-relevance/RelevantTypes.ajava.goal | 2 +- ...holeProgramInferenceJavaParserStorage.java | 82 +++++++++---------- 5 files changed, 49 insertions(+), 49 deletions(-) diff --git a/checker/tests/ainfer-relevance/IShouldBeSibling1.ajava.goal b/checker/tests/ainfer-relevance/IShouldBeSibling1.ajava.goal index 861614bb6047..620df29c3c02 100644 --- a/checker/tests/ainfer-relevance/IShouldBeSibling1.ajava.goal +++ b/checker/tests/ainfer-relevance/IShouldBeSibling1.ajava.goal @@ -18,7 +18,7 @@ public class IShouldBeSibling1 { // annotation is nonetheless a declaration annotation. @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 @org.checkerframework.dataflow.qual.Pure - double iShouldBeSibling1(double d) { + double iShouldBeSibling1(double d) { return d; } } diff --git a/checker/tests/ainfer-relevance/IrrelevantTypes.ajava.goal b/checker/tests/ainfer-relevance/IrrelevantTypes.ajava.goal index a2323e2b7c61..f7dd7e7547a4 100644 --- a/checker/tests/ainfer-relevance/IrrelevantTypes.ajava.goal +++ b/checker/tests/ainfer-relevance/IrrelevantTypes.ajava.goal @@ -6,11 +6,11 @@ @org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") public class IrrelevantTypes { - static double doubleField; + static double doubleField; - static @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType String [] arrayField; + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType String[] arrayField; - static java.util. Date dateField; + static java.util.Date dateField; @org.checkerframework.dataflow.qual.Impure static void assignFields() { @@ -20,12 +20,12 @@ public class IrrelevantTypes { } @org.checkerframework.dataflow.qual.Pure - static double doubleReturn() { + static double doubleReturn() { return doubleField; } @org.checkerframework.dataflow.qual.Pure - static @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType String [] arrayReturn() { + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType String[] arrayReturn() { return arrayField; } } diff --git a/checker/tests/ainfer-relevance/RelevantSubtype.ajava.goal b/checker/tests/ainfer-relevance/RelevantSubtype.ajava.goal index a2d440001fd1..7f3edd26709a 100644 --- a/checker/tests/ainfer-relevance/RelevantSubtype.ajava.goal +++ b/checker/tests/ainfer-relevance/RelevantSubtype.ajava.goal @@ -15,7 +15,7 @@ public class RelevantSubtype implements CharSequence { // related to a listed type by subtyping. The goal file shows that inference omits the // `@AinferTop` that it infers for this return type. @org.checkerframework.dataflow.qual.Pure - public char charAt(@org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType RelevantSubtype this, @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType int index) { + public char charAt(@org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType RelevantSubtype this, @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType int index) { return 'a'; } diff --git a/checker/tests/ainfer-relevance/RelevantTypes.ajava.goal b/checker/tests/ainfer-relevance/RelevantTypes.ajava.goal index 064c1162bab0..1a3e10b5e757 100644 --- a/checker/tests/ainfer-relevance/RelevantTypes.ajava.goal +++ b/checker/tests/ainfer-relevance/RelevantTypes.ajava.goal @@ -56,7 +56,7 @@ public class RelevantTypes { // the goal file contains only `@AinferSibling1 String ...`: the annotation on the array type is // omitted, because arrays are irrelevant to this checker. @org.checkerframework.dataflow.qual.SideEffectFree - static void varargs(@org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 String ... args) { + static void varargs(@org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 String... args) { } @org.checkerframework.dataflow.qual.Impure diff --git a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java index 3f8ddc1fc017..ef7e2f57ba0a 100644 --- a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java +++ b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java @@ -24,10 +24,7 @@ import com.github.javaparser.ast.expr.AnnotationExpr; import com.github.javaparser.ast.expr.ArrayCreationExpr; import com.github.javaparser.ast.expr.CharLiteralExpr; -import com.github.javaparser.ast.expr.MarkerAnnotationExpr; -import com.github.javaparser.ast.expr.NormalAnnotationExpr; import com.github.javaparser.ast.expr.ObjectCreationExpr; -import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr; import com.github.javaparser.ast.expr.StringLiteralExpr; import com.github.javaparser.ast.nodeTypes.NodeWithAnnotations; import com.github.javaparser.ast.nodeTypes.NodeWithVariables; @@ -69,6 +66,7 @@ import java.util.StringJoiner; import java.util.TreeMap; import java.util.TreeSet; +import java.util.function.Predicate; import java.util.stream.Collectors; import javax.lang.model.element.AnnotationMirror; import javax.lang.model.element.Element; @@ -1328,49 +1326,19 @@ private void writeAjavaFile( // and crashes when adding annotations in certain locations. // LexicalPreservingPrinter.print(root.declaration, writer); - // To avoid cluttering the output, do not print: - // * invisible qualifiers - // * irrelevant qualifiers, if `omitIrrelevantAnnotations` is true. - Set invisibleQualifierNames = getInvisibleQualifierNames(this.atypeFactory); + // The compilation unit is cloned because this method side-effects it: it removes the + // annotations that should not be printed. Removing them from the AST, rather than + // suppressing them in the pretty-printer, prevents the pretty-printer from outputting the + // whitespace that would have separated an annotation from what follows it. + CompilationUnit compilationUnit = root.compilationUnit.clone(); + removeUnprintedAnnotations(compilationUnit, omitIrrelevantAnnotations); + DefaultPrettyPrinter prettyPrinter = new DefaultPrettyPrinter() { @Override public String print(Node node) { VoidVisitor visitor = new DefaultPrettyPrinterVisitor(getConfiguration()) { - @Override - public void visit(MarkerAnnotationExpr n, Void arg) { - if (invisibleQualifierNames.contains(n.getName().toString())) { - return; - } - if (omitIrrelevantAnnotations && !annotationIsRelevant(n)) { - return; - } - super.visit(n, arg); - } - - @Override - public void visit(SingleMemberAnnotationExpr n, Void arg) { - if (invisibleQualifierNames.contains(n.getName().toString())) { - return; - } - if (omitIrrelevantAnnotations && !annotationIsRelevant(n)) { - return; - } - super.visit(n, arg); - } - - @Override - public void visit(NormalAnnotationExpr n, Void arg) { - if (invisibleQualifierNames.contains(n.getName().toString())) { - return; - } - if (omitIrrelevantAnnotations && !annotationIsRelevant(n)) { - return; - } - super.visit(n, arg); - } - // visit(CharLiteralExpr) and visit(StringLiteralExpr) work around bugs in // JavaParser, with respect to handling lonely surrogate characters. @@ -1398,13 +1366,45 @@ public void visit(final StringLiteralExpr n, final Void arg) { } }; - String fileContent = prettyPrinter.print(root.compilationUnit); + String fileContent = prettyPrinter.print(compilationUnit); writer.write(fileContent); } catch (IOException e) { throw new BugInCF("Error while writing ajava file " + outputPath, e); } } + /** + * Removes from the given compilation unit the annotations that an ajava file should not contain, + * because they would clutter it: + * + *

    + *
  • invisible qualifiers + *
  • irrelevant qualifiers, if {@code omitIrrelevantAnnotations} is true + *
+ * + * @param compilationUnit the compilation unit to side-effect + * @param omitIrrelevantAnnotations if true, also remove annotations that are irrelevant where + * they appear + */ + private void removeUnprintedAnnotations( + CompilationUnit compilationUnit, boolean omitIrrelevantAnnotations) { + Set invisibleQualifierNames = getInvisibleQualifierNames(this.atypeFactory); + Predicate shouldRemove = + anno -> + invisibleQualifierNames.contains(anno.getNameAsString()) + || (omitIrrelevantAnnotations && !annotationIsRelevant(anno)); + for (Node node : compilationUnit.findAll(Node.class)) { + if (node instanceof NodeWithAnnotations annotated) { + annotated.getAnnotations().removeIf(shouldRemove); + } + if (node instanceof Parameter param) { + // A varargs annotation is on the array type that `...` creates, as in + // `void m(String @Anno ... args)`. It is not in `param.getAnnotations()`. + param.getVarArgsAnnotations().removeIf(shouldRemove); + } + } + } + // TODO: Move these two routines to StringUtils. /** From 0baa04e93b6b60ff5b961f229566d9766536086f Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Wed, 16 Sep 2026 07:41:26 -0700 Subject: [PATCH 06/58] Resolve a type name in a local class's and an anonymous class's scope `JavaParserUtil.resolveTypeName` did not model the scope of a local class or of an anonymous class, so a name that such a class declares or inherits resolved to a same-named type that is declared outside it. If that type is irrelevant, inference silently discarded a correct annotation. `TypeDeclaration.getFullyQualifiedName()` is part of the problem: for a local class `Foo` in `Outer`, it returns "Outer.Foo", which `Elements` resolves to a member type of `Outer`. Model these scopes: * A local class shadows every type of the same name, and `Elements` cannot look up a local class, so return null (that is, be conservative). * Likewise for a member type of a class that `Elements` cannot look up: a local class, an anonymous class (including the body of an enum constant), or a class nested within one. * Such a class also inherits its supertype's member types. Resolve the supertype and search it, which is precise rather than conservative. The supertype names are excluded from the class's own body scope, both because that is the Java rule and because it bounds the recursion. Add `nameableFullyQualifiedName`, which returns a fully-qualified name only when `Elements` can look it up, in place of `TypeDeclaration.getFullyQualifiedName()`. Add tests for a local class, an anonymous class, an enum constant's body, and a member type inherited into a local or anonymous class. Each test loses an annotation if its part of this fix is reverted. `InheritedTypeShadows` also shows that the supertype search is precise: an annotation on an inherited member type that is irrelevant is still omitted. Co-Authored-By: Claude Opus 5 --- .../AnonymousTypeShadows.ajava.goal | 80 +++++++ .../InheritedTypeShadows.ajava.goal | 90 ++++++++ .../LocalTypeShadows.ajava.goal | 43 ++++ .../non-annotated/AnonymousTypeShadows.java | 63 ++++++ .../non-annotated/InheritedTypeShadows.java | 73 +++++++ .../non-annotated/LocalTypeShadows.java | 34 +++ .../framework/util/JavaParserUtil.java | 197 +++++++++++++++++- 7 files changed, 573 insertions(+), 7 deletions(-) create mode 100644 checker/tests/ainfer-relevance/AnonymousTypeShadows.ajava.goal create mode 100644 checker/tests/ainfer-relevance/InheritedTypeShadows.ajava.goal create mode 100644 checker/tests/ainfer-relevance/LocalTypeShadows.ajava.goal create mode 100644 checker/tests/ainfer-relevance/non-annotated/AnonymousTypeShadows.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/InheritedTypeShadows.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/LocalTypeShadows.java diff --git a/checker/tests/ainfer-relevance/AnonymousTypeShadows.ajava.goal b/checker/tests/ainfer-relevance/AnonymousTypeShadows.ajava.goal new file mode 100644 index 000000000000..45ff528f9baa --- /dev/null +++ b/checker/tests/ainfer-relevance/AnonymousTypeShadows.ajava.goal @@ -0,0 +1,80 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A member type of an anonymous class shadows a type of the same name that is declared elsewhere, +// such as a member of an enclosing class. The name "Foo" refers to the member of the anonymous +// class, which is relevant because it is a subtype of `CharSequence`, rather than to the member +// class `AnonymousTypeShadows.Foo`, which is irrelevant. An anonymous class has no name, so +// inference cannot determine the member type; it must conservatively retain the annotation. If +// inference instead resolves the name to `AnonymousTypeShadows.Foo` and therefore discards the +// annotation, then the second (validation) pass of this test issues the warnings that are written +// below. +// +// The body of an enum constant also declares an anonymous class, so it is tested the same way. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class AnonymousTypeShadows { + + static class Foo { + } + + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType Object anonymous = new Object() { + + abstract class Foo implements CharSequence { + } + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 Foo field; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "this.field" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + void assignField() { + field = getSibling1(); + } + + @org.checkerframework.dataflow.qual.Impure + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + void expectsSibling1(Foo f) { + } + + @org.checkerframework.dataflow.qual.Pure + Foo getSibling1() { + return null; + } + }; + + enum EnumWithConstantBody { + + CONSTANT { + + abstract class Foo implements CharSequence { + } + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 Foo field; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "this.field" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + void assignField() { + field = getSibling1(); + } + + @org.checkerframework.dataflow.qual.Impure + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + void expectsSibling1(Foo f) { + } + + @org.checkerframework.dataflow.qual.Pure + Foo getSibling1() { + return null; + } + } + + } +} diff --git a/checker/tests/ainfer-relevance/InheritedTypeShadows.ajava.goal b/checker/tests/ainfer-relevance/InheritedTypeShadows.ajava.goal new file mode 100644 index 000000000000..f871fb61f5ce --- /dev/null +++ b/checker/tests/ainfer-relevance/InheritedTypeShadows.ajava.goal @@ -0,0 +1,90 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A local class and an anonymous class inherit their supertype's member types, which shadow types +// of the same name that are declared elsewhere. Inference must resolve the name "Foo" to +// `Base.Foo`, which is relevant because it is a subtype of `CharSequence`, rather than to the +// member class `InheritedTypeShadows.Foo`, which is irrelevant. Neither a local class nor an +// anonymous class has a name that inference can look up, so inference must resolve the name by +// searching the supertype. If inference resolves the name incorrectly and therefore discards the +// annotation, then the second (validation) pass of this test issues the warnings that are written +// below. +// +// The search of the supertype is precise rather than merely conservative: the goal file shows +// that inference discards an annotation on `Irrelevant`, which is the inherited member type +// `Base.Irrelevant` and is irrelevant. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class InheritedTypeShadows { + + static class Foo { + } + + static class Base { + + abstract static class Foo implements CharSequence { + } + + static class Irrelevant { + } + } + + @org.checkerframework.dataflow.qual.SideEffectFree + static void declareLocalClass() { + class Local extends Base { + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 Foo field; + + Irrelevant irrelevantField; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "this.field" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + void assignField() { + field = getSibling1(); + irrelevantField = new Irrelevant(); + } + + @org.checkerframework.dataflow.qual.Impure + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + void expectsSibling1(@org.checkerframework.checker.testchecker.ainfer.qual.AinferTop Local this, Foo f) { + } + + @org.checkerframework.dataflow.qual.Pure + Foo getSibling1(@org.checkerframework.checker.testchecker.ainfer.qual.AinferTop Local this) { + return null; + } + } + } + + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferTop Object anonymous = new Base() { + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 Foo field; + + Irrelevant irrelevantField; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "this.field" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + void assignField() { + field = getSibling1(); + irrelevantField = new Irrelevant(); + } + + @org.checkerframework.dataflow.qual.Impure + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + void expectsSibling1(Foo f) { + } + + @org.checkerframework.dataflow.qual.Pure + Foo getSibling1() { + return null; + } + }; +} diff --git a/checker/tests/ainfer-relevance/LocalTypeShadows.ajava.goal b/checker/tests/ainfer-relevance/LocalTypeShadows.ajava.goal new file mode 100644 index 000000000000..cbd9885fcbf2 --- /dev/null +++ b/checker/tests/ainfer-relevance/LocalTypeShadows.ajava.goal @@ -0,0 +1,43 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A local class declaration shadows a type of the same name that is declared elsewhere, such as a +// member of an enclosing class. Inference must resolve the name "Foo" to the local class, which +// is relevant because it is a subtype of `CharSequence`, rather than to the member class +// `LocalTypeShadows.Foo`, which is irrelevant. If inference resolves the name incorrectly and +// therefore discards the annotation, then the second (validation) pass of this test issues the +// warning that is written below. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class LocalTypeShadows { + + static class Foo { + } + + @org.checkerframework.dataflow.qual.SideEffectFree + static void declareLocalClass() { + abstract class Foo implements CharSequence { + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 Foo field; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "this.field" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + void assignField() { + field = getSibling1(); + } + + @org.checkerframework.dataflow.qual.Impure + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + void expectsSibling1(@org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType Foo this, Foo f) { + } + + @org.checkerframework.dataflow.qual.Pure + Foo getSibling1(@org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType Foo this) { + return null; + } + } + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/AnonymousTypeShadows.java b/checker/tests/ainfer-relevance/non-annotated/AnonymousTypeShadows.java new file mode 100644 index 000000000000..5eb6cc63f057 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/AnonymousTypeShadows.java @@ -0,0 +1,63 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A member type of an anonymous class shadows a type of the same name that is declared elsewhere, +// such as a member of an enclosing class. The name "Foo" refers to the member of the anonymous +// class, which is relevant because it is a subtype of `CharSequence`, rather than to the member +// class `AnonymousTypeShadows.Foo`, which is irrelevant. An anonymous class has no name, so +// inference cannot determine the member type; it must conservatively retain the annotation. If +// inference instead resolves the name to `AnonymousTypeShadows.Foo` and therefore discards the +// annotation, then the second (validation) pass of this test issues the warnings that are written +// below. +// +// The body of an enum constant also declares an anonymous class, so it is tested the same way. +public class AnonymousTypeShadows { + + static class Foo {} + + static Object anonymous = + new Object() { + + abstract class Foo implements CharSequence {} + + Foo field; + + void assignField() { + field = getSibling1(); + } + + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + void expectsSibling1(@AinferSibling1 Foo f) {} + + @AinferSibling1 Foo getSibling1() { + return null; + } + }; + + enum EnumWithConstantBody { + CONSTANT { + + abstract class Foo implements CharSequence {} + + Foo field; + + void assignField() { + field = getSibling1(); + } + + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + void expectsSibling1(@AinferSibling1 Foo f) {} + + @AinferSibling1 Foo getSibling1() { + return null; + } + } + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/InheritedTypeShadows.java b/checker/tests/ainfer-relevance/non-annotated/InheritedTypeShadows.java new file mode 100644 index 000000000000..01f2531f6a33 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/InheritedTypeShadows.java @@ -0,0 +1,73 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A local class and an anonymous class inherit their supertype's member types, which shadow types +// of the same name that are declared elsewhere. Inference must resolve the name "Foo" to +// `Base.Foo`, which is relevant because it is a subtype of `CharSequence`, rather than to the +// member class `InheritedTypeShadows.Foo`, which is irrelevant. Neither a local class nor an +// anonymous class has a name that inference can look up, so inference must resolve the name by +// searching the supertype. If inference resolves the name incorrectly and therefore discards the +// annotation, then the second (validation) pass of this test issues the warnings that are written +// below. +// +// The search of the supertype is precise rather than merely conservative: the goal file shows +// that inference discards an annotation on `Irrelevant`, which is the inherited member type +// `Base.Irrelevant` and is irrelevant. +public class InheritedTypeShadows { + + static class Foo {} + + static class Base { + abstract static class Foo implements CharSequence {} + + static class Irrelevant {} + } + + static void declareLocalClass() { + class Local extends Base { + + Foo field; + + Irrelevant irrelevantField; + + void assignField() { + field = getSibling1(); + irrelevantField = new Irrelevant(); + } + + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + void expectsSibling1(@AinferSibling1 Foo f) {} + + @AinferSibling1 Foo getSibling1() { + return null; + } + } + } + + static Object anonymous = + new Base() { + + Foo field; + + Irrelevant irrelevantField; + + void assignField() { + field = getSibling1(); + irrelevantField = new Irrelevant(); + } + + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + void expectsSibling1(@AinferSibling1 Foo f) {} + + @AinferSibling1 Foo getSibling1() { + return null; + } + }; +} diff --git a/checker/tests/ainfer-relevance/non-annotated/LocalTypeShadows.java b/checker/tests/ainfer-relevance/non-annotated/LocalTypeShadows.java new file mode 100644 index 000000000000..e59a76ab8f5f --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/LocalTypeShadows.java @@ -0,0 +1,34 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A local class declaration shadows a type of the same name that is declared elsewhere, such as a +// member of an enclosing class. Inference must resolve the name "Foo" to the local class, which +// is relevant because it is a subtype of `CharSequence`, rather than to the member class +// `LocalTypeShadows.Foo`, which is irrelevant. If inference resolves the name incorrectly and +// therefore discards the annotation, then the second (validation) pass of this test issues the +// warning that is written below. +public class LocalTypeShadows { + + static class Foo {} + + static void declareLocalClass() { + abstract class Foo implements CharSequence { + + Foo field; + + void assignField() { + field = getSibling1(); + } + + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + void expectsSibling1(@AinferSibling1 Foo f) {} + + @AinferSibling1 Foo getSibling1() { + return null; + } + } + } +} diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index 4ff5b08d654f..7fa508f136a2 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -7,20 +7,24 @@ import com.github.javaparser.ast.Node; import com.github.javaparser.ast.body.AnnotationDeclaration; import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.body.EnumConstantDeclaration; import com.github.javaparser.ast.body.EnumDeclaration; import com.github.javaparser.ast.body.RecordDeclaration; import com.github.javaparser.ast.body.TypeDeclaration; import com.github.javaparser.ast.expr.AnnotationExpr; import com.github.javaparser.ast.expr.ArrayInitializerExpr; import com.github.javaparser.ast.expr.BinaryExpr; +import com.github.javaparser.ast.expr.ObjectCreationExpr; import com.github.javaparser.ast.expr.StringLiteralExpr; import com.github.javaparser.ast.nodeTypes.NodeWithTypeParameters; +import com.github.javaparser.ast.stmt.Statement; import com.github.javaparser.ast.type.ClassOrInterfaceType; import com.github.javaparser.ast.type.PrimitiveType; import com.github.javaparser.ast.type.TypeParameter; import com.github.javaparser.ast.visitor.VoidVisitorAdapter; import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Collections; import java.util.Deque; import java.util.HashSet; import java.util.List; @@ -51,7 +55,8 @@ private JavaParserUtil() { /** * Returns the element for the given JavaParser type, whose name is resolved in the scope of the * type declarations and the compilation unit that contain it. Returns null if the name cannot be - * resolved, which happens for a type variable and for a type that is not on the classpath. + * resolved: it names a type variable, a local class, a member of a local or anonymous class, or a + * type that is not on the classpath. * * @param elements used for looking up names * @param type a JavaParser class or interface type @@ -75,12 +80,17 @@ private JavaParserUtil() { suffix = name.substring(dotIndex); } - // A type parameter, or a type that is lexically enclosed in a type declaration, takes - // precedence over an import, over a type in the same package, over a type in `java.lang`, and - // over the interpretation of `name` as a fully-qualified name. + // A type parameter, a local class, or a type that is lexically enclosed in a type declaration, + // takes precedence over an import, over a type in the same package, over a type in + // `java.lang`, and over the interpretation of `name` as a fully-qualified name. + // + // `child` is the child of `ancestor` that contains `type`. It distinguishes a use of `name` + // within a class body, where the class's member types are in scope, from a use in the class's + // own supertype names, where they are not. + Node child = type; for (Node ancestor = type.getParentNode().orElse(null); ancestor != null; - ancestor = ancestor.getParentNode().orElse(null)) { + child = ancestor, ancestor = ancestor.getParentNode().orElse(null)) { if (ancestor instanceof NodeWithTypeParameters genericDeclaration) { for (TypeParameter typeParameter : genericDeclaration.getTypeParameters()) { if (typeParameter.getNameAsString().equals(firstComponent)) { @@ -90,8 +100,42 @@ private JavaParserUtil() { } } } - if (ancestor instanceof TypeDeclaration enclosingType) { - String enclosingName = enclosingType.getFullyQualifiedName().orElse(null); + + if (declaresLocalType(ancestor, firstComponent)) { + // `name` names a local class, or is nested within one. A local class shadows any type of + // the same name, and `Elements` cannot look up a local class by name. + return null; + } + + if (ancestor instanceof EnumConstantDeclaration enumConstant + && declaresMemberType(enumConstant.getClassBody(), firstComponent)) { + // The body of an enum constant declares an anonymous class. A member type of an + // anonymous class has no name that `Elements` can look up. (There is no need to search + // the anonymous class's supertype, which is the enum: the enum declaration is an + // ancestor, so a later iteration of this loop searches it.) + return null; + } + + // The member types of a class that `Elements` cannot look up by name -- an anonymous class, + // a local class, or a class that is nested within one -- shadow types that are declared + // outside the class. `unnameableSupertypes` is non-null if `type` appears within the body + // of such a class, in which case it holds the class's supertypes, which this method searches + // for a member type that `name` might refer to. + List unnameableSupertypes = null; + if (ancestor instanceof ObjectCreationExpr creation) { + List body = creation.getAnonymousClassBody().orElse(null); + List supertypes = Collections.singletonList(creation.getType()); + // If `child` is the supertype name, then `type` is not in the anonymous class's body, and + // testing `child` also prevents infinite recursion on the recursive call below. + if (body != null && !containsSame(supertypes, child)) { + if (declaresMemberType(body, firstComponent)) { + // A member type of an anonymous class has no name that `Elements` can look up. + return null; + } + unnameableSupertypes = supertypes; + } + } else if (ancestor instanceof TypeDeclaration enclosingType) { + String enclosingName = nameableFullyQualifiedName(enclosingType); if (enclosingName != null) { TypeElement result = elements.getTypeElement(enclosingName + "." + name); if (result != null) { @@ -105,6 +149,32 @@ private JavaParserUtil() { return result; } } + } else { + List supertypes = supertypes(enclosingType); + // If `child` is one of the supertype names, then `type` is not in the class's body, and + // testing `child` also prevents infinite recursion on the recursive call below. + if (!containsSame(supertypes, child)) { + if (declaresMemberType(enclosingType.getMembers(), firstComponent)) { + // A member type of an unnameable class has no name that `Elements` can look up. + return null; + } + unnameableSupertypes = supertypes; + } + } + } + if (unnameableSupertypes != null) { + for (ClassOrInterfaceType supertype : unnameableSupertypes) { + TypeElement supertypeElement = resolveTypeName(elements, supertype); + if (supertypeElement == null) { + // The supertype could not be determined, so neither could the member types that the + // unnameable class inherits and that might shadow `name`. + return null; + } + TypeElement result = + resolveMemberType(elements, supertypeElement, firstComponent, suffix); + if (result != null) { + return result; + } } } } @@ -184,6 +254,119 @@ private JavaParserUtil() { return elements.getTypeElement(name); } + /** + * Returns true if {@code node} directly contains a statement that declares a local class, + * interface, enum, or record whose name is {@code name}. Such a declaration shadows, throughout + * the block that contains it, every type of the same name that is declared elsewhere. + * + * @param node a JavaParser node, such as a block + * @param name a simple type name + * @return true if {@code node} declares a local type named {@code name} + */ + private static boolean declaresLocalType(Node node, String name) { + for (Node child : node.getChildNodes()) { + if (child instanceof Statement) { + // A local type declaration is the only kind of statement whose child is a type + // declaration. + for (Node grandchild : child.getChildNodes()) { + if (grandchild instanceof TypeDeclaration localType + && localType.getNameAsString().equals(name)) { + return true; + } + } + } + } + return false; + } + + /** + * Returns true if one of the given body declarations is a type declaration whose name is {@code + * name}. + * + * @param members the body declarations of a class or interface + * @param name a simple type name + * @return true if {@code members} contains a type declaration named {@code name} + */ + private static boolean declaresMemberType(List members, String name) { + for (Node member : members) { + if (member instanceof TypeDeclaration memberType + && memberType.getNameAsString().equals(name)) { + return true; + } + } + return false; + } + + /** + * Returns the fully-qualified name by which {@link Elements} can look up the given type + * declaration, or null if there is none: {@code typeDecl} is a local class, or it is a member of + * a local class or of an anonymous class (including the body of an enum constant). ({@link + * TypeDeclaration#getFullyQualifiedName} does not make this distinction; it returns a name that + * {@link Elements} might resolve to a different type declaration.) + * + * @param typeDecl a JavaParser type declaration + * @return the fully-qualified name of {@code typeDecl}, or null if it has none + */ + private static @Nullable String nameableFullyQualifiedName(TypeDeclaration typeDecl) { + for (Node node = typeDecl; node != null; node = node.getParentNode().orElse(null)) { + if (node instanceof TypeDeclaration) { + Node parent = node.getParentNode().orElse(null); + // The parent of a local type declaration is the statement that declares it. The parent + // of a member of an anonymous class is the object creation expression, or the enum + // constant declaration, that declares the anonymous class. + if (parent instanceof Statement + || parent instanceof ObjectCreationExpr + || parent instanceof EnumConstantDeclaration) { + return null; + } + } + } + return typeDecl.getFullyQualifiedName().orElse(null); + } + + /** + * Returns the supertypes that the given type declaration names: its {@code extends} clause and + * its {@code implements} clause. The result does not include an implicit supertype such as {@code + * java.lang.Object}, none of which declares a member type. + * + * @param typeDecl a JavaParser type declaration + * @return the supertypes that {@code typeDecl} names + */ + private static List supertypes(TypeDeclaration typeDecl) { + if (typeDecl instanceof ClassOrInterfaceDeclaration classDecl) { + List result = new ArrayList<>(classDecl.getExtendedTypes()); + result.addAll(classDecl.getImplementedTypes()); + return result; + } + if (typeDecl instanceof EnumDeclaration enumDecl) { + return enumDecl.getImplementedTypes(); + } + if (typeDecl instanceof RecordDeclaration recordDecl) { + return recordDecl.getImplementedTypes(); + } + // An annotation declaration's only supertype is `java.lang.annotation.Annotation`. + return Collections.emptyList(); + } + + /** + * Returns true if one of the given nodes is the given node, compared by reference equality. + * JavaParser's {@code equals()} is structural, so {@link List#contains} does not distinguish two + * occurrences of the same type name. + * + * @param nodes some JavaParser nodes + * @param node a JavaParser node + * @return true if {@code nodes} contains {@code node} itself + */ + @SuppressWarnings("interning:not.interned") // reference equality of AST nodes + private static boolean containsSame(List nodes, Node node) { + for (Node n : nodes) { + if (n == node) { + return true; + } + } + return false; + } + /** * Returns the element for the member type named {@code firstComponent + suffix} that {@code * typeElement} declares or inherits, or null if there is no such member type. From 01c6c57e7a3e7ddce6399ee9703061d1976d79f0 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Wed, 16 Sep 2026 10:18:29 -0700 Subject: [PATCH 07/58] Resolve a type name only where the enclosing class's member types are in scope A class's member types, declared and inherited, are in scope only in its body -- not in its annotations, its type parameter section, or its supertype names, and not in the arguments of the object creation expression that declares an anonymous class. Test the child of the enclosing declaration that contains the name, rather than testing only whether the name is the supertype name. Also: * Include the implicit superclass `java.lang.Enum` in an enum's supertypes, because it declares the member type `Enum.EnumDesc`. * Memoize the name lookups that `resolveTypeName` performs, most of which name no type and are repeated for every annotation in every ajava file. * Document which cases of `annotationIsRelevant` the annotations that inference writes can reach, and which are defensive. Co-Authored-By: Claude Opus 5 --- .../AnonymousArgumentScope.ajava.goal | 57 ++++++ .../UnnameableEnumInheritsType.ajava.goal | 32 ++++ .../non-annotated/AnonymousArgumentScope.java | 46 +++++ .../non-annotated/EnumDesc.java | 7 + .../UnnameableEnumInheritsType.java | 27 +++ ...holeProgramInferenceJavaParserStorage.java | 26 ++- .../framework/util/JavaParserUtil.java | 179 ++++++++++++++---- 7 files changed, 330 insertions(+), 44 deletions(-) create mode 100644 checker/tests/ainfer-relevance/AnonymousArgumentScope.ajava.goal create mode 100644 checker/tests/ainfer-relevance/UnnameableEnumInheritsType.ajava.goal create mode 100644 checker/tests/ainfer-relevance/non-annotated/AnonymousArgumentScope.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/EnumDesc.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/UnnameableEnumInheritsType.java diff --git a/checker/tests/ainfer-relevance/AnonymousArgumentScope.ajava.goal b/checker/tests/ainfer-relevance/AnonymousArgumentScope.ajava.goal new file mode 100644 index 000000000000..89cfebd885fc --- /dev/null +++ b/checker/tests/ainfer-relevance/AnonymousArgumentScope.ajava.goal @@ -0,0 +1,57 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// The member types of an anonymous class, both the ones it declares and the ones it inherits, are +// in scope only in its body. They are not in scope in the arguments of the object creation +// expression that declares the anonymous class, even though those arguments are lexically nested +// within it. Inference must resolve the name "Foo", which appears in such an argument, to the +// member class `AnonymousArgumentScope.Foo`, which is relevant because it is a subtype of +// `CharSequence`, rather than to `Base.Foo`, which the anonymous class inherits and which is +// irrelevant. If inference resolves the name incorrectly and therefore discards the annotation, +// then the second (validation) pass of this test issues the warning that is written below. +// +// The same is true of the annotations and the type parameter section of a local class or of a +// member of an unnameable class, but inference writes no annotation in either location, so no +// goal file can show that inference resolves those names correctly. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class AnonymousArgumentScope { + + abstract static class Foo implements CharSequence { + } + + static class Base { + + static class Foo { + } + + @org.checkerframework.dataflow.qual.SideEffectFree + Base(@org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType Object argument) { + } + } + + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferTop Object anonymous = new Base(new Object() { + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 Foo field; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "this.field" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + void assignField() { + field = getSibling1(); + } + + @org.checkerframework.dataflow.qual.Impure + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + void expectsSibling1(Foo f) { + } + + @org.checkerframework.dataflow.qual.Pure + Foo getSibling1() { + return null; + } + }) { + }; +} diff --git a/checker/tests/ainfer-relevance/UnnameableEnumInheritsType.ajava.goal b/checker/tests/ainfer-relevance/UnnameableEnumInheritsType.ajava.goal new file mode 100644 index 000000000000..ffee91e2b918 --- /dev/null +++ b/checker/tests/ainfer-relevance/UnnameableEnumInheritsType.ajava.goal @@ -0,0 +1,32 @@ +// Every enum inherits the member type `EnumDesc` from its implicit superclass `java.lang.Enum`, +// and that inherited member type shadows the type of the same name that is declared in the same +// package. An enum that is declared in a local class has no name that inference can look up, so +// inference determines its member types from its supertypes, which must therefore include the +// implicit `java.lang.Enum`. Inference must resolve the name "EnumDesc" to +// `java.lang.Enum.EnumDesc`, which is irrelevant, rather than to the top-level class `EnumDesc`, +// which is relevant. The goal file shows that inference writes no annotation for +// `enumDescField`, but does write one for `charSequenceField`. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class UnnameableEnumInheritsType { + + @org.checkerframework.dataflow.qual.Impure + static void declareLocalClass() { + class Local { + + enum Nested { + + CONSTANT; + + EnumDesc enumDescField; + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType CharSequence charSequenceField; + + @org.checkerframework.dataflow.qual.Impure + void assignFields(EnumDesc e, CharSequence c) { + enumDescField = e; + charSequenceField = c; + } + } + } + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/AnonymousArgumentScope.java b/checker/tests/ainfer-relevance/non-annotated/AnonymousArgumentScope.java new file mode 100644 index 000000000000..f058d0003000 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/AnonymousArgumentScope.java @@ -0,0 +1,46 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// The member types of an anonymous class, both the ones it declares and the ones it inherits, are +// in scope only in its body. They are not in scope in the arguments of the object creation +// expression that declares the anonymous class, even though those arguments are lexically nested +// within it. Inference must resolve the name "Foo", which appears in such an argument, to the +// member class `AnonymousArgumentScope.Foo`, which is relevant because it is a subtype of +// `CharSequence`, rather than to `Base.Foo`, which the anonymous class inherits and which is +// irrelevant. If inference resolves the name incorrectly and therefore discards the annotation, +// then the second (validation) pass of this test issues the warning that is written below. +// +// The same is true of the annotations and the type parameter section of a local class or of a +// member of an unnameable class, but inference writes no annotation in either location, so no +// goal file can show that inference resolves those names correctly. +public class AnonymousArgumentScope { + + abstract static class Foo implements CharSequence {} + + static class Base { + static class Foo {} + + Base(Object argument) {} + } + + static Object anonymous = + new Base( + new Object() { + + Foo field; + + void assignField() { + field = getSibling1(); + } + + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + void expectsSibling1(@AinferSibling1 Foo f) {} + + @AinferSibling1 Foo getSibling1() { + return null; + } + }) {}; +} diff --git a/checker/tests/ainfer-relevance/non-annotated/EnumDesc.java b/checker/tests/ainfer-relevance/non-annotated/EnumDesc.java new file mode 100644 index 000000000000..54580f8cba68 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/EnumDesc.java @@ -0,0 +1,7 @@ +// This class exists so that the unnamed package declares a type whose name is also the name of the +// member type `EnumDesc` that every enum inherits from `java.lang.Enum`. It is used by +// `UnnameableEnumInheritsType.java`. +// +// This class is relevant, because it is a subtype of `CharSequence`, which the checker's +// `@RelevantJavaTypes` lists. By contrast, `java.lang.Enum.EnumDesc` is irrelevant. +public abstract class EnumDesc implements CharSequence {} diff --git a/checker/tests/ainfer-relevance/non-annotated/UnnameableEnumInheritsType.java b/checker/tests/ainfer-relevance/non-annotated/UnnameableEnumInheritsType.java new file mode 100644 index 000000000000..de4565abdc31 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/UnnameableEnumInheritsType.java @@ -0,0 +1,27 @@ +// Every enum inherits the member type `EnumDesc` from its implicit superclass `java.lang.Enum`, +// and that inherited member type shadows the type of the same name that is declared in the same +// package. An enum that is declared in a local class has no name that inference can look up, so +// inference determines its member types from its supertypes, which must therefore include the +// implicit `java.lang.Enum`. Inference must resolve the name "EnumDesc" to +// `java.lang.Enum.EnumDesc`, which is irrelevant, rather than to the top-level class `EnumDesc`, +// which is relevant. The goal file shows that inference writes no annotation for +// `enumDescField`, but does write one for `charSequenceField`. +public class UnnameableEnumInheritsType { + + static void declareLocalClass() { + class Local { + enum Nested { + CONSTANT; + + EnumDesc enumDescField; + + CharSequence charSequenceField; + + void assignFields(EnumDesc e, CharSequence c) { + enumDescField = e; + charSequenceField = c; + } + } + } + } +} diff --git a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java index ef7e2f57ba0a..ba43278b9a3a 100644 --- a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java +++ b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java @@ -135,6 +135,15 @@ public class WholeProgramInferenceJavaParserStorage /** The element utilities for {@code atypeFactory}. */ protected final Elements elements; + /** + * Memoizes the name lookups that {@link JavaParserUtil#resolveTypeName} performs while writing + * ajava files. Resolving one name looks up many candidate names, most of which name no type, and + * the same names are looked up for every annotation in every ajava file. {@link + * #writeResultsToFile} clears this before writing, because a name that names no type in one + * annotation processing round might name a generated type in a later round. + */ + private final Map typeElementCache = new HashMap<>(); + /** * Maps from binary class name to the wrapper containing the class. Contains all classes in Java * source files containing an Element for which an annotation has been inferred. @@ -1031,6 +1040,8 @@ public void writeResultsToFile(OutputFormat outputFormat, BaseTypeChecker checke setSupertypesAndSubtypesModified(); + typeElementCache.clear(); + for (String path : modifiedFiles) { // This calls deepCopy() because wpiPrepareCompilationUnitForWriting performs side // effects on the inference results that we don't want to be persistent. The JavaParser @@ -1139,6 +1150,14 @@ boolean annotationIsRelevant(AnnotationExpr anno) { // a formal parameter. Such an annotation is treated as a type qualifier on the declaration's // element type -- except when the declaration's type is `void`, on which no type qualifier can // be written, so the annotation is certainly a declaration annotation. + // + // Only two of the cases below can arise from the annotations that inference writes: the + // `Type` case and the varargs part of the `Parameter` case. `transferAnnotations` first + // removes every annotation from the AST, then adds only `@AnnotatedFor` (which is not a + // supported qualifier), declaration annotations that `writeDeclarationAnnotation` marks, type + // qualifiers on `Type` nodes, and varargs annotations. The other cases are defensive: they + // are not exercised by the test suite, but they keep this method correct for any other + // annotation that might appear in the AST. if (parentNode instanceof Type type) { return typeIsRelevant(gatf, type); @@ -1165,8 +1184,9 @@ boolean annotationIsRelevant(AnnotationExpr anno) { if (parentNode instanceof MethodDeclaration method) { if (method.getType() instanceof VoidType) { // No type qualifier can be written on `void`, so the annotation is a declaration - // annotation that is also a type qualifier, as `addMethodDeclarationAnnotation` can - // create. Be conservative. + // annotation that is also a type qualifier -- of the sort that + // `addMethodDeclarationAnnotation` creates, though such an annotation is marked and is + // handled above. Be conservative. return true; } return typeIsRelevant(gatf, innermostComponentType(method.getType())); @@ -1273,7 +1293,7 @@ private boolean arrayCreationLevelIsRelevant( return types.getNoType(TypeKind.VOID); } if (type instanceof ClassOrInterfaceType classType) { - TypeElement typeElt = JavaParserUtil.resolveTypeName(elements, classType); + TypeElement typeElt = JavaParserUtil.resolveTypeName(elements, classType, typeElementCache); return typeElt == null ? null : typeElt.asType(); } // An intersection type, a union type, `var`, a wildcard, a type parameter declaration, etc. diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index 7fa508f136a2..fd59b80a06c0 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -26,8 +26,10 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Deque; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import javax.annotation.processing.ProcessingEnvironment; @@ -58,13 +60,37 @@ private JavaParserUtil() { * resolved: it names a type variable, a local class, a member of a local or anonymous class, or a * type that is not on the classpath. * + *

A client that resolves many names should call {@link #resolveTypeName(Elements, + * ClassOrInterfaceType, Map)}, which memoizes the name lookups. + * * @param elements used for looking up names * @param type a JavaParser class or interface type * @return the element for {@code type}, or null if it cannot be determined */ - @SuppressWarnings("signature:argument") // calls to getTypeElement() whose result is checked public static @Nullable TypeElement resolveTypeName( Elements elements, ClassOrInterfaceType type) { + return resolveTypeName(elements, type, new HashMap<>(4)); + } + + /** + * Returns the element for the given JavaParser type, whose name is resolved in the scope of the + * type declarations and the compilation unit that contain it. Returns null if the name cannot be + * resolved: it names a type variable, a local class, a member of a local or anonymous class, or a + * type that is not on the classpath. + * + *

Resolving one name looks up many candidate names, most of which name no type, so a client + * that resolves many names should pass the same cache to each call. A cache should not be reused + * across annotation processing rounds or across {@code Elements} instances: a name that names no + * type in one round might name a generated type in a later round. + * + * @param elements used for looking up names + * @param type a JavaParser class or interface type + * @param cache maps a name to the type it names, or to null if it names no type; this method both + * reads and writes it + * @return the element for {@code type}, or null if it cannot be determined + */ + public static @Nullable TypeElement resolveTypeName( + Elements elements, ClassOrInterfaceType type, Map cache) { String name = type.getNameWithScope(); // `firstComponent` is what a single-type import must import; the rest of `name` names a @@ -85,8 +111,9 @@ private JavaParserUtil() { // `java.lang`, and over the interpretation of `name` as a fully-qualified name. // // `child` is the child of `ancestor` that contains `type`. It distinguishes a use of `name` - // within a class body, where the class's member types are in scope, from a use in the class's - // own supertype names, where they are not. + // within a class body, where the class's member types are in scope, from a use elsewhere in + // the class declaration -- in its annotations, its type parameter section, or its supertype + // names -- where they are not. Node child = type; for (Node ancestor = type.getParentNode().orElse(null); ancestor != null; @@ -108,8 +135,10 @@ private JavaParserUtil() { } if (ancestor instanceof EnumConstantDeclaration enumConstant + && containsSame(enumConstant.getClassBody(), child) && declaresMemberType(enumConstant.getClassBody(), firstComponent)) { - // The body of an enum constant declares an anonymous class. A member type of an + // The body of an enum constant declares an anonymous class, whose member types are in + // scope only in that body and not in the constant's arguments. A member type of an // anonymous class has no name that `Elements` can look up. (There is no need to search // the anonymous class's supertype, which is the enum: the enum declaration is an // ancestor, so a later iteration of this loop searches it.) @@ -124,54 +153,53 @@ && declaresMemberType(enumConstant.getClassBody(), firstComponent)) { List unnameableSupertypes = null; if (ancestor instanceof ObjectCreationExpr creation) { List body = creation.getAnonymousClassBody().orElse(null); - List supertypes = Collections.singletonList(creation.getType()); - // If `child` is the supertype name, then `type` is not in the anonymous class's body, and - // testing `child` also prevents infinite recursion on the recursive call below. - if (body != null && !containsSame(supertypes, child)) { + // The anonymous class's member types are in scope only in its body, and not in the + // creation expression's scope, type arguments, supertype name, or arguments. Testing + // `child` also prevents infinite recursion on the recursive call below. + if (body != null && containsSame(body, child)) { if (declaresMemberType(body, firstComponent)) { // A member type of an anonymous class has no name that `Elements` can look up. return null; } - unnameableSupertypes = supertypes; + unnameableSupertypes = Collections.singletonList(creation.getType()); } - } else if (ancestor instanceof TypeDeclaration enclosingType) { + } else if (ancestor instanceof TypeDeclaration enclosingType + // The class's member types, declared and inherited, are in scope only in its body, and + // not in its annotations, its type parameter section, or its supertype names. Testing + // `child` also prevents infinite recursion on the recursive call below. + && containsSame(scopeOfMemberTypes(enclosingType), child)) { String enclosingName = nameableFullyQualifiedName(enclosingType); if (enclosingName != null) { - TypeElement result = elements.getTypeElement(enclosingName + "." + name); + TypeElement result = getTypeElement(elements, enclosingName + "." + name, cache); if (result != null) { return result; } // The enclosing type might inherit the member type rather than declare it. - TypeElement enclosingElement = elements.getTypeElement(enclosingName); + TypeElement enclosingElement = getTypeElement(elements, enclosingName, cache); if (enclosingElement != null) { - result = resolveMemberType(elements, enclosingElement, firstComponent, suffix); + result = resolveMemberType(elements, enclosingElement, firstComponent, suffix, cache); if (result != null) { return result; } } } else { - List supertypes = supertypes(enclosingType); - // If `child` is one of the supertype names, then `type` is not in the class's body, and - // testing `child` also prevents infinite recursion on the recursive call below. - if (!containsSame(supertypes, child)) { - if (declaresMemberType(enclosingType.getMembers(), firstComponent)) { - // A member type of an unnameable class has no name that `Elements` can look up. - return null; - } - unnameableSupertypes = supertypes; + if (declaresMemberType(enclosingType.getMembers(), firstComponent)) { + // A member type of an unnameable class has no name that `Elements` can look up. + return null; } + unnameableSupertypes = supertypes(enclosingType); } } if (unnameableSupertypes != null) { for (ClassOrInterfaceType supertype : unnameableSupertypes) { - TypeElement supertypeElement = resolveTypeName(elements, supertype); + TypeElement supertypeElement = resolveTypeName(elements, supertype, cache); if (supertypeElement == null) { // The supertype could not be determined, so neither could the member types that the // unnameable class inherits and that might shadow `name`. return null; } TypeElement result = - resolveMemberType(elements, supertypeElement, firstComponent, suffix); + resolveMemberType(elements, supertypeElement, firstComponent, suffix, cache); if (result != null) { return result; } @@ -182,7 +210,7 @@ && declaresMemberType(enumConstant.getClassBody(), firstComponent)) { CompilationUnit cu = type.findCompilationUnit().orElse(null); if (cu == null) { // The name might be fully-qualified. - return elements.getTypeElement(name); + return getTypeElement(elements, name, cache); } // A single-type import or a single-static import of a member type takes precedence over an @@ -193,7 +221,7 @@ && declaresMemberType(enumConstant.getClassBody(), firstComponent)) { } String importedName = importDecl.getNameAsString(); if (importedName.equals(firstComponent) || importedName.endsWith("." + firstComponent)) { - TypeElement result = elements.getTypeElement(importedName + suffix); + TypeElement result = getTypeElement(elements, importedName + suffix, cache); if (result != null) { return result; } @@ -204,9 +232,9 @@ && declaresMemberType(enumConstant.getClassBody(), firstComponent)) { // import that names a field or a method resolves to no type element at all.) String containerName = importedName.substring(0, importedName.length() - firstComponent.length() - 1); - TypeElement containerElement = elements.getTypeElement(containerName); + TypeElement containerElement = getTypeElement(elements, containerName, cache); if (containerElement != null) { - result = resolveMemberType(elements, containerElement, firstComponent, suffix); + result = resolveMemberType(elements, containerElement, firstComponent, suffix, cache); if (result != null) { return result; } @@ -228,7 +256,7 @@ && declaresMemberType(enumConstant.getClassBody(), firstComponent)) { } containerPrefixes.add("java.lang."); for (String containerPrefix : containerPrefixes) { - TypeElement result = elements.getTypeElement(containerPrefix + name); + TypeElement result = getTypeElement(elements, containerPrefix + name, cache); if (result != null) { return result; } @@ -238,9 +266,10 @@ && declaresMemberType(enumConstant.getClassBody(), firstComponent)) { // type inherits. for (ImportDeclaration importDecl : cu.getImports()) { if (importDecl.isAsterisk()) { - TypeElement importedElement = elements.getTypeElement(importDecl.getNameAsString()); + TypeElement importedElement = getTypeElement(elements, importDecl.getNameAsString(), cache); if (importedElement != null) { - TypeElement result = resolveMemberType(elements, importedElement, firstComponent, suffix); + TypeElement result = + resolveMemberType(elements, importedElement, firstComponent, suffix, cache); if (result != null) { return result; } @@ -251,7 +280,28 @@ && declaresMemberType(enumConstant.getClassBody(), firstComponent)) { // The name might be fully-qualified, or might be a top-level type in the unnamed package. // This lookup is last, because a type that is in scope shadows a type whose fully-qualified // name is `name`. - return elements.getTypeElement(name); + return getTypeElement(elements, name, cache); + } + + /** + * Returns the element for the type that {@code name} names, or null if it names no type. The + * result of every lookup, including a lookup that finds no type, is memoized in {@code cache}. + * + * @param elements used for looking up names + * @param name a name that might name a type + * @param cache maps a name to the type it names, or to null if it names no type; this method both + * reads and writes it + * @return the element for the type named {@code name}, or null if there is none + */ + @SuppressWarnings("signature:argument") // a call to getTypeElement() whose result is checked + private static @Nullable TypeElement getTypeElement( + Elements elements, String name, Map cache) { + if (cache.containsKey(name)) { + return cache.get(name); + } + TypeElement result = elements.getTypeElement(name); + cache.put(name, result); + return result; } /** @@ -325,12 +375,15 @@ private static boolean declaresMemberType(List members, String n } /** - * Returns the supertypes that the given type declaration names: its {@code extends} clause and - * its {@code implements} clause. The result does not include an implicit supertype such as {@code - * java.lang.Object}, none of which declares a member type. + * Returns the supertypes of the given type declaration from which it can inherit a member type: + * those that its {@code extends} clause and its {@code implements} clause name, plus the implicit + * superclass {@code java.lang.Enum} of an enum, which declares the member type {@code + * Enum.EnumDesc}. The result does not include the other implicit supertypes -- {@code + * java.lang.Object}, {@code java.lang.Record}, and {@code java.lang.annotation.Annotation} -- + * none of which declares a member type. * * @param typeDecl a JavaParser type declaration - * @return the supertypes that {@code typeDecl} names + * @return the supertypes of {@code typeDecl} that might declare a member type */ private static List supertypes(TypeDeclaration typeDecl) { if (typeDecl instanceof ClassOrInterfaceDeclaration classDecl) { @@ -339,7 +392,10 @@ private static List supertypes(TypeDeclaration typeDecl return result; } if (typeDecl instanceof EnumDeclaration enumDecl) { - return enumDecl.getImplementedTypes(); + // Copy the list rather than side-effecting the AST by adding to it. + List result = new ArrayList<>(enumDecl.getImplementedTypes()); + result.add(javaLangEnum()); + return result; } if (typeDecl instanceof RecordDeclaration recordDecl) { return recordDecl.getImplementedTypes(); @@ -348,6 +404,43 @@ private static List supertypes(TypeDeclaration typeDecl return Collections.emptyList(); } + /** + * Returns a new JavaParser type that names {@code java.lang.Enum}, the implicit superclass of + * every enum. The result is not part of any AST, so {@link #resolveTypeName} resolves its name as + * a fully-qualified name. + * + * @return a JavaParser type that names {@code java.lang.Enum} + */ + private static ClassOrInterfaceType javaLangEnum() { + return new ClassOrInterfaceType( + new ClassOrInterfaceType(new ClassOrInterfaceType(null, "java"), "lang"), "Enum"); + } + + /** + * Returns the children of the given type declaration in which its member types are in scope: its + * body declarations, an enum's constants, and a record's components. The result does not include + * a child in which they are not in scope: an annotation on the declaration, a type parameter, or + * a supertype name. + * + * @param typeDecl a JavaParser type declaration + * @return the children of {@code typeDecl} in which its member types are in scope + */ + private static List scopeOfMemberTypes(TypeDeclaration typeDecl) { + if (typeDecl instanceof EnumDeclaration enumDecl) { + // A member type is in scope in a constant's arguments and in its class body. + List result = new ArrayList<>(enumDecl.getMembers()); + result.addAll(enumDecl.getEntries()); + return result; + } + if (typeDecl instanceof RecordDeclaration recordDecl) { + // A member type is in scope in the type of a record component. + List result = new ArrayList<>(recordDecl.getMembers()); + result.addAll(recordDecl.getParameters()); + return result; + } + return typeDecl.getMembers(); + } + /** * Returns true if one of the given nodes is the given node, compared by reference equality. * JavaParser's {@code equals()} is structural, so {@link List#contains} does not distinguish two @@ -379,10 +472,16 @@ private static boolean containsSame(List nodes, Node node) { * @param firstComponent the simple name of a member type of {@code typeElement} * @param suffix the rest of the type name, which names a type nested within {@code * firstComponent}; it is empty or starts with "." + * @param cache maps a name to the type it names, or to null if it names no type; this method both + * reads and writes it * @return the element for the member type, or null if it cannot be determined */ private static @Nullable TypeElement resolveMemberType( - Elements elements, TypeElement typeElement, String firstComponent, String suffix) { + Elements elements, + TypeElement typeElement, + String firstComponent, + String suffix, + Map cache) { Set visited = new HashSet<>(); visited.add(typeElement); Deque worklist = new ArrayDeque<>(); @@ -396,9 +495,7 @@ private static boolean containsSame(List nodes, Node node) { if (suffix.isEmpty()) { return member; } - @SuppressWarnings("signature:argument") // concatenation of canonical names is canonical - TypeElement result = elements.getTypeElement(member.getQualifiedName() + suffix); - return result; + return getTypeElement(elements, member.getQualifiedName() + suffix, cache); } } for (TypeElement supertype : ElementUtils.getDirectSuperTypeElements(current, elements)) { From be1256fcef0acb9597319f9d4532e4c6f40bcfd4 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Wed, 16 Sep 2026 10:54:31 -0700 Subject: [PATCH 08/58] Don't treat an uninherited member type as inherited `resolveMemberType` returned any non-private member type that it found in a supertype. A package-private member type is inherited only within its own package, so a package-private member type of a supertype in a different package was wrongly returned. Also, a declaration hides what its declaring type would otherwise inherit even when the declaration itself is not inherited, so the search must not continue past it into that type's supertypes. Either error made `annotationIsRelevant` ask about the wrong type, which could discard a relevant annotation. Co-Authored-By: Claude Opus 5 --- .../AinferRelevanceAjavaGenerationTest.java | 31 +++--- .../PackagePrivateNotInherited.ajava.goal | 39 ++++++++ .../ainfer-relevance/non-annotated/List.java | 3 +- .../PackagePrivateNotInherited.java | 32 +++++++ .../otherpackage/OtherPackageGrandparent.java | 15 +++ .../otherpackage/OtherPackageSuperclass.java | 16 ++++ ...rpackage.OtherPackageSuperclass.ajava.goal | 18 ++++ .../framework/util/JavaParserUtil.java | 94 ++++++++++++++++--- 8 files changed, 218 insertions(+), 30 deletions(-) create mode 100644 checker/tests/ainfer-relevance/PackagePrivateNotInherited.ajava.goal create mode 100644 checker/tests/ainfer-relevance/non-annotated/PackagePrivateNotInherited.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/otherpackage/OtherPackageGrandparent.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/otherpackage/OtherPackageSuperclass.java create mode 100644 checker/tests/ainfer-relevance/otherpackage.OtherPackageSuperclass.ajava.goal diff --git a/checker/src/test/java/org/checkerframework/checker/test/junit/ainferrunners/AinferRelevanceAjavaGenerationTest.java b/checker/src/test/java/org/checkerframework/checker/test/junit/ainferrunners/AinferRelevanceAjavaGenerationTest.java index 4b32dd1554ee..5e2148443899 100644 --- a/checker/src/test/java/org/checkerframework/checker/test/junit/ainferrunners/AinferRelevanceAjavaGenerationTest.java +++ b/checker/src/test/java/org/checkerframework/checker/test/junit/ainferrunners/AinferRelevanceAjavaGenerationTest.java @@ -66,8 +66,8 @@ public static String[] getTestDirs() { /** * Compares each generated ajava file to its goal file. A goal file is named {@code - * .ajava.goal}. Every generated ajava file must have a goal file and vice versa, so - * that no inference result goes unexamined. + * .ajava.goal}. Every generated ajava file must have a goal file and + * vice versa, so that no inference result goes unexamined. * *

Unlike the second (validation) pass of this test, this comparison detects an annotation that * inference wrote even though the annotation is irrelevant where it appears. Such an annotation @@ -140,26 +140,23 @@ private static List generatedAjavaFiles() throws IOException { * Returns the goal file that corresponds to the given generated ajava file. The goal file need * not exist. * + *

Inference writes a class's ajava file into a subdirectory of {@link #inferenceOutputDir} + * that corresponds to the class's package, but the goal files are all in {@link #goalDir}, so a + * goal file's name starts with the class's fully-qualified name. + * * @param ajavaFile a generated ajava file * @return the goal file that corresponds to {@code ajavaFile} */ private static Path goalFileFor(Path ajavaFile) { - if (!inferenceOutputDir.equals(ajavaFile.getParent())) { - // Inference writes a class's ajava file into a subdirectory that corresponds to the class's - // package, but the goal files are all in one directory, so a goal file for a class in a - // named package could never match. Every test input is in the unnamed package, so this - // failure means that a new test input declares a package. - Assert.fail( - String.format( - "%s is not directly in %s, so its class is not in the unnamed package. Either put" - + " the test input in the unnamed package, or generalize" - + " AinferRelevanceAjavaGenerationTest to give each goal file a name that" - + " includes the package.%n", - ajavaFile.toAbsolutePath(), inferenceOutputDir.toAbsolutePath())); - } - String ajavaFileName = ajavaFile.getFileName().toString(); + Path relativeToOutputDir = inferenceOutputDir.relativize(ajavaFile); + String ajavaFileName = relativeToOutputDir.getFileName().toString(); String className = ajavaFileName.substring(0, ajavaFileName.length() - ajavaSuffix.length()); - return goalDir.resolve(className + goalSuffix); + Path packageDir = relativeToOutputDir.getParent(); + String qualifiedName = + packageDir == null + ? className + : packageDir.toString().replace(File.separatorChar, '.') + "." + className; + return goalDir.resolve(qualifiedName + goalSuffix); } /** diff --git a/checker/tests/ainfer-relevance/PackagePrivateNotInherited.ajava.goal b/checker/tests/ainfer-relevance/PackagePrivateNotInherited.ajava.goal new file mode 100644 index 000000000000..86ff174e327a --- /dev/null +++ b/checker/tests/ainfer-relevance/PackagePrivateNotInherited.ajava.goal @@ -0,0 +1,39 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A class does not inherit a package-private member type of a superclass that is in a different +// package, and a declaration hides what its declaring class would otherwise inherit even when the +// declaration itself is not inherited. This class therefore inherits neither +// `otherpackage.OtherPackageSuperclass.List` (which is package-private, and this compilation unit +// is in the unnamed package) nor `otherpackage.OtherPackageGrandparent.List` (which is public, but +// which `OtherPackageSuperclass` does not inherit, because `OtherPackageSuperclass` declares a +// member type of the same name). Inference must resolve the name "List" to the top-level class +// `List` in the unnamed package, which is relevant because it is a subtype of `CharSequence`; +// both member classes named `List` are irrelevant. If inference resolves the name incorrectly and +// therefore discards the annotation, then the second (validation) pass of this test issues the +// warning that is written below. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class PackagePrivateNotInherited extends otherpackage.OtherPackageSuperclass { + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 List field; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "this.field" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + void assignField() { + field = getSibling1(); + } + + @org.checkerframework.dataflow.qual.Impure + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + void expectsSibling1(PackagePrivateNotInherited this, List l) { + } + + @org.checkerframework.dataflow.qual.Pure + List getSibling1(PackagePrivateNotInherited this) { + return null; + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/List.java b/checker/tests/ainfer-relevance/non-annotated/List.java index 3c8f80a6cefc..6245986087ea 100644 --- a/checker/tests/ainfer-relevance/non-annotated/List.java +++ b/checker/tests/ainfer-relevance/non-annotated/List.java @@ -1,5 +1,6 @@ // This class exists so that the unnamed package declares a type whose name is also the name of a -// type that `SamePackageShadows.java` imports on demand. +// type that `SamePackageShadows.java` imports on demand and the name of a package-private member +// type that `PackagePrivateNotInherited.java`'s superclass declares. // // This class is relevant, because it is a subtype of `CharSequence`, which the checker's // `@RelevantJavaTypes` lists. By contrast, `java.util.List` is irrelevant. diff --git a/checker/tests/ainfer-relevance/non-annotated/PackagePrivateNotInherited.java b/checker/tests/ainfer-relevance/non-annotated/PackagePrivateNotInherited.java new file mode 100644 index 000000000000..8f84303a6d63 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/PackagePrivateNotInherited.java @@ -0,0 +1,32 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A class does not inherit a package-private member type of a superclass that is in a different +// package, and a declaration hides what its declaring class would otherwise inherit even when the +// declaration itself is not inherited. This class therefore inherits neither +// `otherpackage.OtherPackageSuperclass.List` (which is package-private, and this compilation unit +// is in the unnamed package) nor `otherpackage.OtherPackageGrandparent.List` (which is public, but +// which `OtherPackageSuperclass` does not inherit, because `OtherPackageSuperclass` declares a +// member type of the same name). Inference must resolve the name "List" to the top-level class +// `List` in the unnamed package, which is relevant because it is a subtype of `CharSequence`; +// both member classes named `List` are irrelevant. If inference resolves the name incorrectly and +// therefore discards the annotation, then the second (validation) pass of this test issues the +// warning that is written below. +public class PackagePrivateNotInherited extends otherpackage.OtherPackageSuperclass { + + List field; + + void assignField() { + field = getSibling1(); + } + + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + void expectsSibling1(@AinferSibling1 List l) {} + + @AinferSibling1 List getSibling1() { + return null; + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/otherpackage/OtherPackageGrandparent.java b/checker/tests/ainfer-relevance/non-annotated/otherpackage/OtherPackageGrandparent.java new file mode 100644 index 000000000000..63847dbae539 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/otherpackage/OtherPackageGrandparent.java @@ -0,0 +1,15 @@ +package otherpackage; + +// This class exists so that `OtherPackageSuperclass`'s package-private member type hides an +// inherited member type of the same name. It is used by `PackagePrivateNotInherited.java`. +// +// This class's member class `List` is irrelevant, because it is not a subtype of any type in the +// checker's `@RelevantJavaTypes`. By contrast, the top-level class `List` in the unnamed package +// is relevant. +public class OtherPackageGrandparent { + + // `OtherPackageSuperclass` declares a member type of the same name, so `OtherPackageSuperclass` + // does not inherit this one, and therefore no subclass of `OtherPackageSuperclass` does either -- + // not even a subclass in this package, for which this member type is accessible. + public static class List {} +} diff --git a/checker/tests/ainfer-relevance/non-annotated/otherpackage/OtherPackageSuperclass.java b/checker/tests/ainfer-relevance/non-annotated/otherpackage/OtherPackageSuperclass.java new file mode 100644 index 000000000000..ad036d880e43 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/otherpackage/OtherPackageSuperclass.java @@ -0,0 +1,16 @@ +package otherpackage; + +// This class exists so that a test input in the unnamed package can extend a class that is in a +// different package and that declares a package-private member type. It is used by +// `PackagePrivateNotInherited.java`. +// +// This class's member class `List` is irrelevant, because it is not a subtype of any type in the +// checker's `@RelevantJavaTypes`. By contrast, the top-level class `List` in the unnamed package +// is relevant. +public class OtherPackageSuperclass extends OtherPackageGrandparent { + + // A subclass in another package does not inherit this member type, so the name "List" in such a + // subclass's body does not refer to it. This declaration also hides + // `OtherPackageGrandparent.List`, which no subclass of this class inherits. + static class List {} +} diff --git a/checker/tests/ainfer-relevance/otherpackage.OtherPackageSuperclass.ajava.goal b/checker/tests/ainfer-relevance/otherpackage.OtherPackageSuperclass.ajava.goal new file mode 100644 index 000000000000..bdc1b84d1744 --- /dev/null +++ b/checker/tests/ainfer-relevance/otherpackage.OtherPackageSuperclass.ajava.goal @@ -0,0 +1,18 @@ +package otherpackage; + +// This class exists so that a test input in the unnamed package can extend a class that is in a +// different package and that declares a package-private member type. It is used by +// `PackagePrivateNotInherited.java`. +// +// This class's member class `List` is irrelevant, because it is not a subtype of any type in the +// checker's `@RelevantJavaTypes`. By contrast, the top-level class `List` in the unnamed package +// is relevant. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class OtherPackageSuperclass extends OtherPackageGrandparent { + + // A subclass in another package does not inherit this member type, so the name "List" in such a + // subclass's body does not refer to it. This declaration also hides + // `OtherPackageGrandparent.List`, which no subclass of this class inherits. + static class List { + } +} diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index fd59b80a06c0..1949421995ea 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -460,12 +460,25 @@ private static boolean containsSame(List nodes, Node node) { return false; } + /** + * A type whose member types {@link #resolveMemberType} searches, together with whether the type's + * package-private member types are inherited by the type at which the search started. + * + * @param typeElement the type whose member types to search + * @param packagePrivateIsInherited true if a package-private member type of {@code typeElement} + * is a member of the type at which the search started + */ + private record SearchedType(TypeElement typeElement, boolean packagePrivateIsInherited) {} + /** * Returns the element for the member type named {@code firstComponent + suffix} that {@code * typeElement} declares or inherits, or null if there is no such member type. * *

{@code typeElement} and its supertypes are searched in breadth-first order, so a member type - * that is declared in a nearer supertype hides one that is declared in a farther supertype. + * that is declared in a nearer supertype hides one that is declared in a farther supertype. A + * declaration hides whatever its declaring type would otherwise inherit, even if the declaration + * is not itself inherited: a private member type is inherited by no type, and a package-private + * member type is inherited only within its own package. * * @param elements used for looking up names * @param typeElement the type whose member types to search @@ -484,29 +497,86 @@ private static boolean containsSame(List nodes, Node node) { Map cache) { Set visited = new HashSet<>(); visited.add(typeElement); - Deque worklist = new ArrayDeque<>(); - worklist.add(typeElement); + Deque worklist = new ArrayDeque<>(); + // Every member type that `typeElement` declares is a member of `typeElement`, whatever its + // access modifier is. + worklist.add(new SearchedType(typeElement, true)); while (!worklist.isEmpty()) { - TypeElement current = worklist.remove(); - for (TypeElement member : ElementFilter.typesIn(current.getEnclosedElements())) { - // A private member type is not inherited. - if (member.getSimpleName().contentEquals(firstComponent) - && !member.getModifiers().contains(Modifier.PRIVATE)) { + SearchedType current = worklist.remove(); + TypeElement currentElement = current.typeElement(); + // A type declares at most one member type with a given simple name. + TypeElement declared = null; + for (TypeElement member : ElementFilter.typesIn(currentElement.getEnclosedElements())) { + if (member.getSimpleName().contentEquals(firstComponent)) { + declared = member; + break; + } + } + if (declared != null) { + if (isInherited(declared, current.packagePrivateIsInherited())) { if (suffix.isEmpty()) { - return member; + return declared; } - return getTypeElement(elements, member.getQualifiedName() + suffix, cache); + return getTypeElement(elements, declared.getQualifiedName() + suffix, cache); } + // `declared` is not a member of the type at which the search started, and it hides every + // member type of the same name that `currentElement` would otherwise inherit, so do not + // search the supertypes of `currentElement`. + continue; } - for (TypeElement supertype : ElementUtils.getDirectSuperTypeElements(current, elements)) { + for (TypeElement supertype : + ElementUtils.getDirectSuperTypeElements(currentElement, elements)) { if (visited.add(supertype)) { - worklist.add(supertype); + // `currentElement` inherits a package-private member type of `supertype` only if the two + // types are in the same package. + worklist.add( + new SearchedType( + supertype, + current.packagePrivateIsInherited() + && inSamePackage(elements, supertype, currentElement))); } } } return null; } + /** + * Returns true if {@code member} is a member of the type at which a search by {@link + * #resolveMemberType} started -- that is, if every type between that type and the type that + * declares {@code member} inherits it. + * + * @param member a member type of the type that is currently being searched + * @param packagePrivateIsInherited true if a package-private member type of the type that is + * currently being searched is a member of the type at which the search started + * @return true if {@code member} is a member of the type at which the search started + */ + private static boolean isInherited(TypeElement member, boolean packagePrivateIsInherited) { + Set modifiers = member.getModifiers(); + if (modifiers.contains(Modifier.PRIVATE)) { + // A private member type is not inherited. + return false; + } + if (modifiers.contains(Modifier.PUBLIC) || modifiers.contains(Modifier.PROTECTED)) { + return true; + } + return packagePrivateIsInherited; + } + + /** + * Returns true if the two types are declared in the same package. + * + * @param elements used for looking up the package that contains a type + * @param type1 a type + * @param type2 a type + * @return true if {@code type1} and {@code type2} are declared in the same package + */ + private static boolean inSamePackage(Elements elements, TypeElement type1, TypeElement type2) { + return elements + .getPackageOf(type1) + .getQualifiedName() + .contentEquals(elements.getPackageOf(type2).getQualifiedName()); + } + /** * Given the compilation unit node for a source file, returns the top level type definition with * the given name. From 2263d3a51149fdee23318d6ac3f6f4f2d3d3eb3d Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Wed, 16 Sep 2026 11:26:15 -0700 Subject: [PATCH 09/58] Memoize the name lookups of `JavaParserUtil.resolveTypeName` Resolving one type name looks up many candidate names -- one per enclosing type declaration, one per import, one per on-demand import, one in the same package, one in `java.lang`, and one for the name as a fully-qualified name. Most of those names name no type, and a client that resolves many names looks up the same names over and over. Add an overload of `resolveTypeName` that takes a cache, and route every lookup through it, so that a client that resolves many names pays for each distinct name only once. The cache records a lookup that finds no type, which is the common case. The existing one-argument overload is unchanged from a caller's point of view; it allocates a cache that lives for the one call. This is a performance change; it does not change any result. A cache must not be reused across annotation processing rounds or across `Elements` instances, because a name that names no type in one round might name a generated type in a later round; the Javadoc says so. Co-Authored-By: Claude Opus 5 --- .../framework/util/JavaParserUtil.java | 83 +++++++++++++++---- 1 file changed, 67 insertions(+), 16 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index 4ff5b08d654f..20d64ba9faf6 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -22,8 +22,10 @@ import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Deque; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import javax.annotation.processing.ProcessingEnvironment; @@ -53,13 +55,36 @@ private JavaParserUtil() { * type declarations and the compilation unit that contain it. Returns null if the name cannot be * resolved, which happens for a type variable and for a type that is not on the classpath. * + *

A client that resolves many names should call {@link #resolveTypeName(Elements, + * ClassOrInterfaceType, Map)}, which memoizes the name lookups. + * * @param elements used for looking up names * @param type a JavaParser class or interface type * @return the element for {@code type}, or null if it cannot be determined */ - @SuppressWarnings("signature:argument") // calls to getTypeElement() whose result is checked public static @Nullable TypeElement resolveTypeName( Elements elements, ClassOrInterfaceType type) { + return resolveTypeName(elements, type, new HashMap<>(4)); + } + + /** + * Returns the element for the given JavaParser type, whose name is resolved in the scope of the + * type declarations and the compilation unit that contain it. Returns null if the name cannot be + * resolved, which happens for a type variable and for a type that is not on the classpath. + * + *

Resolving one name looks up many candidate names, most of which name no type, so a client + * that resolves many names should pass the same cache to each call. A cache should not be reused + * across annotation processing rounds or across {@code Elements} instances: a name that names no + * type in one round might name a generated type in a later round. + * + * @param elements used for looking up names + * @param type a JavaParser class or interface type + * @param cache maps a name to the type it names, or to null if it names no type; this method both + * reads and writes it + * @return the element for {@code type}, or null if it cannot be determined + */ + public static @Nullable TypeElement resolveTypeName( + Elements elements, ClassOrInterfaceType type, Map cache) { String name = type.getNameWithScope(); // `firstComponent` is what a single-type import must import; the rest of `name` names a @@ -93,14 +118,14 @@ private JavaParserUtil() { if (ancestor instanceof TypeDeclaration enclosingType) { String enclosingName = enclosingType.getFullyQualifiedName().orElse(null); if (enclosingName != null) { - TypeElement result = elements.getTypeElement(enclosingName + "." + name); + TypeElement result = getTypeElement(elements, enclosingName + "." + name, cache); if (result != null) { return result; } // The enclosing type might inherit the member type rather than declare it. - TypeElement enclosingElement = elements.getTypeElement(enclosingName); + TypeElement enclosingElement = getTypeElement(elements, enclosingName, cache); if (enclosingElement != null) { - result = resolveMemberType(elements, enclosingElement, firstComponent, suffix); + result = resolveMemberType(elements, enclosingElement, firstComponent, suffix, cache); if (result != null) { return result; } @@ -112,7 +137,7 @@ private JavaParserUtil() { CompilationUnit cu = type.findCompilationUnit().orElse(null); if (cu == null) { // The name might be fully-qualified. - return elements.getTypeElement(name); + return getTypeElement(elements, name, cache); } // A single-type import or a single-static import of a member type takes precedence over an @@ -123,7 +148,7 @@ private JavaParserUtil() { } String importedName = importDecl.getNameAsString(); if (importedName.equals(firstComponent) || importedName.endsWith("." + firstComponent)) { - TypeElement result = elements.getTypeElement(importedName + suffix); + TypeElement result = getTypeElement(elements, importedName + suffix, cache); if (result != null) { return result; } @@ -134,9 +159,9 @@ private JavaParserUtil() { // import that names a field or a method resolves to no type element at all.) String containerName = importedName.substring(0, importedName.length() - firstComponent.length() - 1); - TypeElement containerElement = elements.getTypeElement(containerName); + TypeElement containerElement = getTypeElement(elements, containerName, cache); if (containerElement != null) { - result = resolveMemberType(elements, containerElement, firstComponent, suffix); + result = resolveMemberType(elements, containerElement, firstComponent, suffix, cache); if (result != null) { return result; } @@ -158,7 +183,7 @@ private JavaParserUtil() { } containerPrefixes.add("java.lang."); for (String containerPrefix : containerPrefixes) { - TypeElement result = elements.getTypeElement(containerPrefix + name); + TypeElement result = getTypeElement(elements, containerPrefix + name, cache); if (result != null) { return result; } @@ -168,9 +193,10 @@ private JavaParserUtil() { // type inherits. for (ImportDeclaration importDecl : cu.getImports()) { if (importDecl.isAsterisk()) { - TypeElement importedElement = elements.getTypeElement(importDecl.getNameAsString()); + TypeElement importedElement = getTypeElement(elements, importDecl.getNameAsString(), cache); if (importedElement != null) { - TypeElement result = resolveMemberType(elements, importedElement, firstComponent, suffix); + TypeElement result = + resolveMemberType(elements, importedElement, firstComponent, suffix, cache); if (result != null) { return result; } @@ -181,7 +207,28 @@ private JavaParserUtil() { // The name might be fully-qualified, or might be a top-level type in the unnamed package. // This lookup is last, because a type that is in scope shadows a type whose fully-qualified // name is `name`. - return elements.getTypeElement(name); + return getTypeElement(elements, name, cache); + } + + /** + * Returns the element for the type that {@code name} names, or null if it names no type. The + * result of every lookup, including a lookup that finds no type, is memoized in {@code cache}. + * + * @param elements used for looking up names + * @param name a name that might name a type + * @param cache maps a name to the type it names, or to null if it names no type; this method both + * reads and writes it + * @return the element for the type named {@code name}, or null if there is none + */ + @SuppressWarnings("signature:argument") // a call to getTypeElement() whose result is checked + private static @Nullable TypeElement getTypeElement( + Elements elements, String name, Map cache) { + if (cache.containsKey(name)) { + return cache.get(name); + } + TypeElement result = elements.getTypeElement(name); + cache.put(name, result); + return result; } /** @@ -196,10 +243,16 @@ private JavaParserUtil() { * @param firstComponent the simple name of a member type of {@code typeElement} * @param suffix the rest of the type name, which names a type nested within {@code * firstComponent}; it is empty or starts with "." + * @param cache maps a name to the type it names, or to null if it names no type; this method both + * reads and writes it * @return the element for the member type, or null if it cannot be determined */ private static @Nullable TypeElement resolveMemberType( - Elements elements, TypeElement typeElement, String firstComponent, String suffix) { + Elements elements, + TypeElement typeElement, + String firstComponent, + String suffix, + Map cache) { Set visited = new HashSet<>(); visited.add(typeElement); Deque worklist = new ArrayDeque<>(); @@ -213,9 +266,7 @@ private JavaParserUtil() { if (suffix.isEmpty()) { return member; } - @SuppressWarnings("signature:argument") // concatenation of canonical names is canonical - TypeElement result = elements.getTypeElement(member.getQualifiedName() + suffix); - return result; + return getTypeElement(elements, member.getQualifiedName() + suffix, cache); } } for (TypeElement supertype : ElementUtils.getDirectSuperTypeElements(current, elements)) { From 5b90bc669862d6242fe770a65a3546de1c77b5b5 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Wed, 16 Sep 2026 11:30:19 -0700 Subject: [PATCH 10/58] Remove an unprinted annotation from the AST, not from the pretty-printer To avoid cluttering an ajava file, whole-program inference does not print the invisible qualifiers. It suppressed them by overriding the pretty-printer's three `visit(...AnnotationExpr)` methods to return without printing. By the time the pretty-printer visits an annotation, it has already printed the whitespace that separates the annotation from what follows it, so each suppressed annotation leaves a stray space or blank line: `java.util. Date`, `static double`, `String [] []`. That partly defeats the purpose of not printing the annotation, which is to reduce clutter. Instead, remove the annotations that should not be printed from a clone of the compilation unit, and print that. The pretty-printer then outputs no separator for them. The clone is necessary because the removal is a side effect, and the AST is printed once per checker that was run. No test output changes: the test checkers declare no invisible qualifier, so nothing is removed in the test suite. This refactoring is worthwhile on its own for the stray whitespace it fixes for a checker that does declare one, and it is a prerequisite for omitting irrelevant annotations, which would otherwise leave the same stray whitespace on every annotation it omits. Co-Authored-By: Claude Opus 5 --- ...holeProgramInferenceJavaParserStorage.java | 62 ++++++++++--------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java index 097d19c46dad..27d837edb5b5 100644 --- a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java +++ b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java @@ -20,11 +20,9 @@ import com.github.javaparser.ast.body.VariableDeclarator; import com.github.javaparser.ast.expr.AnnotationExpr; import com.github.javaparser.ast.expr.CharLiteralExpr; -import com.github.javaparser.ast.expr.MarkerAnnotationExpr; -import com.github.javaparser.ast.expr.NormalAnnotationExpr; import com.github.javaparser.ast.expr.ObjectCreationExpr; -import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr; import com.github.javaparser.ast.expr.StringLiteralExpr; +import com.github.javaparser.ast.nodeTypes.NodeWithAnnotations; import com.github.javaparser.ast.type.ClassOrInterfaceType; import com.github.javaparser.ast.type.Type; import com.github.javaparser.ast.type.TypeParameter; @@ -60,6 +58,7 @@ import java.util.StringJoiner; import java.util.TreeMap; import java.util.TreeSet; +import java.util.function.Predicate; import java.util.stream.Collectors; import javax.lang.model.element.AnnotationMirror; import javax.lang.model.element.Element; @@ -1092,38 +1091,19 @@ private void writeAjavaFile(Path outputPath, CompilationUnitAnnos root) { // and crashes when adding annotations in certain locations. // LexicalPreservingPrinter.print(root.declaration, writer); - // Do not print invisible qualifiers, to avoid cluttering the output. - Set invisibleQualifierNames = getInvisibleQualifierNames(this.atypeFactory); + // The compilation unit is cloned because this method side-effects it: it removes the + // annotations that should not be printed. Removing them from the AST, rather than + // suppressing them in the pretty-printer, prevents the pretty-printer from outputting the + // whitespace that would have separated an annotation from what follows it. + CompilationUnit compilationUnit = root.compilationUnit.clone(); + removeUnprintedAnnotations(compilationUnit); + DefaultPrettyPrinter prettyPrinter = new DefaultPrettyPrinter() { @Override public String print(Node node) { VoidVisitor visitor = new DefaultPrettyPrinterVisitor(getConfiguration()) { - @Override - public void visit(MarkerAnnotationExpr n, Void arg) { - if (invisibleQualifierNames.contains(n.getName().toString())) { - return; - } - super.visit(n, arg); - } - - @Override - public void visit(SingleMemberAnnotationExpr n, Void arg) { - if (invisibleQualifierNames.contains(n.getName().toString())) { - return; - } - super.visit(n, arg); - } - - @Override - public void visit(NormalAnnotationExpr n, Void arg) { - if (invisibleQualifierNames.contains(n.getName().toString())) { - return; - } - super.visit(n, arg); - } - // visit(CharLiteralExpr) and visit(StringLiteralExpr) work around bugs in // JavaParser, with respect to handling lonely surrogate characters. @@ -1151,13 +1131,35 @@ public void visit(final StringLiteralExpr n, final Void arg) { } }; - String fileContent = prettyPrinter.print(root.compilationUnit); + String fileContent = prettyPrinter.print(compilationUnit); writer.write(fileContent); } catch (IOException e) { throw new BugInCF("Error while writing ajava file " + outputPath, e); } } + /** + * Removes from the given compilation unit the annotations that an ajava file should not contain, + * because they would clutter it: the invisible qualifiers. + * + * @param compilationUnit the compilation unit to side-effect + */ + private void removeUnprintedAnnotations(CompilationUnit compilationUnit) { + Set invisibleQualifierNames = getInvisibleQualifierNames(this.atypeFactory); + Predicate shouldRemove = + anno -> invisibleQualifierNames.contains(anno.getNameAsString()); + for (Node node : compilationUnit.findAll(Node.class)) { + if (node instanceof NodeWithAnnotations annotated) { + annotated.getAnnotations().removeIf(shouldRemove); + } + if (node instanceof Parameter param) { + // A varargs annotation is on the array type that `...` creates, as in + // `void m(String @Anno ... args)`. It is not in `param.getAnnotations()`. + param.getVarArgsAnnotations().removeIf(shouldRemove); + } + } + } + // TODO: Move these two routines to StringUtils. /** From 8bfa67dce5771041c777bfe0f5ca23ec01b73a50 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Wed, 16 Sep 2026 11:31:46 -0700 Subject: [PATCH 11/58] Don't write irrelevant annotations in `.ajava` files When a checker declares `@RelevantJavaTypes`, whole-program inference could write, into an `.ajava` file, an annotation on a Java type that the checker treats as irrelevant. Such an annotation is clutter: omitting it does not change the result of type-checking. Omit such an annotation when writing the `.ajava` file. The test is conservative: it discards an annotation only when the annotation is definitely irrelevant where it appears. Relevance constrains the Java types on which a qualifier may be *written*, so it says nothing about a declaration annotation, even one that is also a type qualifier. JavaParser attaches an annotation that precedes a declaration's type to the declaration rather than to the type, so the two cases cannot be told apart from the AST alone. Mark each annotation that inference adds as a declaration annotation with a JavaParser `DataKey`, and always retain it. Update the `ainfer-relevance` goal files and the test inputs' comments, which previously recorded the old behavior. Co-Authored-By: Claude Opus 5 --- .../IShouldBeSibling1.ajava.goal | 2 +- .../IrrelevantTypes.ajava.goal | 19 +- .../RelevantSubtype.ajava.goal | 6 +- .../ainfer-relevance/RelevantTypes.ajava.goal | 8 +- .../non-annotated/IrrelevantTypes.java | 9 +- .../non-annotated/RelevantSubtype.java | 4 +- .../non-annotated/RelevantTypes.java | 6 +- docs/CHANGELOG.md | 5 + ...holeProgramInferenceJavaParserStorage.java | 295 ++++++++++++++++-- 9 files changed, 301 insertions(+), 53 deletions(-) diff --git a/checker/tests/ainfer-relevance/IShouldBeSibling1.ajava.goal b/checker/tests/ainfer-relevance/IShouldBeSibling1.ajava.goal index ec581637c4a9..620df29c3c02 100644 --- a/checker/tests/ainfer-relevance/IShouldBeSibling1.ajava.goal +++ b/checker/tests/ainfer-relevance/IShouldBeSibling1.ajava.goal @@ -18,7 +18,7 @@ public class IShouldBeSibling1 { // annotation is nonetheless a declaration annotation. @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 @org.checkerframework.dataflow.qual.Pure - @org.checkerframework.checker.testchecker.ainfer.qual.AinferTop double iShouldBeSibling1(double d) { + double iShouldBeSibling1(double d) { return d; } } diff --git a/checker/tests/ainfer-relevance/IrrelevantTypes.ajava.goal b/checker/tests/ainfer-relevance/IrrelevantTypes.ajava.goal index bf17d18ef0df..f7dd7e7547a4 100644 --- a/checker/tests/ainfer-relevance/IrrelevantTypes.ajava.goal +++ b/checker/tests/ainfer-relevance/IrrelevantTypes.ajava.goal @@ -1,17 +1,16 @@ -// Inference infers annotations for types that this checker treats as irrelevant: `double`, which -// is not listed in the checker's `@RelevantJavaTypes` and is not related to a listed type by -// subtyping; and arrays, because `Object[].class` is not listed. The goal file records that -// inference currently writes those annotations into the .ajava file. They are clutter: because -// the checker treats these types as irrelevant, omitting them would not change the result of +// Inference must not write, into the .ajava file, an annotation that it infers for an irrelevant +// type: `double`, which is not listed in the checker's `@RelevantJavaTypes` and is not related to +// a listed type by subtyping; or an array, because `Object[].class` is not listed. Because the +// checker treats these types as irrelevant, omitting the annotations does not change the result of // type-checking. @org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") public class IrrelevantTypes { - static @org.checkerframework.checker.testchecker.ainfer.qual.AinferTop double doubleField; + static double doubleField; - static @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType String @org.checkerframework.checker.testchecker.ainfer.qual.AinferTop [] arrayField; + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType String[] arrayField; - static java.util.@org.checkerframework.checker.testchecker.ainfer.qual.AinferTop Date dateField; + static java.util.Date dateField; @org.checkerframework.dataflow.qual.Impure static void assignFields() { @@ -21,12 +20,12 @@ public class IrrelevantTypes { } @org.checkerframework.dataflow.qual.Pure - static @org.checkerframework.checker.testchecker.ainfer.qual.AinferTop double doubleReturn() { + static double doubleReturn() { return doubleField; } @org.checkerframework.dataflow.qual.Pure - static @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType String @org.checkerframework.checker.testchecker.ainfer.qual.AinferTop [] arrayReturn() { + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType String[] arrayReturn() { return arrayField; } } diff --git a/checker/tests/ainfer-relevance/RelevantSubtype.ajava.goal b/checker/tests/ainfer-relevance/RelevantSubtype.ajava.goal index c311becdab3c..7f3edd26709a 100644 --- a/checker/tests/ainfer-relevance/RelevantSubtype.ajava.goal +++ b/checker/tests/ainfer-relevance/RelevantSubtype.ajava.goal @@ -12,10 +12,10 @@ public class RelevantSubtype implements CharSequence { } // `char` is irrelevant to this checker: it is not listed in `@RelevantJavaTypes`, and it is not - // related to a listed type by subtyping. The goal file records that inference nonetheless - // writes `@AinferTop` on this return type. + // related to a listed type by subtyping. The goal file shows that inference omits the + // `@AinferTop` that it infers for this return type. @org.checkerframework.dataflow.qual.Pure - public @org.checkerframework.checker.testchecker.ainfer.qual.AinferTop char charAt(@org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType RelevantSubtype this, @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType int index) { + public char charAt(@org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType RelevantSubtype this, @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType int index) { return 'a'; } diff --git a/checker/tests/ainfer-relevance/RelevantTypes.ajava.goal b/checker/tests/ainfer-relevance/RelevantTypes.ajava.goal index 9f7f2abc8765..1a3e10b5e757 100644 --- a/checker/tests/ainfer-relevance/RelevantTypes.ajava.goal +++ b/checker/tests/ainfer-relevance/RelevantTypes.ajava.goal @@ -52,11 +52,11 @@ public class RelevantTypes { } // A leading annotation on a varargs formal parameter is on the parameter's element type, not on - // the array type that `...` creates. The goal file shows that inference writes an annotation on - // each of the two: `@AinferSibling1 String @AinferTop ...`. The annotation on the array type - // is clutter, because arrays are irrelevant to this checker. + // the array type that `...` creates. Inference infers an annotation for each of the two, but + // the goal file contains only `@AinferSibling1 String ...`: the annotation on the array type is + // omitted, because arrays are irrelevant to this checker. @org.checkerframework.dataflow.qual.SideEffectFree - static void varargs(@org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 String@org.checkerframework.checker.testchecker.ainfer.qual.AinferTop ... args) { + static void varargs(@org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 String... args) { } @org.checkerframework.dataflow.qual.Impure diff --git a/checker/tests/ainfer-relevance/non-annotated/IrrelevantTypes.java b/checker/tests/ainfer-relevance/non-annotated/IrrelevantTypes.java index 0bd21ee13936..31c00223ff95 100644 --- a/checker/tests/ainfer-relevance/non-annotated/IrrelevantTypes.java +++ b/checker/tests/ainfer-relevance/non-annotated/IrrelevantTypes.java @@ -1,8 +1,7 @@ -// Inference infers annotations for types that this checker treats as irrelevant: `double`, which -// is not listed in the checker's `@RelevantJavaTypes` and is not related to a listed type by -// subtyping; and arrays, because `Object[].class` is not listed. The goal file records that -// inference currently writes those annotations into the .ajava file. They are clutter: because -// the checker treats these types as irrelevant, omitting them would not change the result of +// Inference must not write, into the .ajava file, an annotation that it infers for an irrelevant +// type: `double`, which is not listed in the checker's `@RelevantJavaTypes` and is not related to +// a listed type by subtyping; or an array, because `Object[].class` is not listed. Because the +// checker treats these types as irrelevant, omitting the annotations does not change the result of // type-checking. public class IrrelevantTypes { diff --git a/checker/tests/ainfer-relevance/non-annotated/RelevantSubtype.java b/checker/tests/ainfer-relevance/non-annotated/RelevantSubtype.java index 2caa12c7e6fd..dad139884902 100644 --- a/checker/tests/ainfer-relevance/non-annotated/RelevantSubtype.java +++ b/checker/tests/ainfer-relevance/non-annotated/RelevantSubtype.java @@ -11,8 +11,8 @@ public int length() { } // `char` is irrelevant to this checker: it is not listed in `@RelevantJavaTypes`, and it is not - // related to a listed type by subtyping. The goal file records that inference nonetheless - // writes `@AinferTop` on this return type. + // related to a listed type by subtyping. The goal file shows that inference omits the + // `@AinferTop` that it infers for this return type. @Override public char charAt(int index) { return 'a'; diff --git a/checker/tests/ainfer-relevance/non-annotated/RelevantTypes.java b/checker/tests/ainfer-relevance/non-annotated/RelevantTypes.java index dafd45dc20c3..cba65a2a861d 100644 --- a/checker/tests/ainfer-relevance/non-annotated/RelevantTypes.java +++ b/checker/tests/ainfer-relevance/non-annotated/RelevantTypes.java @@ -43,9 +43,9 @@ static void useReturns() { } // A leading annotation on a varargs formal parameter is on the parameter's element type, not on - // the array type that `...` creates. The goal file shows that inference writes an annotation on - // each of the two: `@AinferSibling1 String @AinferTop ...`. The annotation on the array type - // is clutter, because arrays are irrelevant to this checker. + // the array type that `...` creates. Inference infers an annotation for each of the two, but + // the goal file contains only `@AinferSibling1 String ...`: the annotation on the array type is + // omitted, because arrays are irrelevant to this checker. static void varargs(String... args) {} static void callVarargs() { diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b5d4ee08440c..68e501df9fe9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,11 @@ ### User-visible changes +Whole-program inference no longer writes, into an `.ajava` file, an annotation +that is irrelevant where it appears -- that is, one written on a Java type +that the checker's `@RelevantJavaTypes` excludes. Such an annotation was +clutter; omitting it does not change the result of type-checking. + ### Changes for type system implementers `JavaParserUtil`: moved `DEFAULT_LANGUAGE_LEVEL`, `parseCompilationUnit()`, diff --git a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java index 27d837edb5b5..999b5f46c837 100644 --- a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java +++ b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java @@ -1,10 +1,13 @@ package org.checkerframework.common.wholeprograminference; +import com.github.javaparser.ast.ArrayCreationLevel; import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.DataKey; import com.github.javaparser.ast.Node; import com.github.javaparser.ast.NodeList; import com.github.javaparser.ast.PackageDeclaration; import com.github.javaparser.ast.body.AnnotationDeclaration; +import com.github.javaparser.ast.body.AnnotationMemberDeclaration; import com.github.javaparser.ast.body.BodyDeclaration; import com.github.javaparser.ast.body.CallableDeclaration; import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; @@ -19,13 +22,18 @@ import com.github.javaparser.ast.body.TypeDeclaration; import com.github.javaparser.ast.body.VariableDeclarator; import com.github.javaparser.ast.expr.AnnotationExpr; +import com.github.javaparser.ast.expr.ArrayCreationExpr; import com.github.javaparser.ast.expr.CharLiteralExpr; import com.github.javaparser.ast.expr.ObjectCreationExpr; import com.github.javaparser.ast.expr.StringLiteralExpr; import com.github.javaparser.ast.nodeTypes.NodeWithAnnotations; +import com.github.javaparser.ast.nodeTypes.NodeWithVariables; +import com.github.javaparser.ast.type.ArrayType; import com.github.javaparser.ast.type.ClassOrInterfaceType; +import com.github.javaparser.ast.type.PrimitiveType; import com.github.javaparser.ast.type.Type; import com.github.javaparser.ast.type.TypeParameter; +import com.github.javaparser.ast.type.VoidType; import com.github.javaparser.ast.visitor.CloneVisitor; import com.github.javaparser.ast.visitor.VoidVisitor; import com.github.javaparser.printer.DefaultPrettyPrinter; @@ -69,11 +77,13 @@ import javax.lang.model.type.TypeKind; import javax.lang.model.type.TypeMirror; import javax.lang.model.util.Elements; +import javax.lang.model.util.Types; import org.checkerframework.afu.scenelib.util.JVMNames; import org.checkerframework.checker.index.qual.Positive; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; import org.checkerframework.checker.nullness.qual.Nullable; import org.checkerframework.checker.signature.qual.BinaryName; +import org.checkerframework.checker.signature.qual.FullyQualifiedName; import org.checkerframework.common.basetype.BaseTypeChecker; import org.checkerframework.common.wholeprograminference.WholeProgramInference.OutputFormat; import org.checkerframework.dataflow.analysis.Analysis; @@ -111,6 +121,14 @@ public class WholeProgramInferenceJavaParserStorage implements WholeProgramInferenceStorage { + /** + * A key for JavaParser {@link Node} data. It marks an annotation that whole-program inference + * added as a declaration annotation rather than as a type qualifier. Such an annotation is + * written to the ajava file even if a type qualifier would be irrelevant where it appears; see + * {@link #annotationIsRelevant}. + */ + private static final DataKey IS_DECLARATION_ANNOTATION = new DataKey() {}; + /** The type factory associated with this. */ protected final AnnotatedTypeFactory atypeFactory; @@ -1053,16 +1071,243 @@ public void writeResultsToFile(OutputFormat outputFormat, BaseTypeChecker checke Path outputPathNoCheckerName = packageDir.resolve(name + ".ajava"); // Avoid re-writing this file for each checker that was run. if (Files.notExists(outputPathNoCheckerName)) { - writeAjavaFile(outputPathNoCheckerName, root); + // This output is supposed to reproduce the original source code, so retain every + // annotation that the programmer wrote. + writeAjavaFile(outputPathNoCheckerName, root, false); } } root.transferAnnotations(checker); - writeAjavaFile(outputPath, root); + writeAjavaFile(outputPath, root, true); } modifiedFiles.clear(); } + /** + * Adds {@code anno} to {@code node}, marking it as a declaration annotation. Use this rather than + * {@code node.addAnnotation(...)} whenever the annotation was inferred for a declaration rather + * than for a type, so that {@link #annotationIsRelevant} does not discard it. + * + * @param node the JavaParser node for a declaration + * @param anno a declaration annotation that was inferred for {@code node} + */ + private static void writeDeclarationAnnotation( + NodeWithAnnotations node, AnnotationMirror anno) { + AnnotationExpr annoExpr = + AnnotationMirrorToAnnotationExprConversion.annotationMirrorToAnnotationExpr(anno); + annoExpr.setData(IS_DECLARATION_ANNOTATION, true); + node.addAnnotation(annoExpr); + } + + /** + * Returns true if the annotation might be relevant (where it appears in the program). + * + * @param anno an annotation + * @return true if the annotation might be relevant + */ + @SuppressWarnings("interning:not.interned") + boolean annotationIsRelevant(AnnotationExpr anno) { + if (!(atypeFactory instanceof GenericAnnotatedTypeFactory gatf)) { + return true; + } + if (gatf.relevantJavaTypes == null) { + return true; + } + + @SuppressWarnings("signature:assignment") // immediately tests + @FullyQualifiedName String aName = anno.getNameAsString(); + if (!atypeFactory.isSupportedQualifier(aName)) { + // The annotation might be a declaration annotation, such as a side effect specification. + return true; + } + + if (anno.containsData(IS_DECLARATION_ANNOTATION)) { + // Inference added the annotation as a declaration annotation, as + // `addMethodDeclarationAnnotation`, `addFieldDeclarationAnnotation`, and + // `addDeclarationAnnotationToFormalParameter` do. Relevance constrains the types on which a + // qualifier may be *written*, so it says nothing about a declaration annotation. + return true; + } + + Node parentNode = anno.getParentNode().orElse(null); + if (parentNode == null) { + return true; + } + + // An annotation that was inferred for a type is attached to a Type node. An annotation that + // was inferred for a declaration is attached to the declaration; it is usually not a type + // qualifier, because a checker should not add a type qualifier as a declaration annotation. + // + // JavaParser attaches an annotation that precedes a declaration's type to the declaration + // rather than to the type, so the two cases cannot be distinguished for a method, a field, or + // a formal parameter. Such an annotation is treated as a type qualifier on the declaration's + // element type -- except when the declaration's type is `void`, on which no type qualifier can + // be written, so the annotation is certainly a declaration annotation. + // + // Only two of the cases below can arise from the annotations that inference writes: the + // `Type` case and the varargs part of the `Parameter` case. `transferAnnotations` first + // removes every annotation from the AST, then adds only `@AnnotatedFor` (which is not a + // supported qualifier), declaration annotations that `writeDeclarationAnnotation` marks, type + // qualifiers on `Type` nodes, and varargs annotations. The other cases are defensive: they + // are not exercised by the test suite, but they keep this method correct for any other + // annotation that might appear in the AST. + + if (parentNode instanceof Type type) { + return typeIsRelevant(gatf, type); + } + if (parentNode instanceof ArrayCreationLevel level) { + // The annotation is on an array type, as in `new String @Anno [10]`. + return arrayCreationLevelIsRelevant(gatf, level); + } + if (parentNode instanceof Parameter param) { + // Use reference equality. `NodeList.contains()` would use structural equality, which does + // not distinguish the two annotations in `void m(@Anno String @Anno ... args)`. + if (param.getVarArgsAnnotations().stream().anyMatch(a -> a == anno)) { + // The annotation is on the array type that `...` creates, as in + // `void m(String @Anno ... args)`. + return typeIsRelevant(gatf, param.getType(), 1); + } + // The annotation precedes the type, so it is on the element type; in + // `void m(@Anno String... args)`, `@Anno` is on `String`. + return typeIsRelevant(gatf, innermostComponentType(param.getType())); + } + if (parentNode instanceof ReceiverParameter receiverParam) { + return typeIsRelevant(gatf, innermostComponentType(receiverParam.getType())); + } + if (parentNode instanceof MethodDeclaration method) { + if (method.getType() instanceof VoidType) { + // No type qualifier can be written on `void`, so the annotation is a declaration + // annotation that is also a type qualifier -- of the sort that + // `addMethodDeclarationAnnotation` creates, though such an annotation is marked and is + // handled above. Be conservative. + return true; + } + return typeIsRelevant(gatf, innermostComponentType(method.getType())); + } + if (parentNode instanceof AnnotationMemberDeclaration member) { + return typeIsRelevant(gatf, innermostComponentType(member.getType())); + } + if (parentNode instanceof NodeWithVariables declaration) { + // A field declaration or a local variable declaration. All its variables have the same + // element type, even if they have different numbers of array levels as in `int i, a[];`. + NodeList variables = declaration.getVariables(); + if (variables.isEmpty()) { + throw new BugInCF( + "No variables in declaration %s [%s]", declaration, declaration.getClass()); + } + return typeIsRelevant(gatf, innermostComponentType(variables.get(0).getType())); + } + + // The annotation is on some other declaration: a type declaration, a type parameter + // declaration, a constructor, etc. Be conservative. + return true; + } + + /** + * Returns true if a type qualifier that is written on the given type might be relevant. This + * implementation is conservative and only returns false if such a qualifier is definitely not + * relevant. + * + * @param gatf the type factory associated with this + * @param type a JavaParser type + * @return true if a type qualifier written on {@code type} might be relevant + */ + private boolean typeIsRelevant(GenericAnnotatedTypeFactory gatf, Type type) { + return typeIsRelevant(gatf, type, 0); + } + + /** + * Returns true if a type qualifier that is written on the given type might be relevant, where the + * type is {@code componentType} wrapped in {@code arrayLevels} array levels. This implementation + * is conservative and only returns false if such a qualifier is definitely not relevant. + * + * @param gatf the type factory associated with this + * @param componentType a JavaParser type + * @param arrayLevels the number of array levels to wrap {@code componentType} in; may be 0 + * @return true if a type qualifier written on the array type might be relevant + */ + private boolean typeIsRelevant( + GenericAnnotatedTypeFactory gatf, Type componentType, int arrayLevels) { + TypeMirror tm = typeToTypeMirror(componentType); + if (tm == null) { + // The type could not be determined. Be conservative. + return true; + } + Types types = atypeFactory.getProcessingEnv().getTypeUtils(); + for (int i = 0; i < arrayLevels; i++) { + tm = types.getArrayType(tm); + } + return gatf.isRelevant(tm); + } + + /** + * Returns true if the annotation on the given array creation level might be relevant. In {@code + * new String @Anno [10][]}, the annotation is on the type {@code String[][]}. + * + * @param gatf the type factory associated with this + * @param level an array creation level that an annotation is written on + * @return true if the annotation on {@code level} might be relevant + */ + @SuppressWarnings("interning:not.interned") // reference equality of AST nodes + private boolean arrayCreationLevelIsRelevant( + GenericAnnotatedTypeFactory gatf, ArrayCreationLevel level) { + if (!(level.getParentNode().orElse(null) instanceof ArrayCreationExpr creation)) { + // Be conservative. + return true; + } + // Use reference equality; `NodeList.indexOf()` would use structural equality, which does not + // distinguish the two levels in `new String @Anno [10] @Anno [10]`. + NodeList levels = creation.getLevels(); + for (int i = 0; i < levels.size(); i++) { + if (levels.get(i) == level) { + return typeIsRelevant(gatf, creation.getElementType(), levels.size() - i); + } + } + // Be conservative. + return true; + } + + /** + * Returns the TypeMirror for the given JavaParser type, or null if it cannot be determined. + * + * @param type a JavaParser type + * @return the TypeMirror for {@code type}, or null if it cannot be determined + */ + private @Nullable TypeMirror typeToTypeMirror(Type type) { + Types types = atypeFactory.getProcessingEnv().getTypeUtils(); + if (type instanceof ArrayType arrayType) { + TypeMirror componentType = typeToTypeMirror(arrayType.getComponentType()); + return componentType == null ? null : types.getArrayType(componentType); + } + if (type instanceof PrimitiveType primitiveType) { + return types.getPrimitiveType(JavaParserUtil.typeKindForPrimitive(primitiveType)); + } + if (type instanceof VoidType) { + return types.getNoType(TypeKind.VOID); + } + if (type instanceof ClassOrInterfaceType classType) { + TypeElement typeElt = JavaParserUtil.resolveTypeName(elements, classType); + return typeElt == null ? null : typeElt.asType(); + } + // An intersection type, a union type, `var`, a wildcard, a type parameter declaration, etc. + return null; + } + + /** + * Returns the element type of the given type: the type itself if it is not an array type, or its + * innermost component type if it is. + * + * @param type a JavaParser type + * @return the element type of {@code type} + */ + private static Type innermostComponentType(Type type) { + Type componentType = type; + while (componentType instanceof ArrayType arrayType) { + componentType = arrayType.getComponentType(); + } + return componentType; + } + /** * Returns the relative directory that corresponds to a package name. For example, if {@code * separatorChar} is {@code '/'}, then the package name {@code "org.example"} yields {@code @@ -1081,8 +1326,11 @@ public void writeResultsToFile(OutputFormat outputFormat, BaseTypeChecker checke * * @param outputPath the path to which the ajava file should be written * @param root the compilation unit to be written + * @param omitIrrelevantAnnotations if true, do not write annotations that are irrelevant where + * they appear */ - private void writeAjavaFile(Path outputPath, CompilationUnitAnnos root) { + private void writeAjavaFile( + Path outputPath, CompilationUnitAnnos root, boolean omitIrrelevantAnnotations) { try (Writer writer = Files.newBufferedWriter(outputPath, StandardCharsets.UTF_8)) { // This commented implementation uses JavaParser's lexical preserving printing, which @@ -1096,7 +1344,7 @@ private void writeAjavaFile(Path outputPath, CompilationUnitAnnos root) { // suppressing them in the pretty-printer, prevents the pretty-printer from outputting the // whitespace that would have separated an annotation from what follows it. CompilationUnit compilationUnit = root.compilationUnit.clone(); - removeUnprintedAnnotations(compilationUnit); + removeUnprintedAnnotations(compilationUnit, omitIrrelevantAnnotations); DefaultPrettyPrinter prettyPrinter = new DefaultPrettyPrinter() { @@ -1140,14 +1388,24 @@ public void visit(final StringLiteralExpr n, final Void arg) { /** * Removes from the given compilation unit the annotations that an ajava file should not contain, - * because they would clutter it: the invisible qualifiers. + * because they would clutter it: + * + *

    + *
  • invisible qualifiers + *
  • irrelevant qualifiers, if {@code omitIrrelevantAnnotations} is true + *
* * @param compilationUnit the compilation unit to side-effect + * @param omitIrrelevantAnnotations if true, also remove annotations that are irrelevant where + * they appear */ - private void removeUnprintedAnnotations(CompilationUnit compilationUnit) { + private void removeUnprintedAnnotations( + CompilationUnit compilationUnit, boolean omitIrrelevantAnnotations) { Set invisibleQualifierNames = getInvisibleQualifierNames(this.atypeFactory); Predicate shouldRemove = - anno -> invisibleQualifierNames.contains(anno.getNameAsString()); + anno -> + invisibleQualifierNames.contains(anno.getNameAsString()) + || (omitIrrelevantAnnotations && !annotationIsRelevant(anno)); for (Node node : compilationUnit.findAll(Node.class)) { if (node instanceof NodeWithAnnotations annotated) { annotated.getAnnotations().removeIf(shouldRemove); @@ -1432,9 +1690,7 @@ public void transferAnnotations() { if (classAnnotations != null && classDeclaration != null) { for (AnnotationMirror annotation : classAnnotations) { - classDeclaration.addAnnotation( - AnnotationMirrorToAnnotationExprConversion.annotationMirrorToAnnotationExpr( - annotation)); + writeDeclarationAnnotation(classDeclaration, annotation); } } @@ -1814,26 +2070,20 @@ public AnnotatedTypeMirror getPreOrPostconditionsForExpression( public void transferAnnotations() { if (atypeFactory instanceof GenericAnnotatedTypeFactory genericAtf) { for (AnnotationMirror contractAnno : genericAtf.getContractAnnotations(this)) { - declaration.addAnnotation( - AnnotationMirrorToAnnotationExprConversion.annotationMirrorToAnnotationExpr( - contractAnno)); + writeDeclarationAnnotation(declaration, contractAnno); } } if (declarationAnnotations != null) { for (AnnotationMirror annotation : declarationAnnotations) { - declaration.addAnnotation( - AnnotationMirrorToAnnotationExprConversion.annotationMirrorToAnnotationExpr( - annotation)); + writeDeclarationAnnotation(declaration, annotation); } } if (paramsDeclAnnos != null) { for (IPair pair : paramsDeclAnnos) { Parameter param = declaration.getParameter(pair.first - 1); - param.addAnnotation( - AnnotationMirrorToAnnotationExprConversion.annotationMirrorToAnnotationExpr( - pair.second)); + writeDeclarationAnnotation(param, pair.second); } } @@ -2020,13 +2270,8 @@ public void transferAnnotations() { } if (declarationAnnotations != null) { - // Don't add directly to the type of the variable declarator, - // because declaration annotations need to be attached to the FieldDeclaration - // node instead. for (AnnotationMirror annotation : declarationAnnotations) { - decl.addAnnotation( - AnnotationMirrorToAnnotationExprConversion.annotationMirrorToAnnotationExpr( - annotation)); + writeDeclarationAnnotation(decl, annotation); } } From 55b04941a7e4b4d3ff6c6ad9e5248b49e39d1dc3 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Wed, 16 Sep 2026 11:35:06 -0700 Subject: [PATCH 12/58] Resolve a type name in a local class's and an anonymous class's scope `JavaParserUtil.resolveTypeName` did not model the scope of a local class or of an anonymous class, so a name that such a class declares or inherits resolved to a same-named type that is declared outside it. If that type is irrelevant, inference silently discarded a correct annotation. `TypeDeclaration.getFullyQualifiedName()` is part of the problem: for a local class `Foo` in `Outer`, it returns "Outer.Foo", which `Elements` resolves to a member type of `Outer`. Model these scopes: * A local class shadows every type of the same name, and `Elements` cannot look up a local class, so return null (that is, be conservative). * Likewise for a member type of a class that `Elements` cannot look up: a local class, an anonymous class (including the body of an enum constant), or a class nested within one. * Such a class also inherits its supertype's member types. Resolve the supertype and search it, which is precise rather than conservative. The supertype names are excluded from the class's own body scope, both because that is the Java rule and because it bounds the recursion. Add `nameableFullyQualifiedName`, which returns a fully-qualified name only when `Elements` can look it up, in place of `TypeDeclaration.getFullyQualifiedName()`. Resolving a supertype's name multiplies the number of name lookups, so pass a single cache from the ajava writer to every call, rather than letting each call allocate one that it discards. Add tests for a local class, an anonymous class, an enum constant's body, and a member type inherited into a local or anonymous class. Each test loses an annotation if its part of this fix is reverted. `InheritedTypeShadows` also shows that the supertype search is precise: an annotation on an inherited member type that is irrelevant is still omitted. Co-Authored-By: Claude Opus 5 --- .../AnonymousTypeShadows.ajava.goal | 80 +++++++ .../InheritedTypeShadows.ajava.goal | 90 ++++++++ .../LocalTypeShadows.ajava.goal | 43 ++++ .../non-annotated/AnonymousTypeShadows.java | 63 ++++++ .../non-annotated/InheritedTypeShadows.java | 73 +++++++ .../non-annotated/LocalTypeShadows.java | 34 +++ ...holeProgramInferenceJavaParserStorage.java | 13 +- .../framework/util/JavaParserUtil.java | 197 +++++++++++++++++- 8 files changed, 585 insertions(+), 8 deletions(-) create mode 100644 checker/tests/ainfer-relevance/AnonymousTypeShadows.ajava.goal create mode 100644 checker/tests/ainfer-relevance/InheritedTypeShadows.ajava.goal create mode 100644 checker/tests/ainfer-relevance/LocalTypeShadows.ajava.goal create mode 100644 checker/tests/ainfer-relevance/non-annotated/AnonymousTypeShadows.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/InheritedTypeShadows.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/LocalTypeShadows.java diff --git a/checker/tests/ainfer-relevance/AnonymousTypeShadows.ajava.goal b/checker/tests/ainfer-relevance/AnonymousTypeShadows.ajava.goal new file mode 100644 index 000000000000..45ff528f9baa --- /dev/null +++ b/checker/tests/ainfer-relevance/AnonymousTypeShadows.ajava.goal @@ -0,0 +1,80 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A member type of an anonymous class shadows a type of the same name that is declared elsewhere, +// such as a member of an enclosing class. The name "Foo" refers to the member of the anonymous +// class, which is relevant because it is a subtype of `CharSequence`, rather than to the member +// class `AnonymousTypeShadows.Foo`, which is irrelevant. An anonymous class has no name, so +// inference cannot determine the member type; it must conservatively retain the annotation. If +// inference instead resolves the name to `AnonymousTypeShadows.Foo` and therefore discards the +// annotation, then the second (validation) pass of this test issues the warnings that are written +// below. +// +// The body of an enum constant also declares an anonymous class, so it is tested the same way. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class AnonymousTypeShadows { + + static class Foo { + } + + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType Object anonymous = new Object() { + + abstract class Foo implements CharSequence { + } + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 Foo field; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "this.field" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + void assignField() { + field = getSibling1(); + } + + @org.checkerframework.dataflow.qual.Impure + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + void expectsSibling1(Foo f) { + } + + @org.checkerframework.dataflow.qual.Pure + Foo getSibling1() { + return null; + } + }; + + enum EnumWithConstantBody { + + CONSTANT { + + abstract class Foo implements CharSequence { + } + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 Foo field; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "this.field" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + void assignField() { + field = getSibling1(); + } + + @org.checkerframework.dataflow.qual.Impure + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + void expectsSibling1(Foo f) { + } + + @org.checkerframework.dataflow.qual.Pure + Foo getSibling1() { + return null; + } + } + + } +} diff --git a/checker/tests/ainfer-relevance/InheritedTypeShadows.ajava.goal b/checker/tests/ainfer-relevance/InheritedTypeShadows.ajava.goal new file mode 100644 index 000000000000..f871fb61f5ce --- /dev/null +++ b/checker/tests/ainfer-relevance/InheritedTypeShadows.ajava.goal @@ -0,0 +1,90 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A local class and an anonymous class inherit their supertype's member types, which shadow types +// of the same name that are declared elsewhere. Inference must resolve the name "Foo" to +// `Base.Foo`, which is relevant because it is a subtype of `CharSequence`, rather than to the +// member class `InheritedTypeShadows.Foo`, which is irrelevant. Neither a local class nor an +// anonymous class has a name that inference can look up, so inference must resolve the name by +// searching the supertype. If inference resolves the name incorrectly and therefore discards the +// annotation, then the second (validation) pass of this test issues the warnings that are written +// below. +// +// The search of the supertype is precise rather than merely conservative: the goal file shows +// that inference discards an annotation on `Irrelevant`, which is the inherited member type +// `Base.Irrelevant` and is irrelevant. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class InheritedTypeShadows { + + static class Foo { + } + + static class Base { + + abstract static class Foo implements CharSequence { + } + + static class Irrelevant { + } + } + + @org.checkerframework.dataflow.qual.SideEffectFree + static void declareLocalClass() { + class Local extends Base { + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 Foo field; + + Irrelevant irrelevantField; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "this.field" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + void assignField() { + field = getSibling1(); + irrelevantField = new Irrelevant(); + } + + @org.checkerframework.dataflow.qual.Impure + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + void expectsSibling1(@org.checkerframework.checker.testchecker.ainfer.qual.AinferTop Local this, Foo f) { + } + + @org.checkerframework.dataflow.qual.Pure + Foo getSibling1(@org.checkerframework.checker.testchecker.ainfer.qual.AinferTop Local this) { + return null; + } + } + } + + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferTop Object anonymous = new Base() { + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 Foo field; + + Irrelevant irrelevantField; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "this.field" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + void assignField() { + field = getSibling1(); + irrelevantField = new Irrelevant(); + } + + @org.checkerframework.dataflow.qual.Impure + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + void expectsSibling1(Foo f) { + } + + @org.checkerframework.dataflow.qual.Pure + Foo getSibling1() { + return null; + } + }; +} diff --git a/checker/tests/ainfer-relevance/LocalTypeShadows.ajava.goal b/checker/tests/ainfer-relevance/LocalTypeShadows.ajava.goal new file mode 100644 index 000000000000..cbd9885fcbf2 --- /dev/null +++ b/checker/tests/ainfer-relevance/LocalTypeShadows.ajava.goal @@ -0,0 +1,43 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A local class declaration shadows a type of the same name that is declared elsewhere, such as a +// member of an enclosing class. Inference must resolve the name "Foo" to the local class, which +// is relevant because it is a subtype of `CharSequence`, rather than to the member class +// `LocalTypeShadows.Foo`, which is irrelevant. If inference resolves the name incorrectly and +// therefore discards the annotation, then the second (validation) pass of this test issues the +// warning that is written below. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class LocalTypeShadows { + + static class Foo { + } + + @org.checkerframework.dataflow.qual.SideEffectFree + static void declareLocalClass() { + abstract class Foo implements CharSequence { + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 Foo field; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "this.field" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + void assignField() { + field = getSibling1(); + } + + @org.checkerframework.dataflow.qual.Impure + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + void expectsSibling1(@org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType Foo this, Foo f) { + } + + @org.checkerframework.dataflow.qual.Pure + Foo getSibling1(@org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType Foo this) { + return null; + } + } + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/AnonymousTypeShadows.java b/checker/tests/ainfer-relevance/non-annotated/AnonymousTypeShadows.java new file mode 100644 index 000000000000..5eb6cc63f057 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/AnonymousTypeShadows.java @@ -0,0 +1,63 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A member type of an anonymous class shadows a type of the same name that is declared elsewhere, +// such as a member of an enclosing class. The name "Foo" refers to the member of the anonymous +// class, which is relevant because it is a subtype of `CharSequence`, rather than to the member +// class `AnonymousTypeShadows.Foo`, which is irrelevant. An anonymous class has no name, so +// inference cannot determine the member type; it must conservatively retain the annotation. If +// inference instead resolves the name to `AnonymousTypeShadows.Foo` and therefore discards the +// annotation, then the second (validation) pass of this test issues the warnings that are written +// below. +// +// The body of an enum constant also declares an anonymous class, so it is tested the same way. +public class AnonymousTypeShadows { + + static class Foo {} + + static Object anonymous = + new Object() { + + abstract class Foo implements CharSequence {} + + Foo field; + + void assignField() { + field = getSibling1(); + } + + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + void expectsSibling1(@AinferSibling1 Foo f) {} + + @AinferSibling1 Foo getSibling1() { + return null; + } + }; + + enum EnumWithConstantBody { + CONSTANT { + + abstract class Foo implements CharSequence {} + + Foo field; + + void assignField() { + field = getSibling1(); + } + + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + void expectsSibling1(@AinferSibling1 Foo f) {} + + @AinferSibling1 Foo getSibling1() { + return null; + } + } + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/InheritedTypeShadows.java b/checker/tests/ainfer-relevance/non-annotated/InheritedTypeShadows.java new file mode 100644 index 000000000000..01f2531f6a33 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/InheritedTypeShadows.java @@ -0,0 +1,73 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A local class and an anonymous class inherit their supertype's member types, which shadow types +// of the same name that are declared elsewhere. Inference must resolve the name "Foo" to +// `Base.Foo`, which is relevant because it is a subtype of `CharSequence`, rather than to the +// member class `InheritedTypeShadows.Foo`, which is irrelevant. Neither a local class nor an +// anonymous class has a name that inference can look up, so inference must resolve the name by +// searching the supertype. If inference resolves the name incorrectly and therefore discards the +// annotation, then the second (validation) pass of this test issues the warnings that are written +// below. +// +// The search of the supertype is precise rather than merely conservative: the goal file shows +// that inference discards an annotation on `Irrelevant`, which is the inherited member type +// `Base.Irrelevant` and is irrelevant. +public class InheritedTypeShadows { + + static class Foo {} + + static class Base { + abstract static class Foo implements CharSequence {} + + static class Irrelevant {} + } + + static void declareLocalClass() { + class Local extends Base { + + Foo field; + + Irrelevant irrelevantField; + + void assignField() { + field = getSibling1(); + irrelevantField = new Irrelevant(); + } + + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + void expectsSibling1(@AinferSibling1 Foo f) {} + + @AinferSibling1 Foo getSibling1() { + return null; + } + } + } + + static Object anonymous = + new Base() { + + Foo field; + + Irrelevant irrelevantField; + + void assignField() { + field = getSibling1(); + irrelevantField = new Irrelevant(); + } + + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + void expectsSibling1(@AinferSibling1 Foo f) {} + + @AinferSibling1 Foo getSibling1() { + return null; + } + }; +} diff --git a/checker/tests/ainfer-relevance/non-annotated/LocalTypeShadows.java b/checker/tests/ainfer-relevance/non-annotated/LocalTypeShadows.java new file mode 100644 index 000000000000..e59a76ab8f5f --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/LocalTypeShadows.java @@ -0,0 +1,34 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A local class declaration shadows a type of the same name that is declared elsewhere, such as a +// member of an enclosing class. Inference must resolve the name "Foo" to the local class, which +// is relevant because it is a subtype of `CharSequence`, rather than to the member class +// `LocalTypeShadows.Foo`, which is irrelevant. If inference resolves the name incorrectly and +// therefore discards the annotation, then the second (validation) pass of this test issues the +// warning that is written below. +public class LocalTypeShadows { + + static class Foo {} + + static void declareLocalClass() { + abstract class Foo implements CharSequence { + + Foo field; + + void assignField() { + field = getSibling1(); + } + + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + void expectsSibling1(@AinferSibling1 Foo f) {} + + @AinferSibling1 Foo getSibling1() { + return null; + } + } + } +} diff --git a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java index 999b5f46c837..1eb90d54da54 100644 --- a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java +++ b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java @@ -135,6 +135,15 @@ public class WholeProgramInferenceJavaParserStorage /** The element utilities for {@code atypeFactory}. */ protected final Elements elements; + /** + * Memoizes the name lookups that {@link JavaParserUtil#resolveTypeName} performs while writing + * ajava files. Resolving one name looks up many candidate names, most of which name no type, and + * the same names are looked up for every annotation in every ajava file. {@link + * #writeResultsToFile} clears this before writing, because a name that names no type in one + * annotation processing round might name a generated type in a later round. + */ + private final Map typeElementCache = new HashMap<>(); + /** * Maps from binary class name to the wrapper containing the class. Contains all classes in Java * source files containing an Element for which an annotation has been inferred. @@ -1035,6 +1044,8 @@ public void writeResultsToFile(OutputFormat outputFormat, BaseTypeChecker checke setSupertypesAndSubtypesModified(); + typeElementCache.clear(); + for (String path : modifiedFiles) { // This calls deepCopy() because wpiPrepareCompilationUnitForWriting performs side // effects on the inference results that we don't want to be persistent. The JavaParser @@ -1286,7 +1297,7 @@ private boolean arrayCreationLevelIsRelevant( return types.getNoType(TypeKind.VOID); } if (type instanceof ClassOrInterfaceType classType) { - TypeElement typeElt = JavaParserUtil.resolveTypeName(elements, classType); + TypeElement typeElt = JavaParserUtil.resolveTypeName(elements, classType, typeElementCache); return typeElt == null ? null : typeElt.asType(); } // An intersection type, a union type, `var`, a wildcard, a type parameter declaration, etc. diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index 20d64ba9faf6..27d14fdcf457 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -7,20 +7,24 @@ import com.github.javaparser.ast.Node; import com.github.javaparser.ast.body.AnnotationDeclaration; import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.body.EnumConstantDeclaration; import com.github.javaparser.ast.body.EnumDeclaration; import com.github.javaparser.ast.body.RecordDeclaration; import com.github.javaparser.ast.body.TypeDeclaration; import com.github.javaparser.ast.expr.AnnotationExpr; import com.github.javaparser.ast.expr.ArrayInitializerExpr; import com.github.javaparser.ast.expr.BinaryExpr; +import com.github.javaparser.ast.expr.ObjectCreationExpr; import com.github.javaparser.ast.expr.StringLiteralExpr; import com.github.javaparser.ast.nodeTypes.NodeWithTypeParameters; +import com.github.javaparser.ast.stmt.Statement; import com.github.javaparser.ast.type.ClassOrInterfaceType; import com.github.javaparser.ast.type.PrimitiveType; import com.github.javaparser.ast.type.TypeParameter; import com.github.javaparser.ast.visitor.VoidVisitorAdapter; import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Collections; import java.util.Deque; import java.util.HashMap; import java.util.HashSet; @@ -53,7 +57,8 @@ private JavaParserUtil() { /** * Returns the element for the given JavaParser type, whose name is resolved in the scope of the * type declarations and the compilation unit that contain it. Returns null if the name cannot be - * resolved, which happens for a type variable and for a type that is not on the classpath. + * resolved: it names a type variable, a local class, a member of a local or anonymous class, or a + * type that is not on the classpath. * *

A client that resolves many names should call {@link #resolveTypeName(Elements, * ClassOrInterfaceType, Map)}, which memoizes the name lookups. @@ -100,12 +105,17 @@ private JavaParserUtil() { suffix = name.substring(dotIndex); } - // A type parameter, or a type that is lexically enclosed in a type declaration, takes - // precedence over an import, over a type in the same package, over a type in `java.lang`, and - // over the interpretation of `name` as a fully-qualified name. + // A type parameter, a local class, or a type that is lexically enclosed in a type declaration, + // takes precedence over an import, over a type in the same package, over a type in + // `java.lang`, and over the interpretation of `name` as a fully-qualified name. + // + // `child` is the child of `ancestor` that contains `type`. It distinguishes a use of `name` + // within a class body, where the class's member types are in scope, from a use in the class's + // own supertype names, where they are not. + Node child = type; for (Node ancestor = type.getParentNode().orElse(null); ancestor != null; - ancestor = ancestor.getParentNode().orElse(null)) { + child = ancestor, ancestor = ancestor.getParentNode().orElse(null)) { if (ancestor instanceof NodeWithTypeParameters genericDeclaration) { for (TypeParameter typeParameter : genericDeclaration.getTypeParameters()) { if (typeParameter.getNameAsString().equals(firstComponent)) { @@ -115,8 +125,42 @@ private JavaParserUtil() { } } } - if (ancestor instanceof TypeDeclaration enclosingType) { - String enclosingName = enclosingType.getFullyQualifiedName().orElse(null); + + if (declaresLocalType(ancestor, firstComponent)) { + // `name` names a local class, or is nested within one. A local class shadows any type of + // the same name, and `Elements` cannot look up a local class by name. + return null; + } + + if (ancestor instanceof EnumConstantDeclaration enumConstant + && declaresMemberType(enumConstant.getClassBody(), firstComponent)) { + // The body of an enum constant declares an anonymous class. A member type of an + // anonymous class has no name that `Elements` can look up. (There is no need to search + // the anonymous class's supertype, which is the enum: the enum declaration is an + // ancestor, so a later iteration of this loop searches it.) + return null; + } + + // The member types of a class that `Elements` cannot look up by name -- an anonymous class, + // a local class, or a class that is nested within one -- shadow types that are declared + // outside the class. `unnameableSupertypes` is non-null if `type` appears within the body + // of such a class, in which case it holds the class's supertypes, which this method searches + // for a member type that `name` might refer to. + List unnameableSupertypes = null; + if (ancestor instanceof ObjectCreationExpr creation) { + List body = creation.getAnonymousClassBody().orElse(null); + List supertypes = Collections.singletonList(creation.getType()); + // If `child` is the supertype name, then `type` is not in the anonymous class's body, and + // testing `child` also prevents infinite recursion on the recursive call below. + if (body != null && !containsSame(supertypes, child)) { + if (declaresMemberType(body, firstComponent)) { + // A member type of an anonymous class has no name that `Elements` can look up. + return null; + } + unnameableSupertypes = supertypes; + } + } else if (ancestor instanceof TypeDeclaration enclosingType) { + String enclosingName = nameableFullyQualifiedName(enclosingType); if (enclosingName != null) { TypeElement result = getTypeElement(elements, enclosingName + "." + name, cache); if (result != null) { @@ -130,6 +174,32 @@ private JavaParserUtil() { return result; } } + } else { + List supertypes = supertypes(enclosingType); + // If `child` is one of the supertype names, then `type` is not in the class's body, and + // testing `child` also prevents infinite recursion on the recursive call below. + if (!containsSame(supertypes, child)) { + if (declaresMemberType(enclosingType.getMembers(), firstComponent)) { + // A member type of an unnameable class has no name that `Elements` can look up. + return null; + } + unnameableSupertypes = supertypes; + } + } + } + if (unnameableSupertypes != null) { + for (ClassOrInterfaceType supertype : unnameableSupertypes) { + TypeElement supertypeElement = resolveTypeName(elements, supertype, cache); + if (supertypeElement == null) { + // The supertype could not be determined, so neither could the member types that the + // unnameable class inherits and that might shadow `name`. + return null; + } + TypeElement result = + resolveMemberType(elements, supertypeElement, firstComponent, suffix, cache); + if (result != null) { + return result; + } } } } @@ -231,6 +301,119 @@ private JavaParserUtil() { return result; } + /** + * Returns true if {@code node} directly contains a statement that declares a local class, + * interface, enum, or record whose name is {@code name}. Such a declaration shadows, throughout + * the block that contains it, every type of the same name that is declared elsewhere. + * + * @param node a JavaParser node, such as a block + * @param name a simple type name + * @return true if {@code node} declares a local type named {@code name} + */ + private static boolean declaresLocalType(Node node, String name) { + for (Node child : node.getChildNodes()) { + if (child instanceof Statement) { + // A local type declaration is the only kind of statement whose child is a type + // declaration. + for (Node grandchild : child.getChildNodes()) { + if (grandchild instanceof TypeDeclaration localType + && localType.getNameAsString().equals(name)) { + return true; + } + } + } + } + return false; + } + + /** + * Returns true if one of the given body declarations is a type declaration whose name is {@code + * name}. + * + * @param members the body declarations of a class or interface + * @param name a simple type name + * @return true if {@code members} contains a type declaration named {@code name} + */ + private static boolean declaresMemberType(List members, String name) { + for (Node member : members) { + if (member instanceof TypeDeclaration memberType + && memberType.getNameAsString().equals(name)) { + return true; + } + } + return false; + } + + /** + * Returns the fully-qualified name by which {@link Elements} can look up the given type + * declaration, or null if there is none: {@code typeDecl} is a local class, or it is a member of + * a local class or of an anonymous class (including the body of an enum constant). ({@link + * TypeDeclaration#getFullyQualifiedName} does not make this distinction; it returns a name that + * {@link Elements} might resolve to a different type declaration.) + * + * @param typeDecl a JavaParser type declaration + * @return the fully-qualified name of {@code typeDecl}, or null if it has none + */ + private static @Nullable String nameableFullyQualifiedName(TypeDeclaration typeDecl) { + for (Node node = typeDecl; node != null; node = node.getParentNode().orElse(null)) { + if (node instanceof TypeDeclaration) { + Node parent = node.getParentNode().orElse(null); + // The parent of a local type declaration is the statement that declares it. The parent + // of a member of an anonymous class is the object creation expression, or the enum + // constant declaration, that declares the anonymous class. + if (parent instanceof Statement + || parent instanceof ObjectCreationExpr + || parent instanceof EnumConstantDeclaration) { + return null; + } + } + } + return typeDecl.getFullyQualifiedName().orElse(null); + } + + /** + * Returns the supertypes that the given type declaration names: its {@code extends} clause and + * its {@code implements} clause. The result does not include an implicit supertype such as {@code + * java.lang.Object}, none of which declares a member type. + * + * @param typeDecl a JavaParser type declaration + * @return the supertypes that {@code typeDecl} names + */ + private static List supertypes(TypeDeclaration typeDecl) { + if (typeDecl instanceof ClassOrInterfaceDeclaration classDecl) { + List result = new ArrayList<>(classDecl.getExtendedTypes()); + result.addAll(classDecl.getImplementedTypes()); + return result; + } + if (typeDecl instanceof EnumDeclaration enumDecl) { + return enumDecl.getImplementedTypes(); + } + if (typeDecl instanceof RecordDeclaration recordDecl) { + return recordDecl.getImplementedTypes(); + } + // An annotation declaration's only supertype is `java.lang.annotation.Annotation`. + return Collections.emptyList(); + } + + /** + * Returns true if one of the given nodes is the given node, compared by reference equality. + * JavaParser's {@code equals()} is structural, so {@link List#contains} does not distinguish two + * occurrences of the same type name. + * + * @param nodes some JavaParser nodes + * @param node a JavaParser node + * @return true if {@code nodes} contains {@code node} itself + */ + @SuppressWarnings("interning:not.interned") // reference equality of AST nodes + private static boolean containsSame(List nodes, Node node) { + for (Node n : nodes) { + if (n == node) { + return true; + } + } + return false; + } + /** * Returns the element for the member type named {@code firstComponent + suffix} that {@code * typeElement} declares or inherits, or null if there is no such member type. From 063d3f109d03998f3ede2b33e08f15f53c905c3b Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Wed, 16 Sep 2026 11:35:42 -0700 Subject: [PATCH 13/58] Resolve a type name only where the enclosing class's member types are in scope A class's member types, declared and inherited, are in scope only in its body -- not in its annotations, its type parameter section, or its supertype names, and not in the arguments of the object creation expression that declares an anonymous class. Test the child of the enclosing declaration that contains the name, rather than testing only whether the name is the supertype name. Also include the implicit superclass `java.lang.Enum` in an enum's supertypes, because it declares the member type `Enum.EnumDesc`. Add tests for a name in an anonymous class's arguments and for a member type that an unnameable enum inherits. Co-Authored-By: Claude Opus 5 --- .../AnonymousArgumentScope.ajava.goal | 57 +++++++++++ .../UnnameableEnumInheritsType.ajava.goal | 32 +++++++ .../non-annotated/AnonymousArgumentScope.java | 46 +++++++++ .../non-annotated/EnumDesc.java | 7 ++ .../UnnameableEnumInheritsType.java | 27 ++++++ .../framework/util/JavaParserUtil.java | 94 ++++++++++++++----- 6 files changed, 239 insertions(+), 24 deletions(-) create mode 100644 checker/tests/ainfer-relevance/AnonymousArgumentScope.ajava.goal create mode 100644 checker/tests/ainfer-relevance/UnnameableEnumInheritsType.ajava.goal create mode 100644 checker/tests/ainfer-relevance/non-annotated/AnonymousArgumentScope.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/EnumDesc.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/UnnameableEnumInheritsType.java diff --git a/checker/tests/ainfer-relevance/AnonymousArgumentScope.ajava.goal b/checker/tests/ainfer-relevance/AnonymousArgumentScope.ajava.goal new file mode 100644 index 000000000000..89cfebd885fc --- /dev/null +++ b/checker/tests/ainfer-relevance/AnonymousArgumentScope.ajava.goal @@ -0,0 +1,57 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// The member types of an anonymous class, both the ones it declares and the ones it inherits, are +// in scope only in its body. They are not in scope in the arguments of the object creation +// expression that declares the anonymous class, even though those arguments are lexically nested +// within it. Inference must resolve the name "Foo", which appears in such an argument, to the +// member class `AnonymousArgumentScope.Foo`, which is relevant because it is a subtype of +// `CharSequence`, rather than to `Base.Foo`, which the anonymous class inherits and which is +// irrelevant. If inference resolves the name incorrectly and therefore discards the annotation, +// then the second (validation) pass of this test issues the warning that is written below. +// +// The same is true of the annotations and the type parameter section of a local class or of a +// member of an unnameable class, but inference writes no annotation in either location, so no +// goal file can show that inference resolves those names correctly. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class AnonymousArgumentScope { + + abstract static class Foo implements CharSequence { + } + + static class Base { + + static class Foo { + } + + @org.checkerframework.dataflow.qual.SideEffectFree + Base(@org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType Object argument) { + } + } + + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferTop Object anonymous = new Base(new Object() { + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 Foo field; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "this.field" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + void assignField() { + field = getSibling1(); + } + + @org.checkerframework.dataflow.qual.Impure + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + void expectsSibling1(Foo f) { + } + + @org.checkerframework.dataflow.qual.Pure + Foo getSibling1() { + return null; + } + }) { + }; +} diff --git a/checker/tests/ainfer-relevance/UnnameableEnumInheritsType.ajava.goal b/checker/tests/ainfer-relevance/UnnameableEnumInheritsType.ajava.goal new file mode 100644 index 000000000000..ffee91e2b918 --- /dev/null +++ b/checker/tests/ainfer-relevance/UnnameableEnumInheritsType.ajava.goal @@ -0,0 +1,32 @@ +// Every enum inherits the member type `EnumDesc` from its implicit superclass `java.lang.Enum`, +// and that inherited member type shadows the type of the same name that is declared in the same +// package. An enum that is declared in a local class has no name that inference can look up, so +// inference determines its member types from its supertypes, which must therefore include the +// implicit `java.lang.Enum`. Inference must resolve the name "EnumDesc" to +// `java.lang.Enum.EnumDesc`, which is irrelevant, rather than to the top-level class `EnumDesc`, +// which is relevant. The goal file shows that inference writes no annotation for +// `enumDescField`, but does write one for `charSequenceField`. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class UnnameableEnumInheritsType { + + @org.checkerframework.dataflow.qual.Impure + static void declareLocalClass() { + class Local { + + enum Nested { + + CONSTANT; + + EnumDesc enumDescField; + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType CharSequence charSequenceField; + + @org.checkerframework.dataflow.qual.Impure + void assignFields(EnumDesc e, CharSequence c) { + enumDescField = e; + charSequenceField = c; + } + } + } + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/AnonymousArgumentScope.java b/checker/tests/ainfer-relevance/non-annotated/AnonymousArgumentScope.java new file mode 100644 index 000000000000..f058d0003000 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/AnonymousArgumentScope.java @@ -0,0 +1,46 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// The member types of an anonymous class, both the ones it declares and the ones it inherits, are +// in scope only in its body. They are not in scope in the arguments of the object creation +// expression that declares the anonymous class, even though those arguments are lexically nested +// within it. Inference must resolve the name "Foo", which appears in such an argument, to the +// member class `AnonymousArgumentScope.Foo`, which is relevant because it is a subtype of +// `CharSequence`, rather than to `Base.Foo`, which the anonymous class inherits and which is +// irrelevant. If inference resolves the name incorrectly and therefore discards the annotation, +// then the second (validation) pass of this test issues the warning that is written below. +// +// The same is true of the annotations and the type parameter section of a local class or of a +// member of an unnameable class, but inference writes no annotation in either location, so no +// goal file can show that inference resolves those names correctly. +public class AnonymousArgumentScope { + + abstract static class Foo implements CharSequence {} + + static class Base { + static class Foo {} + + Base(Object argument) {} + } + + static Object anonymous = + new Base( + new Object() { + + Foo field; + + void assignField() { + field = getSibling1(); + } + + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + void expectsSibling1(@AinferSibling1 Foo f) {} + + @AinferSibling1 Foo getSibling1() { + return null; + } + }) {}; +} diff --git a/checker/tests/ainfer-relevance/non-annotated/EnumDesc.java b/checker/tests/ainfer-relevance/non-annotated/EnumDesc.java new file mode 100644 index 000000000000..54580f8cba68 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/EnumDesc.java @@ -0,0 +1,7 @@ +// This class exists so that the unnamed package declares a type whose name is also the name of the +// member type `EnumDesc` that every enum inherits from `java.lang.Enum`. It is used by +// `UnnameableEnumInheritsType.java`. +// +// This class is relevant, because it is a subtype of `CharSequence`, which the checker's +// `@RelevantJavaTypes` lists. By contrast, `java.lang.Enum.EnumDesc` is irrelevant. +public abstract class EnumDesc implements CharSequence {} diff --git a/checker/tests/ainfer-relevance/non-annotated/UnnameableEnumInheritsType.java b/checker/tests/ainfer-relevance/non-annotated/UnnameableEnumInheritsType.java new file mode 100644 index 000000000000..de4565abdc31 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/UnnameableEnumInheritsType.java @@ -0,0 +1,27 @@ +// Every enum inherits the member type `EnumDesc` from its implicit superclass `java.lang.Enum`, +// and that inherited member type shadows the type of the same name that is declared in the same +// package. An enum that is declared in a local class has no name that inference can look up, so +// inference determines its member types from its supertypes, which must therefore include the +// implicit `java.lang.Enum`. Inference must resolve the name "EnumDesc" to +// `java.lang.Enum.EnumDesc`, which is irrelevant, rather than to the top-level class `EnumDesc`, +// which is relevant. The goal file shows that inference writes no annotation for +// `enumDescField`, but does write one for `charSequenceField`. +public class UnnameableEnumInheritsType { + + static void declareLocalClass() { + class Local { + enum Nested { + CONSTANT; + + EnumDesc enumDescField; + + CharSequence charSequenceField; + + void assignFields(EnumDesc e, CharSequence c) { + enumDescField = e; + charSequenceField = c; + } + } + } + } +} diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index 27d14fdcf457..fd59b80a06c0 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -75,7 +75,8 @@ private JavaParserUtil() { /** * Returns the element for the given JavaParser type, whose name is resolved in the scope of the * type declarations and the compilation unit that contain it. Returns null if the name cannot be - * resolved, which happens for a type variable and for a type that is not on the classpath. + * resolved: it names a type variable, a local class, a member of a local or anonymous class, or a + * type that is not on the classpath. * *

Resolving one name looks up many candidate names, most of which name no type, so a client * that resolves many names should pass the same cache to each call. A cache should not be reused @@ -110,8 +111,9 @@ private JavaParserUtil() { // `java.lang`, and over the interpretation of `name` as a fully-qualified name. // // `child` is the child of `ancestor` that contains `type`. It distinguishes a use of `name` - // within a class body, where the class's member types are in scope, from a use in the class's - // own supertype names, where they are not. + // within a class body, where the class's member types are in scope, from a use elsewhere in + // the class declaration -- in its annotations, its type parameter section, or its supertype + // names -- where they are not. Node child = type; for (Node ancestor = type.getParentNode().orElse(null); ancestor != null; @@ -133,8 +135,10 @@ private JavaParserUtil() { } if (ancestor instanceof EnumConstantDeclaration enumConstant + && containsSame(enumConstant.getClassBody(), child) && declaresMemberType(enumConstant.getClassBody(), firstComponent)) { - // The body of an enum constant declares an anonymous class. A member type of an + // The body of an enum constant declares an anonymous class, whose member types are in + // scope only in that body and not in the constant's arguments. A member type of an // anonymous class has no name that `Elements` can look up. (There is no need to search // the anonymous class's supertype, which is the enum: the enum declaration is an // ancestor, so a later iteration of this loop searches it.) @@ -149,17 +153,21 @@ && declaresMemberType(enumConstant.getClassBody(), firstComponent)) { List unnameableSupertypes = null; if (ancestor instanceof ObjectCreationExpr creation) { List body = creation.getAnonymousClassBody().orElse(null); - List supertypes = Collections.singletonList(creation.getType()); - // If `child` is the supertype name, then `type` is not in the anonymous class's body, and - // testing `child` also prevents infinite recursion on the recursive call below. - if (body != null && !containsSame(supertypes, child)) { + // The anonymous class's member types are in scope only in its body, and not in the + // creation expression's scope, type arguments, supertype name, or arguments. Testing + // `child` also prevents infinite recursion on the recursive call below. + if (body != null && containsSame(body, child)) { if (declaresMemberType(body, firstComponent)) { // A member type of an anonymous class has no name that `Elements` can look up. return null; } - unnameableSupertypes = supertypes; + unnameableSupertypes = Collections.singletonList(creation.getType()); } - } else if (ancestor instanceof TypeDeclaration enclosingType) { + } else if (ancestor instanceof TypeDeclaration enclosingType + // The class's member types, declared and inherited, are in scope only in its body, and + // not in its annotations, its type parameter section, or its supertype names. Testing + // `child` also prevents infinite recursion on the recursive call below. + && containsSame(scopeOfMemberTypes(enclosingType), child)) { String enclosingName = nameableFullyQualifiedName(enclosingType); if (enclosingName != null) { TypeElement result = getTypeElement(elements, enclosingName + "." + name, cache); @@ -175,16 +183,11 @@ && declaresMemberType(enumConstant.getClassBody(), firstComponent)) { } } } else { - List supertypes = supertypes(enclosingType); - // If `child` is one of the supertype names, then `type` is not in the class's body, and - // testing `child` also prevents infinite recursion on the recursive call below. - if (!containsSame(supertypes, child)) { - if (declaresMemberType(enclosingType.getMembers(), firstComponent)) { - // A member type of an unnameable class has no name that `Elements` can look up. - return null; - } - unnameableSupertypes = supertypes; + if (declaresMemberType(enclosingType.getMembers(), firstComponent)) { + // A member type of an unnameable class has no name that `Elements` can look up. + return null; } + unnameableSupertypes = supertypes(enclosingType); } } if (unnameableSupertypes != null) { @@ -372,12 +375,15 @@ private static boolean declaresMemberType(List members, String n } /** - * Returns the supertypes that the given type declaration names: its {@code extends} clause and - * its {@code implements} clause. The result does not include an implicit supertype such as {@code - * java.lang.Object}, none of which declares a member type. + * Returns the supertypes of the given type declaration from which it can inherit a member type: + * those that its {@code extends} clause and its {@code implements} clause name, plus the implicit + * superclass {@code java.lang.Enum} of an enum, which declares the member type {@code + * Enum.EnumDesc}. The result does not include the other implicit supertypes -- {@code + * java.lang.Object}, {@code java.lang.Record}, and {@code java.lang.annotation.Annotation} -- + * none of which declares a member type. * * @param typeDecl a JavaParser type declaration - * @return the supertypes that {@code typeDecl} names + * @return the supertypes of {@code typeDecl} that might declare a member type */ private static List supertypes(TypeDeclaration typeDecl) { if (typeDecl instanceof ClassOrInterfaceDeclaration classDecl) { @@ -386,7 +392,10 @@ private static List supertypes(TypeDeclaration typeDecl return result; } if (typeDecl instanceof EnumDeclaration enumDecl) { - return enumDecl.getImplementedTypes(); + // Copy the list rather than side-effecting the AST by adding to it. + List result = new ArrayList<>(enumDecl.getImplementedTypes()); + result.add(javaLangEnum()); + return result; } if (typeDecl instanceof RecordDeclaration recordDecl) { return recordDecl.getImplementedTypes(); @@ -395,6 +404,43 @@ private static List supertypes(TypeDeclaration typeDecl return Collections.emptyList(); } + /** + * Returns a new JavaParser type that names {@code java.lang.Enum}, the implicit superclass of + * every enum. The result is not part of any AST, so {@link #resolveTypeName} resolves its name as + * a fully-qualified name. + * + * @return a JavaParser type that names {@code java.lang.Enum} + */ + private static ClassOrInterfaceType javaLangEnum() { + return new ClassOrInterfaceType( + new ClassOrInterfaceType(new ClassOrInterfaceType(null, "java"), "lang"), "Enum"); + } + + /** + * Returns the children of the given type declaration in which its member types are in scope: its + * body declarations, an enum's constants, and a record's components. The result does not include + * a child in which they are not in scope: an annotation on the declaration, a type parameter, or + * a supertype name. + * + * @param typeDecl a JavaParser type declaration + * @return the children of {@code typeDecl} in which its member types are in scope + */ + private static List scopeOfMemberTypes(TypeDeclaration typeDecl) { + if (typeDecl instanceof EnumDeclaration enumDecl) { + // A member type is in scope in a constant's arguments and in its class body. + List result = new ArrayList<>(enumDecl.getMembers()); + result.addAll(enumDecl.getEntries()); + return result; + } + if (typeDecl instanceof RecordDeclaration recordDecl) { + // A member type is in scope in the type of a record component. + List result = new ArrayList<>(recordDecl.getMembers()); + result.addAll(recordDecl.getParameters()); + return result; + } + return typeDecl.getMembers(); + } + /** * Returns true if one of the given nodes is the given node, compared by reference equality. * JavaParser's {@code equals()} is structural, so {@link List#contains} does not distinguish two From c505fe6ed7b11baddcb83f3dbf06933fc2ad9631 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Wed, 16 Sep 2026 10:54:31 -0700 Subject: [PATCH 14/58] Don't treat an uninherited member type as inherited `resolveMemberType` returned any non-private member type that it found in a supertype. A package-private member type is inherited only within its own package, so a package-private member type of a supertype in a different package was wrongly returned. Also, a declaration hides what its declaring type would otherwise inherit even when the declaration itself is not inherited, so the search must not continue past it into that type's supertypes. Either error made `annotationIsRelevant` ask about the wrong type, which could discard a relevant annotation. Co-Authored-By: Claude Opus 5 --- .../AinferRelevanceAjavaGenerationTest.java | 31 +++--- .../PackagePrivateNotInherited.ajava.goal | 39 ++++++++ .../ainfer-relevance/non-annotated/List.java | 3 +- .../PackagePrivateNotInherited.java | 32 +++++++ .../otherpackage/OtherPackageGrandparent.java | 15 +++ .../otherpackage/OtherPackageSuperclass.java | 16 ++++ ...rpackage.OtherPackageSuperclass.ajava.goal | 18 ++++ .../framework/util/JavaParserUtil.java | 94 ++++++++++++++++--- 8 files changed, 218 insertions(+), 30 deletions(-) create mode 100644 checker/tests/ainfer-relevance/PackagePrivateNotInherited.ajava.goal create mode 100644 checker/tests/ainfer-relevance/non-annotated/PackagePrivateNotInherited.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/otherpackage/OtherPackageGrandparent.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/otherpackage/OtherPackageSuperclass.java create mode 100644 checker/tests/ainfer-relevance/otherpackage.OtherPackageSuperclass.ajava.goal diff --git a/checker/src/test/java/org/checkerframework/checker/test/junit/ainferrunners/AinferRelevanceAjavaGenerationTest.java b/checker/src/test/java/org/checkerframework/checker/test/junit/ainferrunners/AinferRelevanceAjavaGenerationTest.java index 4b32dd1554ee..5e2148443899 100644 --- a/checker/src/test/java/org/checkerframework/checker/test/junit/ainferrunners/AinferRelevanceAjavaGenerationTest.java +++ b/checker/src/test/java/org/checkerframework/checker/test/junit/ainferrunners/AinferRelevanceAjavaGenerationTest.java @@ -66,8 +66,8 @@ public static String[] getTestDirs() { /** * Compares each generated ajava file to its goal file. A goal file is named {@code - * .ajava.goal}. Every generated ajava file must have a goal file and vice versa, so - * that no inference result goes unexamined. + * .ajava.goal}. Every generated ajava file must have a goal file and + * vice versa, so that no inference result goes unexamined. * *

Unlike the second (validation) pass of this test, this comparison detects an annotation that * inference wrote even though the annotation is irrelevant where it appears. Such an annotation @@ -140,26 +140,23 @@ private static List generatedAjavaFiles() throws IOException { * Returns the goal file that corresponds to the given generated ajava file. The goal file need * not exist. * + *

Inference writes a class's ajava file into a subdirectory of {@link #inferenceOutputDir} + * that corresponds to the class's package, but the goal files are all in {@link #goalDir}, so a + * goal file's name starts with the class's fully-qualified name. + * * @param ajavaFile a generated ajava file * @return the goal file that corresponds to {@code ajavaFile} */ private static Path goalFileFor(Path ajavaFile) { - if (!inferenceOutputDir.equals(ajavaFile.getParent())) { - // Inference writes a class's ajava file into a subdirectory that corresponds to the class's - // package, but the goal files are all in one directory, so a goal file for a class in a - // named package could never match. Every test input is in the unnamed package, so this - // failure means that a new test input declares a package. - Assert.fail( - String.format( - "%s is not directly in %s, so its class is not in the unnamed package. Either put" - + " the test input in the unnamed package, or generalize" - + " AinferRelevanceAjavaGenerationTest to give each goal file a name that" - + " includes the package.%n", - ajavaFile.toAbsolutePath(), inferenceOutputDir.toAbsolutePath())); - } - String ajavaFileName = ajavaFile.getFileName().toString(); + Path relativeToOutputDir = inferenceOutputDir.relativize(ajavaFile); + String ajavaFileName = relativeToOutputDir.getFileName().toString(); String className = ajavaFileName.substring(0, ajavaFileName.length() - ajavaSuffix.length()); - return goalDir.resolve(className + goalSuffix); + Path packageDir = relativeToOutputDir.getParent(); + String qualifiedName = + packageDir == null + ? className + : packageDir.toString().replace(File.separatorChar, '.') + "." + className; + return goalDir.resolve(qualifiedName + goalSuffix); } /** diff --git a/checker/tests/ainfer-relevance/PackagePrivateNotInherited.ajava.goal b/checker/tests/ainfer-relevance/PackagePrivateNotInherited.ajava.goal new file mode 100644 index 000000000000..86ff174e327a --- /dev/null +++ b/checker/tests/ainfer-relevance/PackagePrivateNotInherited.ajava.goal @@ -0,0 +1,39 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A class does not inherit a package-private member type of a superclass that is in a different +// package, and a declaration hides what its declaring class would otherwise inherit even when the +// declaration itself is not inherited. This class therefore inherits neither +// `otherpackage.OtherPackageSuperclass.List` (which is package-private, and this compilation unit +// is in the unnamed package) nor `otherpackage.OtherPackageGrandparent.List` (which is public, but +// which `OtherPackageSuperclass` does not inherit, because `OtherPackageSuperclass` declares a +// member type of the same name). Inference must resolve the name "List" to the top-level class +// `List` in the unnamed package, which is relevant because it is a subtype of `CharSequence`; +// both member classes named `List` are irrelevant. If inference resolves the name incorrectly and +// therefore discards the annotation, then the second (validation) pass of this test issues the +// warning that is written below. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class PackagePrivateNotInherited extends otherpackage.OtherPackageSuperclass { + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 List field; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "this.field" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + void assignField() { + field = getSibling1(); + } + + @org.checkerframework.dataflow.qual.Impure + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + void expectsSibling1(PackagePrivateNotInherited this, List l) { + } + + @org.checkerframework.dataflow.qual.Pure + List getSibling1(PackagePrivateNotInherited this) { + return null; + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/List.java b/checker/tests/ainfer-relevance/non-annotated/List.java index 3c8f80a6cefc..6245986087ea 100644 --- a/checker/tests/ainfer-relevance/non-annotated/List.java +++ b/checker/tests/ainfer-relevance/non-annotated/List.java @@ -1,5 +1,6 @@ // This class exists so that the unnamed package declares a type whose name is also the name of a -// type that `SamePackageShadows.java` imports on demand. +// type that `SamePackageShadows.java` imports on demand and the name of a package-private member +// type that `PackagePrivateNotInherited.java`'s superclass declares. // // This class is relevant, because it is a subtype of `CharSequence`, which the checker's // `@RelevantJavaTypes` lists. By contrast, `java.util.List` is irrelevant. diff --git a/checker/tests/ainfer-relevance/non-annotated/PackagePrivateNotInherited.java b/checker/tests/ainfer-relevance/non-annotated/PackagePrivateNotInherited.java new file mode 100644 index 000000000000..8f84303a6d63 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/PackagePrivateNotInherited.java @@ -0,0 +1,32 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A class does not inherit a package-private member type of a superclass that is in a different +// package, and a declaration hides what its declaring class would otherwise inherit even when the +// declaration itself is not inherited. This class therefore inherits neither +// `otherpackage.OtherPackageSuperclass.List` (which is package-private, and this compilation unit +// is in the unnamed package) nor `otherpackage.OtherPackageGrandparent.List` (which is public, but +// which `OtherPackageSuperclass` does not inherit, because `OtherPackageSuperclass` declares a +// member type of the same name). Inference must resolve the name "List" to the top-level class +// `List` in the unnamed package, which is relevant because it is a subtype of `CharSequence`; +// both member classes named `List` are irrelevant. If inference resolves the name incorrectly and +// therefore discards the annotation, then the second (validation) pass of this test issues the +// warning that is written below. +public class PackagePrivateNotInherited extends otherpackage.OtherPackageSuperclass { + + List field; + + void assignField() { + field = getSibling1(); + } + + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + void expectsSibling1(@AinferSibling1 List l) {} + + @AinferSibling1 List getSibling1() { + return null; + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/otherpackage/OtherPackageGrandparent.java b/checker/tests/ainfer-relevance/non-annotated/otherpackage/OtherPackageGrandparent.java new file mode 100644 index 000000000000..63847dbae539 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/otherpackage/OtherPackageGrandparent.java @@ -0,0 +1,15 @@ +package otherpackage; + +// This class exists so that `OtherPackageSuperclass`'s package-private member type hides an +// inherited member type of the same name. It is used by `PackagePrivateNotInherited.java`. +// +// This class's member class `List` is irrelevant, because it is not a subtype of any type in the +// checker's `@RelevantJavaTypes`. By contrast, the top-level class `List` in the unnamed package +// is relevant. +public class OtherPackageGrandparent { + + // `OtherPackageSuperclass` declares a member type of the same name, so `OtherPackageSuperclass` + // does not inherit this one, and therefore no subclass of `OtherPackageSuperclass` does either -- + // not even a subclass in this package, for which this member type is accessible. + public static class List {} +} diff --git a/checker/tests/ainfer-relevance/non-annotated/otherpackage/OtherPackageSuperclass.java b/checker/tests/ainfer-relevance/non-annotated/otherpackage/OtherPackageSuperclass.java new file mode 100644 index 000000000000..ad036d880e43 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/otherpackage/OtherPackageSuperclass.java @@ -0,0 +1,16 @@ +package otherpackage; + +// This class exists so that a test input in the unnamed package can extend a class that is in a +// different package and that declares a package-private member type. It is used by +// `PackagePrivateNotInherited.java`. +// +// This class's member class `List` is irrelevant, because it is not a subtype of any type in the +// checker's `@RelevantJavaTypes`. By contrast, the top-level class `List` in the unnamed package +// is relevant. +public class OtherPackageSuperclass extends OtherPackageGrandparent { + + // A subclass in another package does not inherit this member type, so the name "List" in such a + // subclass's body does not refer to it. This declaration also hides + // `OtherPackageGrandparent.List`, which no subclass of this class inherits. + static class List {} +} diff --git a/checker/tests/ainfer-relevance/otherpackage.OtherPackageSuperclass.ajava.goal b/checker/tests/ainfer-relevance/otherpackage.OtherPackageSuperclass.ajava.goal new file mode 100644 index 000000000000..bdc1b84d1744 --- /dev/null +++ b/checker/tests/ainfer-relevance/otherpackage.OtherPackageSuperclass.ajava.goal @@ -0,0 +1,18 @@ +package otherpackage; + +// This class exists so that a test input in the unnamed package can extend a class that is in a +// different package and that declares a package-private member type. It is used by +// `PackagePrivateNotInherited.java`. +// +// This class's member class `List` is irrelevant, because it is not a subtype of any type in the +// checker's `@RelevantJavaTypes`. By contrast, the top-level class `List` in the unnamed package +// is relevant. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class OtherPackageSuperclass extends OtherPackageGrandparent { + + // A subclass in another package does not inherit this member type, so the name "List" in such a + // subclass's body does not refer to it. This declaration also hides + // `OtherPackageGrandparent.List`, which no subclass of this class inherits. + static class List { + } +} diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index fd59b80a06c0..1949421995ea 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -460,12 +460,25 @@ private static boolean containsSame(List nodes, Node node) { return false; } + /** + * A type whose member types {@link #resolveMemberType} searches, together with whether the type's + * package-private member types are inherited by the type at which the search started. + * + * @param typeElement the type whose member types to search + * @param packagePrivateIsInherited true if a package-private member type of {@code typeElement} + * is a member of the type at which the search started + */ + private record SearchedType(TypeElement typeElement, boolean packagePrivateIsInherited) {} + /** * Returns the element for the member type named {@code firstComponent + suffix} that {@code * typeElement} declares or inherits, or null if there is no such member type. * *

{@code typeElement} and its supertypes are searched in breadth-first order, so a member type - * that is declared in a nearer supertype hides one that is declared in a farther supertype. + * that is declared in a nearer supertype hides one that is declared in a farther supertype. A + * declaration hides whatever its declaring type would otherwise inherit, even if the declaration + * is not itself inherited: a private member type is inherited by no type, and a package-private + * member type is inherited only within its own package. * * @param elements used for looking up names * @param typeElement the type whose member types to search @@ -484,29 +497,86 @@ private static boolean containsSame(List nodes, Node node) { Map cache) { Set visited = new HashSet<>(); visited.add(typeElement); - Deque worklist = new ArrayDeque<>(); - worklist.add(typeElement); + Deque worklist = new ArrayDeque<>(); + // Every member type that `typeElement` declares is a member of `typeElement`, whatever its + // access modifier is. + worklist.add(new SearchedType(typeElement, true)); while (!worklist.isEmpty()) { - TypeElement current = worklist.remove(); - for (TypeElement member : ElementFilter.typesIn(current.getEnclosedElements())) { - // A private member type is not inherited. - if (member.getSimpleName().contentEquals(firstComponent) - && !member.getModifiers().contains(Modifier.PRIVATE)) { + SearchedType current = worklist.remove(); + TypeElement currentElement = current.typeElement(); + // A type declares at most one member type with a given simple name. + TypeElement declared = null; + for (TypeElement member : ElementFilter.typesIn(currentElement.getEnclosedElements())) { + if (member.getSimpleName().contentEquals(firstComponent)) { + declared = member; + break; + } + } + if (declared != null) { + if (isInherited(declared, current.packagePrivateIsInherited())) { if (suffix.isEmpty()) { - return member; + return declared; } - return getTypeElement(elements, member.getQualifiedName() + suffix, cache); + return getTypeElement(elements, declared.getQualifiedName() + suffix, cache); } + // `declared` is not a member of the type at which the search started, and it hides every + // member type of the same name that `currentElement` would otherwise inherit, so do not + // search the supertypes of `currentElement`. + continue; } - for (TypeElement supertype : ElementUtils.getDirectSuperTypeElements(current, elements)) { + for (TypeElement supertype : + ElementUtils.getDirectSuperTypeElements(currentElement, elements)) { if (visited.add(supertype)) { - worklist.add(supertype); + // `currentElement` inherits a package-private member type of `supertype` only if the two + // types are in the same package. + worklist.add( + new SearchedType( + supertype, + current.packagePrivateIsInherited() + && inSamePackage(elements, supertype, currentElement))); } } } return null; } + /** + * Returns true if {@code member} is a member of the type at which a search by {@link + * #resolveMemberType} started -- that is, if every type between that type and the type that + * declares {@code member} inherits it. + * + * @param member a member type of the type that is currently being searched + * @param packagePrivateIsInherited true if a package-private member type of the type that is + * currently being searched is a member of the type at which the search started + * @return true if {@code member} is a member of the type at which the search started + */ + private static boolean isInherited(TypeElement member, boolean packagePrivateIsInherited) { + Set modifiers = member.getModifiers(); + if (modifiers.contains(Modifier.PRIVATE)) { + // A private member type is not inherited. + return false; + } + if (modifiers.contains(Modifier.PUBLIC) || modifiers.contains(Modifier.PROTECTED)) { + return true; + } + return packagePrivateIsInherited; + } + + /** + * Returns true if the two types are declared in the same package. + * + * @param elements used for looking up the package that contains a type + * @param type1 a type + * @param type2 a type + * @return true if {@code type1} and {@code type2} are declared in the same package + */ + private static boolean inSamePackage(Elements elements, TypeElement type1, TypeElement type2) { + return elements + .getPackageOf(type1) + .getQualifiedName() + .contentEquals(elements.getPackageOf(type2).getQualifiedName()); + } + /** * Given the compilation unit node for a source file, returns the top level type definition with * the given name. From 8adda37647bf618008120c30688686bbb9bc2f2d Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Wed, 16 Sep 2026 12:03:51 -0700 Subject: [PATCH 15/58] Use the shared WPI output directory helpers in the ainfer-relevance task Every other `ainfer*Generate*` task deletes the WPI output directory with `wpiOutputDirectory()` and `DirectoryDeleter`. This task still inlined an equivalent loop, which the merge of the commit that introduced those helpers left behind. Co-Authored-By: Claude Opus 5 --- checker/build.gradle | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/checker/build.gradle b/checker/build.gradle index 483a0c287b1d..832a95c2f90e 100644 --- a/checker/build.gradle +++ b/checker/build.gradle @@ -736,28 +736,14 @@ tasks.register("ainferRelevanceGenerateAjava", Test) { exceptionFormat = "full" events("passed", "skipped", "failed") } - // Resolve these directories at configuration time, and delete them with `File.deleteDir()` - // rather than with `Project.delete()`, because the Gradle configuration cache forbids using - // a project object from within a task action. - // The list includes `build/whole-program-inference`, the directory that WPI writes into. - // `doLast` renames that directory away, but `doLast` does not run when the test fails, so a - // failed run would otherwise leave stale output that this task would fold into its own - // results. `AinferRelevanceAjavaGenerationTest` also reads that directory directly, to - // compare the inference output to the goal files. - List ainferRelevanceDirsToDelete = [ + // `AinferRelevanceAjavaGenerationTest` reads the WPI output directory directly, to compare the + // inference output to the goal files. + List dirsToDelete = [ file("tests/ainfer-relevance/annotated"), - layout.buildDirectory.dir("whole-program-inference").get().asFile, + wpiOutputDirectory(), ] doFirst { - ainferRelevanceDirsToDelete.forEach { dirToDelete -> - // Unlike `Project.delete()`, `File.deleteDir()` reports failure by returning false rather - // than by throwing. (It returns true if the directory does not exist, and false if the - // path exists but is not a directory.) Without this test, this task would run against - // stale output after a failed deletion. - if (!dirToDelete.deleteDir()) { - throw new GradleException("Could not delete ${dirToDelete}") - } - } + DirectoryDeleter.deleteDirectories(dirsToDelete) } doLast { copyNonannotatedToAnnotatedDirectory("ainfer-relevance") From 78c8c2642e57d9b06a8fba2f2e8e1e454840b5d1 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Wed, 16 Sep 2026 12:04:03 -0700 Subject: [PATCH 16/58] Use a type variable's upper bound when deciding relevance A use of a type variable is relevant exactly when the type variable's upper bound is relevant, so resolve such a use to its bound rather than conservatively retaining every annotation that is written on it. `JavaParserUtil.resolveTypeVariableName` answers which type variable a name refers to. It shares its scope walk with `resolveTypeName`, so the two agree about which declaration a name refers to. That walk now searches a type declaration's declared member types before its type parameters, because a declared member type shadows a type parameter of the same name -- whereas a member type that the declaration merely inherits does not. Co-Authored-By: Claude Opus 5 --- checker/build.gradle | 16 ++ .../IrrelevantTypeVariable.ajava.goal | 19 ++ .../non-annotated/IrrelevantTypeVariable.java | 17 ++ ...holeProgramInferenceJavaParserStorage.java | 43 ++++- .../framework/util/JavaParserUtil.java | 176 ++++++++++++++---- 5 files changed, 236 insertions(+), 35 deletions(-) create mode 100644 checker/tests/ainfer-relevance/IrrelevantTypeVariable.ajava.goal create mode 100644 checker/tests/ainfer-relevance/non-annotated/IrrelevantTypeVariable.java diff --git a/checker/build.gradle b/checker/build.gradle index 832a95c2f90e..426a7db8a3a0 100644 --- a/checker/build.gradle +++ b/checker/build.gradle @@ -742,11 +742,27 @@ tasks.register("ainferRelevanceGenerateAjava", Test) { file("tests/ainfer-relevance/annotated"), wpiOutputDirectory(), ] + // Resolved at configuration time, and deleted with `File.delete()` rather than with + // `Project.delete()`, because the Gradle configuration cache forbids using a project object + // from within a task action. + File irrelevantTypeVariableAnnotated = + file("tests/ainfer-relevance/annotated/IrrelevantTypeVariable.java") doFirst { DirectoryDeleter.deleteDirectories(dirsToDelete) } doLast { copyNonannotatedToAnnotatedDirectory("ainfer-relevance") + + // This test writes an annotation on a use of a type variable whose upper bound is + // irrelevant, so the checker issues an `anno.on.irrelevant` warning for it in both passes of + // the test. The `// ::` comment that states the expectation is removed by + // `copyNonannotatedToAnnotatedDirectory`, so the second (validation) pass would report the + // warning as unexpected. + // `File.delete()` reports failure by returning false rather than by throwing. Without this + // test, the validation pass would report a confusing unexpected diagnostic instead. + if (!irrelevantTypeVariableAnnotated.delete()) { + throw new GradleException("Could not delete ${irrelevantTypeVariableAnnotated}") + } } } diff --git a/checker/tests/ainfer-relevance/IrrelevantTypeVariable.ajava.goal b/checker/tests/ainfer-relevance/IrrelevantTypeVariable.ajava.goal new file mode 100644 index 000000000000..6939e06fa497 --- /dev/null +++ b/checker/tests/ainfer-relevance/IrrelevantTypeVariable.ajava.goal @@ -0,0 +1,19 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A use of a type variable is relevant only if the type variable's upper bound is relevant. +// `Number` is not listed in the checker's `@RelevantJavaTypes` and is not related to a listed type +// by subtyping, so inference must not write, into the .ajava file, an annotation that it infers +// for a use of `T`. The checker rejects the annotation that is written below, for the same +// reason; that is why the Gradle build does not run the second (validation) pass of this test on +// this file. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class IrrelevantTypeVariable { + + T field; + + // :: warning: [anno.on.irrelevant] + @org.checkerframework.dataflow.qual.Impure + void assignField(T t) { + field = t; + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/IrrelevantTypeVariable.java b/checker/tests/ainfer-relevance/non-annotated/IrrelevantTypeVariable.java new file mode 100644 index 000000000000..adf36d827687 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/IrrelevantTypeVariable.java @@ -0,0 +1,17 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A use of a type variable is relevant only if the type variable's upper bound is relevant. +// `Number` is not listed in the checker's `@RelevantJavaTypes` and is not related to a listed type +// by subtyping, so inference must not write, into the .ajava file, an annotation that it infers +// for a use of `T`. The checker rejects the annotation that is written below, for the same +// reason; that is why the Gradle build does not run the second (validation) pass of this test on +// this file. +public class IrrelevantTypeVariable { + + T field; + + // :: warning: [anno.on.irrelevant] + void assignField(@AinferSibling1 T t) { + field = t; + } +} diff --git a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java index 1eb90d54da54..6d9862a29062 100644 --- a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java +++ b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java @@ -1298,12 +1298,53 @@ private boolean arrayCreationLevelIsRelevant( } if (type instanceof ClassOrInterfaceType classType) { TypeElement typeElt = JavaParserUtil.resolveTypeName(elements, classType, typeElementCache); - return typeElt == null ? null : typeElt.asType(); + if (typeElt != null) { + return typeElt.asType(); + } + // `classType` might name a type variable, which has no TypeElement. + return typeVariableUpperBound(classType); } // An intersection type, a union type, `var`, a wildcard, a type parameter declaration, etc. return null; } + /** + * If the given JavaParser type names a type variable, returns the TypeMirror for the type + * variable's upper bound. Otherwise, or if the upper bound cannot be determined, returns null. + * + *

The upper bound stands in for the type variable itself, which has no TypeMirror here. That + * is sound for deciding relevance, because {@code GenericAnnotatedTypeFactory.isRelevant} treats + * a type variable as relevant exactly when its upper bound is. + * + * @param type a JavaParser class or interface type + * @return the TypeMirror for the upper bound of the type variable that {@code type} names, or + * null + */ + private @Nullable TypeMirror typeVariableUpperBound(ClassOrInterfaceType type) { + if (type.getNameWithScope().indexOf('.') != -1) { + // A type variable has no member types, so a qualified name does not name a type variable. + return null; + } + TypeParameter typeParameter = + JavaParserUtil.resolveTypeVariableName(elements, type, typeElementCache); + if (typeParameter == null) { + return null; + } + NodeList bounds = typeParameter.getTypeBound(); + if (bounds.isEmpty()) { + // The implicit upper bound is `Object`. + TypeElement objectElt = elements.getTypeElement("java.lang.Object"); + return objectElt == null ? null : objectElt.asType(); + } + if (bounds.size() > 1) { + // The upper bound is an intersection type, which `Types` cannot create. Be conservative. + return null; + } + // The bound may itself be a type variable, as in ``; the + // recursion terminates because Java forbids a cycle among type variable bounds. + return typeToTypeMirror(bounds.get(0)); + } + /** * Returns the element type of the given type: the type itself if it is not an array type, or its * innermost component type if it is. diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index 1949421995ea..9cdeb09d063a 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -58,7 +58,8 @@ private JavaParserUtil() { * Returns the element for the given JavaParser type, whose name is resolved in the scope of the * type declarations and the compilation unit that contain it. Returns null if the name cannot be * resolved: it names a type variable, a local class, a member of a local or anonymous class, or a - * type that is not on the classpath. + * type that is not on the classpath. To learn whether the name names a type variable, and which + * one, call {@link #resolveTypeVariableName(Elements, ClassOrInterfaceType, Map)}. * *

A client that resolves many names should call {@link #resolveTypeName(Elements, * ClassOrInterfaceType, Map)}, which memoizes the name lookups. @@ -76,7 +77,8 @@ private JavaParserUtil() { * Returns the element for the given JavaParser type, whose name is resolved in the scope of the * type declarations and the compilation unit that contain it. Returns null if the name cannot be * resolved: it names a type variable, a local class, a member of a local or anonymous class, or a - * type that is not on the classpath. + * type that is not on the classpath. To learn whether the name names a type variable, and which + * one, call {@link #resolveTypeVariableName(Elements, ClassOrInterfaceType, Map)}. * *

Resolving one name looks up many candidate names, most of which name no type, so a client * that resolves many names should pass the same cache to each call. A cache should not be reused @@ -91,6 +93,97 @@ private JavaParserUtil() { */ public static @Nullable TypeElement resolveTypeName( Elements elements, ClassOrInterfaceType type, Map cache) { + return resolveName(elements, type, cache).typeElement(); + } + + /** + * Returns the declaration of the type variable that the given JavaParser type names, or null if + * the name does not name a type variable. The name is resolved in the scope of the type + * declarations and the compilation unit that contain it, so the result is null if a type + * declaration shadows a type variable of the same name. + * + *

A client that resolves many names should call {@link #resolveTypeVariableName(Elements, + * ClassOrInterfaceType, Map)}, which memoizes the name lookups. + * + * @param elements used for looking up names + * @param type a JavaParser class or interface type + * @return the declaration of the type variable that {@code type} names, or null if {@code type} + * does not name a type variable + */ + public static @Nullable TypeParameter resolveTypeVariableName( + Elements elements, ClassOrInterfaceType type) { + return resolveTypeVariableName(elements, type, new HashMap<>(4)); + } + + /** + * Returns the declaration of the type variable that the given JavaParser type names, or null if + * the name does not name a type variable. The name is resolved in the scope of the type + * declarations and the compilation unit that contain it, so the result is null if a type + * declaration shadows a type variable of the same name. + * + *

If the name has more than one component, as in {@code T.Inner}, then its first component is + * the one that might name a type variable. + * + * @param elements used for looking up names + * @param type a JavaParser class or interface type + * @param cache maps a name to the type it names, or to null if it names no type; this method both + * reads and writes it + * @return the declaration of the type variable that {@code type} names, or null if {@code type} + * does not name a type variable + */ + public static @Nullable TypeParameter resolveTypeVariableName( + Elements elements, ClassOrInterfaceType type, Map cache) { + return resolveName(elements, type, cache).typeParameter(); + } + + /** + * What a type name refers to: a type, a type variable, or neither. At most one component is + * non-null. + * + * @param typeElement the type that the name refers to, or null if it refers to no type + * @param typeParameter the declaration of the type variable that the name refers to, or null if + * it refers to no type variable + */ + private record ResolvedName( + @Nullable TypeElement typeElement, @Nullable TypeParameter typeParameter) { + + /** A name that refers to neither a type nor a type variable. */ + private static final ResolvedName NONE = new ResolvedName(null, null); + + /** + * Returns a ResolvedName for the given type, or {@link #NONE} if it is null. + * + * @param typeElement a type, or null + * @return a ResolvedName for {@code typeElement} + */ + private static ResolvedName of(@Nullable TypeElement typeElement) { + return typeElement == null ? NONE : new ResolvedName(typeElement, null); + } + + /** + * Returns a ResolvedName for the given type variable declaration. + * + * @param typeParameter the declaration of a type variable + * @return a ResolvedName for {@code typeParameter} + */ + private static ResolvedName of(TypeParameter typeParameter) { + return new ResolvedName(null, typeParameter); + } + } + + /** + * Returns what the given JavaParser type's name refers to: a type, a type variable, or neither. + * The name is resolved in the scope of the type declarations and the compilation unit that + * contain it. + * + * @param elements used for looking up names + * @param type a JavaParser class or interface type + * @param cache maps a name to the type it names, or to null if it names no type; this method both + * reads and writes it + * @return what the name of {@code type} refers to + */ + private static ResolvedName resolveName( + Elements elements, ClassOrInterfaceType type, Map cache) { String name = type.getNameWithScope(); // `firstComponent` is what a single-type import must import; the rest of `name` names a @@ -118,22 +211,42 @@ private JavaParserUtil() { for (Node ancestor = type.getParentNode().orElse(null); ancestor != null; child = ancestor, ancestor = ancestor.getParentNode().orElse(null)) { + if (declaresLocalType(ancestor, firstComponent)) { + // `name` names a local class, or is nested within one. A local class shadows any type of + // the same name, including a type parameter, and `Elements` cannot look up a local class + // by name. + return ResolvedName.NONE; + } + + // A member type that `ancestor` declares shadows a type parameter of `ancestor` that has the + // same name, so the member types are searched first. A member type that `ancestor` merely + // inherits does not shadow a type parameter, so those are searched below, after the type + // parameters. + if (ancestor instanceof TypeDeclaration enclosingType + && declaresMemberType(enclosingType.getMembers(), firstComponent) + && containsSame(scopeOfMemberTypes(enclosingType), child)) { + String enclosingName = nameableFullyQualifiedName(enclosingType); + if (enclosingName == null) { + // A member type of an unnameable class has no name that `Elements` can look up. + return ResolvedName.NONE; + } + // If `name` has a suffix, then the suffix names a type that is nested within the member + // type. If there is no such type, then `name` names nothing, because the member type + // shadows every other type whose name starts with `firstComponent`. + return ResolvedName.of(getTypeElement(elements, enclosingName + "." + name, cache)); + } + if (ancestor instanceof NodeWithTypeParameters genericDeclaration) { for (TypeParameter typeParameter : genericDeclaration.getTypeParameters()) { if (typeParameter.getNameAsString().equals(firstComponent)) { // `name` names a type parameter, or is nested within one. A type parameter shadows - // any type of the same name, and it has no TypeElement. - return null; + // any type of the same name that is declared outside `ancestor`, and it has no + // TypeElement. + return ResolvedName.of(typeParameter); } } } - if (declaresLocalType(ancestor, firstComponent)) { - // `name` names a local class, or is nested within one. A local class shadows any type of - // the same name, and `Elements` cannot look up a local class by name. - return null; - } - if (ancestor instanceof EnumConstantDeclaration enumConstant && containsSame(enumConstant.getClassBody(), child) && declaresMemberType(enumConstant.getClassBody(), firstComponent)) { @@ -142,7 +255,7 @@ && declaresMemberType(enumConstant.getClassBody(), firstComponent)) { // anonymous class has no name that `Elements` can look up. (There is no need to search // the anonymous class's supertype, which is the enum: the enum declaration is an // ancestor, so a later iteration of this loop searches it.) - return null; + return ResolvedName.NONE; } // The member types of a class that `Elements` cannot look up by name -- an anonymous class, @@ -159,34 +272,29 @@ && declaresMemberType(enumConstant.getClassBody(), firstComponent)) { if (body != null && containsSame(body, child)) { if (declaresMemberType(body, firstComponent)) { // A member type of an anonymous class has no name that `Elements` can look up. - return null; + return ResolvedName.NONE; } unnameableSupertypes = Collections.singletonList(creation.getType()); } } else if (ancestor instanceof TypeDeclaration enclosingType - // The class's member types, declared and inherited, are in scope only in its body, and - // not in its annotations, its type parameter section, or its supertype names. Testing - // `child` also prevents infinite recursion on the recursive call below. + // The class's inherited member types are in scope only in its body, and not in its + // annotations, its type parameter section, or its supertype names. Testing `child` + // also prevents infinite recursion on the recursive call below. (The class's declared + // member types are searched above, before its type parameters.) && containsSame(scopeOfMemberTypes(enclosingType), child)) { String enclosingName = nameableFullyQualifiedName(enclosingType); if (enclosingName != null) { - TypeElement result = getTypeElement(elements, enclosingName + "." + name, cache); - if (result != null) { - return result; - } - // The enclosing type might inherit the member type rather than declare it. + // `enclosingType` does not declare the member type, or the search above would have + // ended, but it might inherit it. TypeElement enclosingElement = getTypeElement(elements, enclosingName, cache); if (enclosingElement != null) { - result = resolveMemberType(elements, enclosingElement, firstComponent, suffix, cache); + TypeElement result = + resolveMemberType(elements, enclosingElement, firstComponent, suffix, cache); if (result != null) { - return result; + return ResolvedName.of(result); } } } else { - if (declaresMemberType(enclosingType.getMembers(), firstComponent)) { - // A member type of an unnameable class has no name that `Elements` can look up. - return null; - } unnameableSupertypes = supertypes(enclosingType); } } @@ -196,12 +304,12 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { if (supertypeElement == null) { // The supertype could not be determined, so neither could the member types that the // unnameable class inherits and that might shadow `name`. - return null; + return ResolvedName.NONE; } TypeElement result = resolveMemberType(elements, supertypeElement, firstComponent, suffix, cache); if (result != null) { - return result; + return ResolvedName.of(result); } } } @@ -210,7 +318,7 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { CompilationUnit cu = type.findCompilationUnit().orElse(null); if (cu == null) { // The name might be fully-qualified. - return getTypeElement(elements, name, cache); + return ResolvedName.of(getTypeElement(elements, name, cache)); } // A single-type import or a single-static import of a member type takes precedence over an @@ -223,7 +331,7 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { if (importedName.equals(firstComponent) || importedName.endsWith("." + firstComponent)) { TypeElement result = getTypeElement(elements, importedName + suffix, cache); if (result != null) { - return result; + return ResolvedName.of(result); } // If `importedName` equals `firstComponent`, the import has no qualifier, so it names no // container to search. (JavaParser accepts such an import even though javac does not.) @@ -236,7 +344,7 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { if (containerElement != null) { result = resolveMemberType(elements, containerElement, firstComponent, suffix, cache); if (result != null) { - return result; + return ResolvedName.of(result); } } } @@ -258,7 +366,7 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { for (String containerPrefix : containerPrefixes) { TypeElement result = getTypeElement(elements, containerPrefix + name, cache); if (result != null) { - return result; + return ResolvedName.of(result); } } @@ -271,7 +379,7 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { TypeElement result = resolveMemberType(elements, importedElement, firstComponent, suffix, cache); if (result != null) { - return result; + return ResolvedName.of(result); } } } @@ -280,7 +388,7 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { // The name might be fully-qualified, or might be a top-level type in the unnamed package. // This lookup is last, because a type that is in scope shadows a type whose fully-qualified // name is `name`. - return getTypeElement(elements, name, cache); + return ResolvedName.of(getTypeElement(elements, name, cache)); } /** From bfdc49836cb51d2beb709fb4b9e514eba966300f Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Wed, 16 Sep 2026 12:04:18 -0700 Subject: [PATCH 17/58] Add unit tests for `JavaParserUtil` name resolution The tests cover which member type a simple name refers to, which depends on accessibility and on hiding, and when a name refers to a type variable rather than to a type declaration that shadows it. Co-Authored-By: Claude Opus 5 --- .../framework/util/JavaParserUtilTest.java | 225 ++++++++++++++++++ .../subpkg/IntermediateSub.java | 13 + .../util/javaparserutil/subpkg/Sub.java | 10 + .../util/javaparserutil/superpkg/Base.java | 29 +++ .../javaparserutil/superpkg/Intermediate.java | 14 ++ .../superpkg/SamePackageIntermediateSub.java | 11 + .../superpkg/SamePackageSub.java | 10 + .../javaparserutil/superpkg/Shadowing.java | 14 ++ .../superpkg/TypeParameterSub.java | 11 + 9 files changed, 337 insertions(+) create mode 100644 framework/src/test/java/org/checkerframework/framework/util/JavaParserUtilTest.java create mode 100644 framework/src/test/java/org/checkerframework/framework/util/javaparserutil/subpkg/IntermediateSub.java create mode 100644 framework/src/test/java/org/checkerframework/framework/util/javaparserutil/subpkg/Sub.java create mode 100644 framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Base.java create mode 100644 framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Intermediate.java create mode 100644 framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/SamePackageIntermediateSub.java create mode 100644 framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/SamePackageSub.java create mode 100644 framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Shadowing.java create mode 100644 framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/TypeParameterSub.java diff --git a/framework/src/test/java/org/checkerframework/framework/util/JavaParserUtilTest.java b/framework/src/test/java/org/checkerframework/framework/util/JavaParserUtilTest.java new file mode 100644 index 000000000000..e74716ed12f4 --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/util/JavaParserUtilTest.java @@ -0,0 +1,225 @@ +package org.checkerframework.framework.util; + +import com.github.javaparser.StaticJavaParser; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.type.ClassOrInterfaceType; +import com.github.javaparser.ast.type.TypeParameter; +import com.sun.tools.javac.file.JavacFileManager; +import com.sun.tools.javac.main.JavaCompiler; +import com.sun.tools.javac.processing.JavacProcessingEnvironment; +import com.sun.tools.javac.util.Context; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import javax.lang.model.element.TypeElement; +import javax.lang.model.util.Elements; +import javax.tools.StandardLocation; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.checkerframework.javacutil.BugInCF; +import org.junit.Assert; +import org.junit.Test; + +/** Tests for {@link JavaParserUtil}. */ +public class JavaParserUtilTest { + + /** The package that declares the member types that the tests resolve. */ + private static final String SUPERPKG = + "org.checkerframework.framework.util.javaparserutil.superpkg"; + + /** A package other than {@link #SUPERPKG}. */ + private static final String SUBPKG = "org.checkerframework.framework.util.javaparserutil.subpkg"; + + /** Creates a new JavaParserUtilTest. */ + public JavaParserUtilTest() {} + + /** + * Tests that {@link JavaParserUtil#resolveTypeName} resolves the simple name of an inherited + * member type only when the subtype really inherits it. + */ + @Test + public void testResolveInheritedMemberType() { + // A public member type is inherited, even across packages. + assertResolvesTo( + SUPERPKG + ".Base.Visible", + SUBPKG, + "class Sub extends " + SUPERPKG + ".Base { Visible f; }", + "Visible"); + assertResolvesTo( + SUPERPKG + ".Base.Visible", + SUPERPKG, + "class SamePackageSub extends Base { Visible f; }", + "Visible"); + + // A package-private member type is inherited only within the package that declares it. + assertResolvesTo( + SUPERPKG + ".Base.Hidden", + SUPERPKG, + "class SamePackageSub extends Base { Hidden f; }", + "Hidden"); + assertResolvesTo( + null, SUBPKG, "class Sub extends " + SUPERPKG + ".Base { Hidden f; }", "Hidden"); + + // A private member type is never inherited. + assertResolvesTo(null, SUPERPKG, "class SamePackageSub extends Base { Secret f; }", "Secret"); + + // `Intermediate.Visible` hides `Base.Visible`. A subtype of `Intermediate` therefore does not + // inherit `Base.Visible`, whether or not it inherits `Intermediate.Visible`. + assertResolvesTo( + SUPERPKG + ".Intermediate.Visible", + SUPERPKG, + "class SamePackageIntermediateSub extends Intermediate { Visible f; }", + "Visible"); + assertResolvesTo( + null, + SUBPKG, + "class IntermediateSub extends " + SUPERPKG + ".Intermediate { Visible f; }", + "Visible"); + } + + /** + * Tests that {@link JavaParserUtil#resolveTypeVariableName} resolves a name to a type variable + * exactly when no type declaration shadows the type variable. + */ + @Test + public void testResolveTypeVariableName() { + // A type parameter of an enclosing class or of an enclosing method. + assertNamesTypeVariable( + "T extends CharSequence", + SUPERPKG, + "class SamePackageSub { T f; }", + "T"); + assertNamesTypeVariable( + "T extends CharSequence", + SUPERPKG, + "class SamePackageSub { void m(T p) {} }", + "T"); + // A type parameter need not have a bound. + assertNamesTypeVariable("T", SUPERPKG, "class SamePackageSub { T f; }", "T"); + + // A member type that the class declares shadows a type parameter of the same name, so the name + // names the member type rather than the type variable. + String shadowing = + "class Shadowing extends Base { public static class Visible {} Visible f; }"; + assertNamesTypeVariable(null, SUPERPKG, shadowing, "Visible"); + assertResolvesTo(SUPERPKG + ".Shadowing.Visible", SUPERPKG, shadowing, "Visible"); + + // A member type that the class only inherits does not shadow a type parameter of the same + // name, so the name names the type variable. + String inherited = "class TypeParameterSub extends Base { Visible f; }"; + assertNamesTypeVariable("Visible", SUPERPKG, inherited, "Visible"); + assertResolvesTo(null, SUPERPKG, inherited, "Visible"); + + // A local class shadows a type parameter of the same name. + assertNamesTypeVariable( + null, + SUPERPKG, + "class SamePackageSub { void m() { class T {} T f; } }", + "T"); + + // A name that names no type variable. + assertNamesTypeVariable( + null, SUPERPKG, "class SamePackageSub extends Base { Visible f; }", "Visible"); + + // If the name has several components, its first component is the one that might name a type + // variable. + assertNamesTypeVariable( + "T extends CharSequence", + SUPERPKG, + "class SamePackageSub { T.Inner f; }", + "T.Inner"); + } + + /** + * Asserts that {@link JavaParserUtil#resolveTypeName} resolves {@code typeName}, as written in + * the given compilation unit, to a type element with the given fully-qualified name. + * + * @param expected the expected fully-qualified name, or null if the name should not resolve + * @param packageName the package of the compilation unit + * @param typeDeclaration the type declaration that the compilation unit contains; it must be the + * declaration of a type that is on the classpath, and it must contain {@code typeName} + * @param typeName the type name to resolve + */ + private static void assertResolvesTo( + @Nullable String expected, String packageName, String typeDeclaration, String typeName) { + String source = "package " + packageName + "; " + typeDeclaration; + ClassOrInterfaceType type = findType(source, typeName); + + TypeElement resolved = JavaParserUtil.resolveTypeName(elements, type); + String resolvedName = resolved == null ? null : resolved.getQualifiedName().toString(); + Assert.assertEquals(source, expected, resolvedName); + } + + /** + * Asserts that {@link JavaParserUtil#resolveTypeVariableName} resolves {@code typeName}, as + * written in the given compilation unit, to the declaration of a type variable that is written as + * {@code expected}. + * + * @param expected the expected type parameter declaration, such as {@code "T extends + * CharSequence"}, or null if the name should not name a type variable + * @param packageName the package of the compilation unit + * @param typeDeclaration the type declaration that the compilation unit contains; it must be the + * declaration of a type that is on the classpath, and it must contain {@code typeName} + * @param typeName the type name to resolve + */ + private static void assertNamesTypeVariable( + @Nullable String expected, String packageName, String typeDeclaration, String typeName) { + String source = "package " + packageName + "; " + typeDeclaration; + ClassOrInterfaceType type = findType(source, typeName); + + TypeParameter resolved = JavaParserUtil.resolveTypeVariableName(elements, type); + String resolvedName = resolved == null ? null : resolved.toString(); + Assert.assertEquals(source, expected, resolvedName); + } + + /** + * Parses the given source code and returns the use of the given type name that it contains. + * + * @param source the source code of a compilation unit + * @param typeName a type name that {@code source} uses + * @return the first use of {@code typeName} in {@code source} + */ + private static ClassOrInterfaceType findType(String source, String typeName) { + CompilationUnit cu = StaticJavaParser.parse(source); + return cu.findAll(ClassOrInterfaceType.class).stream() + .filter(t -> t.getNameWithScope().equals(typeName)) + .findFirst() + .orElseThrow(() -> new BugInCF("No type named %s in %s", typeName, source)); + } + + /** Used by the tests to look up names. */ + private static final Elements elements; + + static { + Context context = new Context(); + // The file manager must be created, and its classpath set, before the compilation environment + // reads them. The classpath makes the test data classes, which are compiled along with this + // test, available to `Elements#getTypeElement`. + JavacFileManager fileManager = new JavacFileManager(context, true, StandardCharsets.UTF_8); + try { + fileManager.setLocation(StandardLocation.CLASS_PATH, classpathFiles()); + } catch (IOException e) { + throw new BugInCF(e, "Cannot set the classpath"); + } + JavacProcessingEnvironment env = JavacProcessingEnvironment.instance(context); + JavaCompiler javac = JavaCompiler.instance(context); + // The list of modules must be initialized before entering symbols. + javac.initModules(com.sun.tools.javac.util.List.nil()); + javac.enterDone(); + elements = env.getElementUtils(); + } + + /** + * Returns the entries of the classpath that this test is running on. + * + * @return the entries of the classpath that this test is running on + */ + private static List classpathFiles() { + List result = new ArrayList<>(); + for (String entry : System.getProperty("java.class.path").split(File.pathSeparator)) { + result.add(new File(entry)); + } + return result; + } +} diff --git a/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/subpkg/IntermediateSub.java b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/subpkg/IntermediateSub.java new file mode 100644 index 000000000000..f861ea733276 --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/subpkg/IntermediateSub.java @@ -0,0 +1,13 @@ +package org.checkerframework.framework.util.javaparserutil.subpkg; + +import org.checkerframework.framework.util.javaparserutil.superpkg.Intermediate; + +/** + * Test data for {@code JavaParserUtilTest}: a subtype of {@link Intermediate} in a different + * package than the one that declares it. + */ +public class IntermediateSub extends Intermediate { + + /** Creates a new IntermediateSub. */ + public IntermediateSub() {} +} diff --git a/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/subpkg/Sub.java b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/subpkg/Sub.java new file mode 100644 index 000000000000..42308cdb936a --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/subpkg/Sub.java @@ -0,0 +1,10 @@ +package org.checkerframework.framework.util.javaparserutil.subpkg; + +import org.checkerframework.framework.util.javaparserutil.superpkg.Base; + +/** Test data for {@code JavaParserUtilTest}: a subtype in a different package than {@link Base}. */ +public class Sub extends Base { + + /** Creates a new Sub. */ + public Sub() {} +} diff --git a/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Base.java b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Base.java new file mode 100644 index 000000000000..5ab10f4f038a --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Base.java @@ -0,0 +1,29 @@ +package org.checkerframework.framework.util.javaparserutil.superpkg; + +/** + * Test data for {@code JavaParserUtilTest}: a class with member types of differing accessibility. + */ +public class Base { + + /** Creates a new Base. */ + public Base() {} + + /** A package-private member type, which only a subtype in this package inherits. */ + static class Hidden {} + + /** A public member type, which every subtype inherits. */ + public static class Visible {} + + /** A private member type, which no subtype inherits. */ + private static class Secret {} + + /** + * Returns null. This method exists so that {@code Secret} is used, which avoids a compiler + * warning. + * + * @return null + */ + Secret secret() { + return null; + } +} diff --git a/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Intermediate.java b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Intermediate.java new file mode 100644 index 000000000000..99dc2e3fbe7f --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Intermediate.java @@ -0,0 +1,14 @@ +package org.checkerframework.framework.util.javaparserutil.superpkg; + +/** + * Test data for {@code JavaParserUtilTest}: a subtype that hides a public member type of {@link + * Base} with a package-private one of the same name. + */ +public class Intermediate extends Base { + + /** Creates a new Intermediate. */ + public Intermediate() {} + + /** Hides {@code Base.Visible}, which no subtype of this class inherits as a result. */ + static class Visible {} +} diff --git a/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/SamePackageIntermediateSub.java b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/SamePackageIntermediateSub.java new file mode 100644 index 000000000000..f3205b397e75 --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/SamePackageIntermediateSub.java @@ -0,0 +1,11 @@ +package org.checkerframework.framework.util.javaparserutil.superpkg; + +/** + * Test data for {@code JavaParserUtilTest}: a subtype of {@link Intermediate} in the package that + * declares it. + */ +public class SamePackageIntermediateSub extends Intermediate { + + /** Creates a new SamePackageIntermediateSub. */ + public SamePackageIntermediateSub() {} +} diff --git a/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/SamePackageSub.java b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/SamePackageSub.java new file mode 100644 index 000000000000..f6840790494c --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/SamePackageSub.java @@ -0,0 +1,10 @@ +package org.checkerframework.framework.util.javaparserutil.superpkg; + +/** + * Test data for {@code JavaParserUtilTest}: a subtype in the package that declares {@link Base}. + */ +public class SamePackageSub extends Base { + + /** Creates a new SamePackageSub. */ + public SamePackageSub() {} +} diff --git a/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Shadowing.java b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Shadowing.java new file mode 100644 index 000000000000..59944f2bf4cc --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Shadowing.java @@ -0,0 +1,14 @@ +package org.checkerframework.framework.util.javaparserutil.superpkg; + +/** + * Test data for {@code JavaParserUtilTest}: a class that declares a member type whose name is also + * the name of a type parameter and of an inherited member type. + */ +public class Shadowing extends Base { + + /** Creates a new Shadowing. */ + public Shadowing() {} + + /** Shadows the type parameter {@code Visible}, and hides {@code Base.Visible}. */ + public static class Visible {} +} diff --git a/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/TypeParameterSub.java b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/TypeParameterSub.java new file mode 100644 index 000000000000..d7555e1f3018 --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/TypeParameterSub.java @@ -0,0 +1,11 @@ +package org.checkerframework.framework.util.javaparserutil.superpkg; + +/** + * Test data for {@code JavaParserUtilTest}: a class with a type parameter whose name is also the + * name of a member type that the class inherits but does not declare. + */ +public class TypeParameterSub extends Base { + + /** Creates a new TypeParameterSub. */ + public TypeParameterSub() {} +} From d69fcb81077cf0ec9736b9e2999355ee48f2d64d Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Wed, 16 Sep 2026 12:03:51 -0700 Subject: [PATCH 18/58] Use the shared WPI output directory helpers in the ainfer-relevance task Every other `ainfer*Generate*` task deletes the WPI output directory with `wpiOutputDirectory()` and `DirectoryDeleter`. This task still inlined an equivalent loop, which the merge of the commit that introduced those helpers left behind. Co-Authored-By: Claude Opus 5 --- checker/build.gradle | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/checker/build.gradle b/checker/build.gradle index 483a0c287b1d..832a95c2f90e 100644 --- a/checker/build.gradle +++ b/checker/build.gradle @@ -736,28 +736,14 @@ tasks.register("ainferRelevanceGenerateAjava", Test) { exceptionFormat = "full" events("passed", "skipped", "failed") } - // Resolve these directories at configuration time, and delete them with `File.deleteDir()` - // rather than with `Project.delete()`, because the Gradle configuration cache forbids using - // a project object from within a task action. - // The list includes `build/whole-program-inference`, the directory that WPI writes into. - // `doLast` renames that directory away, but `doLast` does not run when the test fails, so a - // failed run would otherwise leave stale output that this task would fold into its own - // results. `AinferRelevanceAjavaGenerationTest` also reads that directory directly, to - // compare the inference output to the goal files. - List ainferRelevanceDirsToDelete = [ + // `AinferRelevanceAjavaGenerationTest` reads the WPI output directory directly, to compare the + // inference output to the goal files. + List dirsToDelete = [ file("tests/ainfer-relevance/annotated"), - layout.buildDirectory.dir("whole-program-inference").get().asFile, + wpiOutputDirectory(), ] doFirst { - ainferRelevanceDirsToDelete.forEach { dirToDelete -> - // Unlike `Project.delete()`, `File.deleteDir()` reports failure by returning false rather - // than by throwing. (It returns true if the directory does not exist, and false if the - // path exists but is not a directory.) Without this test, this task would run against - // stale output after a failed deletion. - if (!dirToDelete.deleteDir()) { - throw new GradleException("Could not delete ${dirToDelete}") - } - } + DirectoryDeleter.deleteDirectories(dirsToDelete) } doLast { copyNonannotatedToAnnotatedDirectory("ainfer-relevance") From 692d7a18f56a614f328582d3a0e0587810d4df14 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Wed, 16 Sep 2026 12:04:03 -0700 Subject: [PATCH 19/58] Use a type variable's upper bound when deciding relevance A use of a type variable is relevant exactly when the type variable's upper bound is relevant, so resolve such a use to its bound rather than conservatively retaining every annotation that is written on it. `JavaParserUtil.resolveTypeVariableName` answers which type variable a name refers to. It shares its scope walk with `resolveTypeName`, so the two agree about which declaration a name refers to. That walk now searches a type declaration's declared member types before its type parameters, because a declared member type shadows a type parameter of the same name -- whereas a member type that the declaration merely inherits does not. Co-Authored-By: Claude Opus 5 --- checker/build.gradle | 16 ++ .../IrrelevantTypeVariable.ajava.goal | 19 ++ .../non-annotated/IrrelevantTypeVariable.java | 17 ++ ...holeProgramInferenceJavaParserStorage.java | 43 ++++- .../framework/util/JavaParserUtil.java | 176 ++++++++++++++---- 5 files changed, 236 insertions(+), 35 deletions(-) create mode 100644 checker/tests/ainfer-relevance/IrrelevantTypeVariable.ajava.goal create mode 100644 checker/tests/ainfer-relevance/non-annotated/IrrelevantTypeVariable.java diff --git a/checker/build.gradle b/checker/build.gradle index 832a95c2f90e..426a7db8a3a0 100644 --- a/checker/build.gradle +++ b/checker/build.gradle @@ -742,11 +742,27 @@ tasks.register("ainferRelevanceGenerateAjava", Test) { file("tests/ainfer-relevance/annotated"), wpiOutputDirectory(), ] + // Resolved at configuration time, and deleted with `File.delete()` rather than with + // `Project.delete()`, because the Gradle configuration cache forbids using a project object + // from within a task action. + File irrelevantTypeVariableAnnotated = + file("tests/ainfer-relevance/annotated/IrrelevantTypeVariable.java") doFirst { DirectoryDeleter.deleteDirectories(dirsToDelete) } doLast { copyNonannotatedToAnnotatedDirectory("ainfer-relevance") + + // This test writes an annotation on a use of a type variable whose upper bound is + // irrelevant, so the checker issues an `anno.on.irrelevant` warning for it in both passes of + // the test. The `// ::` comment that states the expectation is removed by + // `copyNonannotatedToAnnotatedDirectory`, so the second (validation) pass would report the + // warning as unexpected. + // `File.delete()` reports failure by returning false rather than by throwing. Without this + // test, the validation pass would report a confusing unexpected diagnostic instead. + if (!irrelevantTypeVariableAnnotated.delete()) { + throw new GradleException("Could not delete ${irrelevantTypeVariableAnnotated}") + } } } diff --git a/checker/tests/ainfer-relevance/IrrelevantTypeVariable.ajava.goal b/checker/tests/ainfer-relevance/IrrelevantTypeVariable.ajava.goal new file mode 100644 index 000000000000..6939e06fa497 --- /dev/null +++ b/checker/tests/ainfer-relevance/IrrelevantTypeVariable.ajava.goal @@ -0,0 +1,19 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A use of a type variable is relevant only if the type variable's upper bound is relevant. +// `Number` is not listed in the checker's `@RelevantJavaTypes` and is not related to a listed type +// by subtyping, so inference must not write, into the .ajava file, an annotation that it infers +// for a use of `T`. The checker rejects the annotation that is written below, for the same +// reason; that is why the Gradle build does not run the second (validation) pass of this test on +// this file. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class IrrelevantTypeVariable { + + T field; + + // :: warning: [anno.on.irrelevant] + @org.checkerframework.dataflow.qual.Impure + void assignField(T t) { + field = t; + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/IrrelevantTypeVariable.java b/checker/tests/ainfer-relevance/non-annotated/IrrelevantTypeVariable.java new file mode 100644 index 000000000000..adf36d827687 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/IrrelevantTypeVariable.java @@ -0,0 +1,17 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A use of a type variable is relevant only if the type variable's upper bound is relevant. +// `Number` is not listed in the checker's `@RelevantJavaTypes` and is not related to a listed type +// by subtyping, so inference must not write, into the .ajava file, an annotation that it infers +// for a use of `T`. The checker rejects the annotation that is written below, for the same +// reason; that is why the Gradle build does not run the second (validation) pass of this test on +// this file. +public class IrrelevantTypeVariable { + + T field; + + // :: warning: [anno.on.irrelevant] + void assignField(@AinferSibling1 T t) { + field = t; + } +} diff --git a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java index 1eb90d54da54..6d9862a29062 100644 --- a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java +++ b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java @@ -1298,12 +1298,53 @@ private boolean arrayCreationLevelIsRelevant( } if (type instanceof ClassOrInterfaceType classType) { TypeElement typeElt = JavaParserUtil.resolveTypeName(elements, classType, typeElementCache); - return typeElt == null ? null : typeElt.asType(); + if (typeElt != null) { + return typeElt.asType(); + } + // `classType` might name a type variable, which has no TypeElement. + return typeVariableUpperBound(classType); } // An intersection type, a union type, `var`, a wildcard, a type parameter declaration, etc. return null; } + /** + * If the given JavaParser type names a type variable, returns the TypeMirror for the type + * variable's upper bound. Otherwise, or if the upper bound cannot be determined, returns null. + * + *

The upper bound stands in for the type variable itself, which has no TypeMirror here. That + * is sound for deciding relevance, because {@code GenericAnnotatedTypeFactory.isRelevant} treats + * a type variable as relevant exactly when its upper bound is. + * + * @param type a JavaParser class or interface type + * @return the TypeMirror for the upper bound of the type variable that {@code type} names, or + * null + */ + private @Nullable TypeMirror typeVariableUpperBound(ClassOrInterfaceType type) { + if (type.getNameWithScope().indexOf('.') != -1) { + // A type variable has no member types, so a qualified name does not name a type variable. + return null; + } + TypeParameter typeParameter = + JavaParserUtil.resolveTypeVariableName(elements, type, typeElementCache); + if (typeParameter == null) { + return null; + } + NodeList bounds = typeParameter.getTypeBound(); + if (bounds.isEmpty()) { + // The implicit upper bound is `Object`. + TypeElement objectElt = elements.getTypeElement("java.lang.Object"); + return objectElt == null ? null : objectElt.asType(); + } + if (bounds.size() > 1) { + // The upper bound is an intersection type, which `Types` cannot create. Be conservative. + return null; + } + // The bound may itself be a type variable, as in ``; the + // recursion terminates because Java forbids a cycle among type variable bounds. + return typeToTypeMirror(bounds.get(0)); + } + /** * Returns the element type of the given type: the type itself if it is not an array type, or its * innermost component type if it is. diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index 1949421995ea..9cdeb09d063a 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -58,7 +58,8 @@ private JavaParserUtil() { * Returns the element for the given JavaParser type, whose name is resolved in the scope of the * type declarations and the compilation unit that contain it. Returns null if the name cannot be * resolved: it names a type variable, a local class, a member of a local or anonymous class, or a - * type that is not on the classpath. + * type that is not on the classpath. To learn whether the name names a type variable, and which + * one, call {@link #resolveTypeVariableName(Elements, ClassOrInterfaceType, Map)}. * *

A client that resolves many names should call {@link #resolveTypeName(Elements, * ClassOrInterfaceType, Map)}, which memoizes the name lookups. @@ -76,7 +77,8 @@ private JavaParserUtil() { * Returns the element for the given JavaParser type, whose name is resolved in the scope of the * type declarations and the compilation unit that contain it. Returns null if the name cannot be * resolved: it names a type variable, a local class, a member of a local or anonymous class, or a - * type that is not on the classpath. + * type that is not on the classpath. To learn whether the name names a type variable, and which + * one, call {@link #resolveTypeVariableName(Elements, ClassOrInterfaceType, Map)}. * *

Resolving one name looks up many candidate names, most of which name no type, so a client * that resolves many names should pass the same cache to each call. A cache should not be reused @@ -91,6 +93,97 @@ private JavaParserUtil() { */ public static @Nullable TypeElement resolveTypeName( Elements elements, ClassOrInterfaceType type, Map cache) { + return resolveName(elements, type, cache).typeElement(); + } + + /** + * Returns the declaration of the type variable that the given JavaParser type names, or null if + * the name does not name a type variable. The name is resolved in the scope of the type + * declarations and the compilation unit that contain it, so the result is null if a type + * declaration shadows a type variable of the same name. + * + *

A client that resolves many names should call {@link #resolveTypeVariableName(Elements, + * ClassOrInterfaceType, Map)}, which memoizes the name lookups. + * + * @param elements used for looking up names + * @param type a JavaParser class or interface type + * @return the declaration of the type variable that {@code type} names, or null if {@code type} + * does not name a type variable + */ + public static @Nullable TypeParameter resolveTypeVariableName( + Elements elements, ClassOrInterfaceType type) { + return resolveTypeVariableName(elements, type, new HashMap<>(4)); + } + + /** + * Returns the declaration of the type variable that the given JavaParser type names, or null if + * the name does not name a type variable. The name is resolved in the scope of the type + * declarations and the compilation unit that contain it, so the result is null if a type + * declaration shadows a type variable of the same name. + * + *

If the name has more than one component, as in {@code T.Inner}, then its first component is + * the one that might name a type variable. + * + * @param elements used for looking up names + * @param type a JavaParser class or interface type + * @param cache maps a name to the type it names, or to null if it names no type; this method both + * reads and writes it + * @return the declaration of the type variable that {@code type} names, or null if {@code type} + * does not name a type variable + */ + public static @Nullable TypeParameter resolveTypeVariableName( + Elements elements, ClassOrInterfaceType type, Map cache) { + return resolveName(elements, type, cache).typeParameter(); + } + + /** + * What a type name refers to: a type, a type variable, or neither. At most one component is + * non-null. + * + * @param typeElement the type that the name refers to, or null if it refers to no type + * @param typeParameter the declaration of the type variable that the name refers to, or null if + * it refers to no type variable + */ + private record ResolvedName( + @Nullable TypeElement typeElement, @Nullable TypeParameter typeParameter) { + + /** A name that refers to neither a type nor a type variable. */ + private static final ResolvedName NONE = new ResolvedName(null, null); + + /** + * Returns a ResolvedName for the given type, or {@link #NONE} if it is null. + * + * @param typeElement a type, or null + * @return a ResolvedName for {@code typeElement} + */ + private static ResolvedName of(@Nullable TypeElement typeElement) { + return typeElement == null ? NONE : new ResolvedName(typeElement, null); + } + + /** + * Returns a ResolvedName for the given type variable declaration. + * + * @param typeParameter the declaration of a type variable + * @return a ResolvedName for {@code typeParameter} + */ + private static ResolvedName of(TypeParameter typeParameter) { + return new ResolvedName(null, typeParameter); + } + } + + /** + * Returns what the given JavaParser type's name refers to: a type, a type variable, or neither. + * The name is resolved in the scope of the type declarations and the compilation unit that + * contain it. + * + * @param elements used for looking up names + * @param type a JavaParser class or interface type + * @param cache maps a name to the type it names, or to null if it names no type; this method both + * reads and writes it + * @return what the name of {@code type} refers to + */ + private static ResolvedName resolveName( + Elements elements, ClassOrInterfaceType type, Map cache) { String name = type.getNameWithScope(); // `firstComponent` is what a single-type import must import; the rest of `name` names a @@ -118,22 +211,42 @@ private JavaParserUtil() { for (Node ancestor = type.getParentNode().orElse(null); ancestor != null; child = ancestor, ancestor = ancestor.getParentNode().orElse(null)) { + if (declaresLocalType(ancestor, firstComponent)) { + // `name` names a local class, or is nested within one. A local class shadows any type of + // the same name, including a type parameter, and `Elements` cannot look up a local class + // by name. + return ResolvedName.NONE; + } + + // A member type that `ancestor` declares shadows a type parameter of `ancestor` that has the + // same name, so the member types are searched first. A member type that `ancestor` merely + // inherits does not shadow a type parameter, so those are searched below, after the type + // parameters. + if (ancestor instanceof TypeDeclaration enclosingType + && declaresMemberType(enclosingType.getMembers(), firstComponent) + && containsSame(scopeOfMemberTypes(enclosingType), child)) { + String enclosingName = nameableFullyQualifiedName(enclosingType); + if (enclosingName == null) { + // A member type of an unnameable class has no name that `Elements` can look up. + return ResolvedName.NONE; + } + // If `name` has a suffix, then the suffix names a type that is nested within the member + // type. If there is no such type, then `name` names nothing, because the member type + // shadows every other type whose name starts with `firstComponent`. + return ResolvedName.of(getTypeElement(elements, enclosingName + "." + name, cache)); + } + if (ancestor instanceof NodeWithTypeParameters genericDeclaration) { for (TypeParameter typeParameter : genericDeclaration.getTypeParameters()) { if (typeParameter.getNameAsString().equals(firstComponent)) { // `name` names a type parameter, or is nested within one. A type parameter shadows - // any type of the same name, and it has no TypeElement. - return null; + // any type of the same name that is declared outside `ancestor`, and it has no + // TypeElement. + return ResolvedName.of(typeParameter); } } } - if (declaresLocalType(ancestor, firstComponent)) { - // `name` names a local class, or is nested within one. A local class shadows any type of - // the same name, and `Elements` cannot look up a local class by name. - return null; - } - if (ancestor instanceof EnumConstantDeclaration enumConstant && containsSame(enumConstant.getClassBody(), child) && declaresMemberType(enumConstant.getClassBody(), firstComponent)) { @@ -142,7 +255,7 @@ && declaresMemberType(enumConstant.getClassBody(), firstComponent)) { // anonymous class has no name that `Elements` can look up. (There is no need to search // the anonymous class's supertype, which is the enum: the enum declaration is an // ancestor, so a later iteration of this loop searches it.) - return null; + return ResolvedName.NONE; } // The member types of a class that `Elements` cannot look up by name -- an anonymous class, @@ -159,34 +272,29 @@ && declaresMemberType(enumConstant.getClassBody(), firstComponent)) { if (body != null && containsSame(body, child)) { if (declaresMemberType(body, firstComponent)) { // A member type of an anonymous class has no name that `Elements` can look up. - return null; + return ResolvedName.NONE; } unnameableSupertypes = Collections.singletonList(creation.getType()); } } else if (ancestor instanceof TypeDeclaration enclosingType - // The class's member types, declared and inherited, are in scope only in its body, and - // not in its annotations, its type parameter section, or its supertype names. Testing - // `child` also prevents infinite recursion on the recursive call below. + // The class's inherited member types are in scope only in its body, and not in its + // annotations, its type parameter section, or its supertype names. Testing `child` + // also prevents infinite recursion on the recursive call below. (The class's declared + // member types are searched above, before its type parameters.) && containsSame(scopeOfMemberTypes(enclosingType), child)) { String enclosingName = nameableFullyQualifiedName(enclosingType); if (enclosingName != null) { - TypeElement result = getTypeElement(elements, enclosingName + "." + name, cache); - if (result != null) { - return result; - } - // The enclosing type might inherit the member type rather than declare it. + // `enclosingType` does not declare the member type, or the search above would have + // ended, but it might inherit it. TypeElement enclosingElement = getTypeElement(elements, enclosingName, cache); if (enclosingElement != null) { - result = resolveMemberType(elements, enclosingElement, firstComponent, suffix, cache); + TypeElement result = + resolveMemberType(elements, enclosingElement, firstComponent, suffix, cache); if (result != null) { - return result; + return ResolvedName.of(result); } } } else { - if (declaresMemberType(enclosingType.getMembers(), firstComponent)) { - // A member type of an unnameable class has no name that `Elements` can look up. - return null; - } unnameableSupertypes = supertypes(enclosingType); } } @@ -196,12 +304,12 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { if (supertypeElement == null) { // The supertype could not be determined, so neither could the member types that the // unnameable class inherits and that might shadow `name`. - return null; + return ResolvedName.NONE; } TypeElement result = resolveMemberType(elements, supertypeElement, firstComponent, suffix, cache); if (result != null) { - return result; + return ResolvedName.of(result); } } } @@ -210,7 +318,7 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { CompilationUnit cu = type.findCompilationUnit().orElse(null); if (cu == null) { // The name might be fully-qualified. - return getTypeElement(elements, name, cache); + return ResolvedName.of(getTypeElement(elements, name, cache)); } // A single-type import or a single-static import of a member type takes precedence over an @@ -223,7 +331,7 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { if (importedName.equals(firstComponent) || importedName.endsWith("." + firstComponent)) { TypeElement result = getTypeElement(elements, importedName + suffix, cache); if (result != null) { - return result; + return ResolvedName.of(result); } // If `importedName` equals `firstComponent`, the import has no qualifier, so it names no // container to search. (JavaParser accepts such an import even though javac does not.) @@ -236,7 +344,7 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { if (containerElement != null) { result = resolveMemberType(elements, containerElement, firstComponent, suffix, cache); if (result != null) { - return result; + return ResolvedName.of(result); } } } @@ -258,7 +366,7 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { for (String containerPrefix : containerPrefixes) { TypeElement result = getTypeElement(elements, containerPrefix + name, cache); if (result != null) { - return result; + return ResolvedName.of(result); } } @@ -271,7 +379,7 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { TypeElement result = resolveMemberType(elements, importedElement, firstComponent, suffix, cache); if (result != null) { - return result; + return ResolvedName.of(result); } } } @@ -280,7 +388,7 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { // The name might be fully-qualified, or might be a top-level type in the unnamed package. // This lookup is last, because a type that is in scope shadows a type whose fully-qualified // name is `name`. - return getTypeElement(elements, name, cache); + return ResolvedName.of(getTypeElement(elements, name, cache)); } /** From c4adb90b8e4fe41e4e9dd97fa167a91e8f478b29 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Wed, 16 Sep 2026 12:04:18 -0700 Subject: [PATCH 20/58] Add unit tests for `JavaParserUtil` name resolution The tests cover which member type a simple name refers to, which depends on accessibility and on hiding, and when a name refers to a type variable rather than to a type declaration that shadows it. Co-Authored-By: Claude Opus 5 --- .../framework/util/JavaParserUtilTest.java | 225 ++++++++++++++++++ .../subpkg/IntermediateSub.java | 13 + .../util/javaparserutil/subpkg/Sub.java | 10 + .../util/javaparserutil/superpkg/Base.java | 29 +++ .../javaparserutil/superpkg/Intermediate.java | 14 ++ .../superpkg/SamePackageIntermediateSub.java | 11 + .../superpkg/SamePackageSub.java | 10 + .../javaparserutil/superpkg/Shadowing.java | 14 ++ .../superpkg/TypeParameterSub.java | 11 + 9 files changed, 337 insertions(+) create mode 100644 framework/src/test/java/org/checkerframework/framework/util/JavaParserUtilTest.java create mode 100644 framework/src/test/java/org/checkerframework/framework/util/javaparserutil/subpkg/IntermediateSub.java create mode 100644 framework/src/test/java/org/checkerframework/framework/util/javaparserutil/subpkg/Sub.java create mode 100644 framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Base.java create mode 100644 framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Intermediate.java create mode 100644 framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/SamePackageIntermediateSub.java create mode 100644 framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/SamePackageSub.java create mode 100644 framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Shadowing.java create mode 100644 framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/TypeParameterSub.java diff --git a/framework/src/test/java/org/checkerframework/framework/util/JavaParserUtilTest.java b/framework/src/test/java/org/checkerframework/framework/util/JavaParserUtilTest.java new file mode 100644 index 000000000000..e74716ed12f4 --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/util/JavaParserUtilTest.java @@ -0,0 +1,225 @@ +package org.checkerframework.framework.util; + +import com.github.javaparser.StaticJavaParser; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.type.ClassOrInterfaceType; +import com.github.javaparser.ast.type.TypeParameter; +import com.sun.tools.javac.file.JavacFileManager; +import com.sun.tools.javac.main.JavaCompiler; +import com.sun.tools.javac.processing.JavacProcessingEnvironment; +import com.sun.tools.javac.util.Context; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import javax.lang.model.element.TypeElement; +import javax.lang.model.util.Elements; +import javax.tools.StandardLocation; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.checkerframework.javacutil.BugInCF; +import org.junit.Assert; +import org.junit.Test; + +/** Tests for {@link JavaParserUtil}. */ +public class JavaParserUtilTest { + + /** The package that declares the member types that the tests resolve. */ + private static final String SUPERPKG = + "org.checkerframework.framework.util.javaparserutil.superpkg"; + + /** A package other than {@link #SUPERPKG}. */ + private static final String SUBPKG = "org.checkerframework.framework.util.javaparserutil.subpkg"; + + /** Creates a new JavaParserUtilTest. */ + public JavaParserUtilTest() {} + + /** + * Tests that {@link JavaParserUtil#resolveTypeName} resolves the simple name of an inherited + * member type only when the subtype really inherits it. + */ + @Test + public void testResolveInheritedMemberType() { + // A public member type is inherited, even across packages. + assertResolvesTo( + SUPERPKG + ".Base.Visible", + SUBPKG, + "class Sub extends " + SUPERPKG + ".Base { Visible f; }", + "Visible"); + assertResolvesTo( + SUPERPKG + ".Base.Visible", + SUPERPKG, + "class SamePackageSub extends Base { Visible f; }", + "Visible"); + + // A package-private member type is inherited only within the package that declares it. + assertResolvesTo( + SUPERPKG + ".Base.Hidden", + SUPERPKG, + "class SamePackageSub extends Base { Hidden f; }", + "Hidden"); + assertResolvesTo( + null, SUBPKG, "class Sub extends " + SUPERPKG + ".Base { Hidden f; }", "Hidden"); + + // A private member type is never inherited. + assertResolvesTo(null, SUPERPKG, "class SamePackageSub extends Base { Secret f; }", "Secret"); + + // `Intermediate.Visible` hides `Base.Visible`. A subtype of `Intermediate` therefore does not + // inherit `Base.Visible`, whether or not it inherits `Intermediate.Visible`. + assertResolvesTo( + SUPERPKG + ".Intermediate.Visible", + SUPERPKG, + "class SamePackageIntermediateSub extends Intermediate { Visible f; }", + "Visible"); + assertResolvesTo( + null, + SUBPKG, + "class IntermediateSub extends " + SUPERPKG + ".Intermediate { Visible f; }", + "Visible"); + } + + /** + * Tests that {@link JavaParserUtil#resolveTypeVariableName} resolves a name to a type variable + * exactly when no type declaration shadows the type variable. + */ + @Test + public void testResolveTypeVariableName() { + // A type parameter of an enclosing class or of an enclosing method. + assertNamesTypeVariable( + "T extends CharSequence", + SUPERPKG, + "class SamePackageSub { T f; }", + "T"); + assertNamesTypeVariable( + "T extends CharSequence", + SUPERPKG, + "class SamePackageSub { void m(T p) {} }", + "T"); + // A type parameter need not have a bound. + assertNamesTypeVariable("T", SUPERPKG, "class SamePackageSub { T f; }", "T"); + + // A member type that the class declares shadows a type parameter of the same name, so the name + // names the member type rather than the type variable. + String shadowing = + "class Shadowing extends Base { public static class Visible {} Visible f; }"; + assertNamesTypeVariable(null, SUPERPKG, shadowing, "Visible"); + assertResolvesTo(SUPERPKG + ".Shadowing.Visible", SUPERPKG, shadowing, "Visible"); + + // A member type that the class only inherits does not shadow a type parameter of the same + // name, so the name names the type variable. + String inherited = "class TypeParameterSub extends Base { Visible f; }"; + assertNamesTypeVariable("Visible", SUPERPKG, inherited, "Visible"); + assertResolvesTo(null, SUPERPKG, inherited, "Visible"); + + // A local class shadows a type parameter of the same name. + assertNamesTypeVariable( + null, + SUPERPKG, + "class SamePackageSub { void m() { class T {} T f; } }", + "T"); + + // A name that names no type variable. + assertNamesTypeVariable( + null, SUPERPKG, "class SamePackageSub extends Base { Visible f; }", "Visible"); + + // If the name has several components, its first component is the one that might name a type + // variable. + assertNamesTypeVariable( + "T extends CharSequence", + SUPERPKG, + "class SamePackageSub { T.Inner f; }", + "T.Inner"); + } + + /** + * Asserts that {@link JavaParserUtil#resolveTypeName} resolves {@code typeName}, as written in + * the given compilation unit, to a type element with the given fully-qualified name. + * + * @param expected the expected fully-qualified name, or null if the name should not resolve + * @param packageName the package of the compilation unit + * @param typeDeclaration the type declaration that the compilation unit contains; it must be the + * declaration of a type that is on the classpath, and it must contain {@code typeName} + * @param typeName the type name to resolve + */ + private static void assertResolvesTo( + @Nullable String expected, String packageName, String typeDeclaration, String typeName) { + String source = "package " + packageName + "; " + typeDeclaration; + ClassOrInterfaceType type = findType(source, typeName); + + TypeElement resolved = JavaParserUtil.resolveTypeName(elements, type); + String resolvedName = resolved == null ? null : resolved.getQualifiedName().toString(); + Assert.assertEquals(source, expected, resolvedName); + } + + /** + * Asserts that {@link JavaParserUtil#resolveTypeVariableName} resolves {@code typeName}, as + * written in the given compilation unit, to the declaration of a type variable that is written as + * {@code expected}. + * + * @param expected the expected type parameter declaration, such as {@code "T extends + * CharSequence"}, or null if the name should not name a type variable + * @param packageName the package of the compilation unit + * @param typeDeclaration the type declaration that the compilation unit contains; it must be the + * declaration of a type that is on the classpath, and it must contain {@code typeName} + * @param typeName the type name to resolve + */ + private static void assertNamesTypeVariable( + @Nullable String expected, String packageName, String typeDeclaration, String typeName) { + String source = "package " + packageName + "; " + typeDeclaration; + ClassOrInterfaceType type = findType(source, typeName); + + TypeParameter resolved = JavaParserUtil.resolveTypeVariableName(elements, type); + String resolvedName = resolved == null ? null : resolved.toString(); + Assert.assertEquals(source, expected, resolvedName); + } + + /** + * Parses the given source code and returns the use of the given type name that it contains. + * + * @param source the source code of a compilation unit + * @param typeName a type name that {@code source} uses + * @return the first use of {@code typeName} in {@code source} + */ + private static ClassOrInterfaceType findType(String source, String typeName) { + CompilationUnit cu = StaticJavaParser.parse(source); + return cu.findAll(ClassOrInterfaceType.class).stream() + .filter(t -> t.getNameWithScope().equals(typeName)) + .findFirst() + .orElseThrow(() -> new BugInCF("No type named %s in %s", typeName, source)); + } + + /** Used by the tests to look up names. */ + private static final Elements elements; + + static { + Context context = new Context(); + // The file manager must be created, and its classpath set, before the compilation environment + // reads them. The classpath makes the test data classes, which are compiled along with this + // test, available to `Elements#getTypeElement`. + JavacFileManager fileManager = new JavacFileManager(context, true, StandardCharsets.UTF_8); + try { + fileManager.setLocation(StandardLocation.CLASS_PATH, classpathFiles()); + } catch (IOException e) { + throw new BugInCF(e, "Cannot set the classpath"); + } + JavacProcessingEnvironment env = JavacProcessingEnvironment.instance(context); + JavaCompiler javac = JavaCompiler.instance(context); + // The list of modules must be initialized before entering symbols. + javac.initModules(com.sun.tools.javac.util.List.nil()); + javac.enterDone(); + elements = env.getElementUtils(); + } + + /** + * Returns the entries of the classpath that this test is running on. + * + * @return the entries of the classpath that this test is running on + */ + private static List classpathFiles() { + List result = new ArrayList<>(); + for (String entry : System.getProperty("java.class.path").split(File.pathSeparator)) { + result.add(new File(entry)); + } + return result; + } +} diff --git a/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/subpkg/IntermediateSub.java b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/subpkg/IntermediateSub.java new file mode 100644 index 000000000000..f861ea733276 --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/subpkg/IntermediateSub.java @@ -0,0 +1,13 @@ +package org.checkerframework.framework.util.javaparserutil.subpkg; + +import org.checkerframework.framework.util.javaparserutil.superpkg.Intermediate; + +/** + * Test data for {@code JavaParserUtilTest}: a subtype of {@link Intermediate} in a different + * package than the one that declares it. + */ +public class IntermediateSub extends Intermediate { + + /** Creates a new IntermediateSub. */ + public IntermediateSub() {} +} diff --git a/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/subpkg/Sub.java b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/subpkg/Sub.java new file mode 100644 index 000000000000..42308cdb936a --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/subpkg/Sub.java @@ -0,0 +1,10 @@ +package org.checkerframework.framework.util.javaparserutil.subpkg; + +import org.checkerframework.framework.util.javaparserutil.superpkg.Base; + +/** Test data for {@code JavaParserUtilTest}: a subtype in a different package than {@link Base}. */ +public class Sub extends Base { + + /** Creates a new Sub. */ + public Sub() {} +} diff --git a/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Base.java b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Base.java new file mode 100644 index 000000000000..5ab10f4f038a --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Base.java @@ -0,0 +1,29 @@ +package org.checkerframework.framework.util.javaparserutil.superpkg; + +/** + * Test data for {@code JavaParserUtilTest}: a class with member types of differing accessibility. + */ +public class Base { + + /** Creates a new Base. */ + public Base() {} + + /** A package-private member type, which only a subtype in this package inherits. */ + static class Hidden {} + + /** A public member type, which every subtype inherits. */ + public static class Visible {} + + /** A private member type, which no subtype inherits. */ + private static class Secret {} + + /** + * Returns null. This method exists so that {@code Secret} is used, which avoids a compiler + * warning. + * + * @return null + */ + Secret secret() { + return null; + } +} diff --git a/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Intermediate.java b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Intermediate.java new file mode 100644 index 000000000000..99dc2e3fbe7f --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Intermediate.java @@ -0,0 +1,14 @@ +package org.checkerframework.framework.util.javaparserutil.superpkg; + +/** + * Test data for {@code JavaParserUtilTest}: a subtype that hides a public member type of {@link + * Base} with a package-private one of the same name. + */ +public class Intermediate extends Base { + + /** Creates a new Intermediate. */ + public Intermediate() {} + + /** Hides {@code Base.Visible}, which no subtype of this class inherits as a result. */ + static class Visible {} +} diff --git a/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/SamePackageIntermediateSub.java b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/SamePackageIntermediateSub.java new file mode 100644 index 000000000000..f3205b397e75 --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/SamePackageIntermediateSub.java @@ -0,0 +1,11 @@ +package org.checkerframework.framework.util.javaparserutil.superpkg; + +/** + * Test data for {@code JavaParserUtilTest}: a subtype of {@link Intermediate} in the package that + * declares it. + */ +public class SamePackageIntermediateSub extends Intermediate { + + /** Creates a new SamePackageIntermediateSub. */ + public SamePackageIntermediateSub() {} +} diff --git a/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/SamePackageSub.java b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/SamePackageSub.java new file mode 100644 index 000000000000..f6840790494c --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/SamePackageSub.java @@ -0,0 +1,10 @@ +package org.checkerframework.framework.util.javaparserutil.superpkg; + +/** + * Test data for {@code JavaParserUtilTest}: a subtype in the package that declares {@link Base}. + */ +public class SamePackageSub extends Base { + + /** Creates a new SamePackageSub. */ + public SamePackageSub() {} +} diff --git a/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Shadowing.java b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Shadowing.java new file mode 100644 index 000000000000..59944f2bf4cc --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Shadowing.java @@ -0,0 +1,14 @@ +package org.checkerframework.framework.util.javaparserutil.superpkg; + +/** + * Test data for {@code JavaParserUtilTest}: a class that declares a member type whose name is also + * the name of a type parameter and of an inherited member type. + */ +public class Shadowing extends Base { + + /** Creates a new Shadowing. */ + public Shadowing() {} + + /** Shadows the type parameter {@code Visible}, and hides {@code Base.Visible}. */ + public static class Visible {} +} diff --git a/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/TypeParameterSub.java b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/TypeParameterSub.java new file mode 100644 index 000000000000..d7555e1f3018 --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/TypeParameterSub.java @@ -0,0 +1,11 @@ +package org.checkerframework.framework.util.javaparserutil.superpkg; + +/** + * Test data for {@code JavaParserUtilTest}: a class with a type parameter whose name is also the + * name of a member type that the class inherits but does not declare. + */ +public class TypeParameterSub extends Base { + + /** Creates a new TypeParameterSub. */ + public TypeParameterSub() {} +} From ce2426c600ce2bf9b87397f7ad4576da50664164 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Wed, 16 Sep 2026 12:36:32 -0700 Subject: [PATCH 21/58] Don't copy the AST when no annotation will be removed When the checker supports no invisible qualifier, `removeUnprintedAnnotations` has no effect, so skip both it and the clone that it requires. Also, walk the AST rather than building a list of every node in it. Co-Authored-By: Claude Opus 5 --- ...holeProgramInferenceJavaParserStorage.java | 43 +++++++++++-------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java index 27d837edb5b5..9eafa491d199 100644 --- a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java +++ b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java @@ -1091,12 +1091,16 @@ private void writeAjavaFile(Path outputPath, CompilationUnitAnnos root) { // and crashes when adding annotations in certain locations. // LexicalPreservingPrinter.print(root.declaration, writer); - // The compilation unit is cloned because this method side-effects it: it removes the - // annotations that should not be printed. Removing them from the AST, rather than - // suppressing them in the pretty-printer, prevents the pretty-printer from outputting the - // whitespace that would have separated an annotation from what follows it. - CompilationUnit compilationUnit = root.compilationUnit.clone(); - removeUnprintedAnnotations(compilationUnit); + // Some annotations should not be printed. They are removed from the AST, rather than + // suppressed in the pretty-printer, to prevent the pretty-printer from outputting the + // whitespace that would have separated an annotation from what follows it. The compilation + // unit is cloned first, because removal side-effects it and it is shared among checkers. + CompilationUnit compilationUnit = root.compilationUnit; + Set invisibleQualifierNames = getInvisibleQualifierNames(this.atypeFactory); + if (!invisibleQualifierNames.isEmpty()) { + compilationUnit = compilationUnit.clone(); + removeUnprintedAnnotations(compilationUnit, invisibleQualifierNames); + } DefaultPrettyPrinter prettyPrinter = new DefaultPrettyPrinter() { @@ -1143,21 +1147,24 @@ public void visit(final StringLiteralExpr n, final Void arg) { * because they would clutter it: the invisible qualifiers. * * @param compilationUnit the compilation unit to side-effect + * @param invisibleQualifierNames the canonical names of the annotations to remove; must not be + * empty */ - private void removeUnprintedAnnotations(CompilationUnit compilationUnit) { - Set invisibleQualifierNames = getInvisibleQualifierNames(this.atypeFactory); + private void removeUnprintedAnnotations( + CompilationUnit compilationUnit, Set invisibleQualifierNames) { Predicate shouldRemove = anno -> invisibleQualifierNames.contains(anno.getNameAsString()); - for (Node node : compilationUnit.findAll(Node.class)) { - if (node instanceof NodeWithAnnotations annotated) { - annotated.getAnnotations().removeIf(shouldRemove); - } - if (node instanceof Parameter param) { - // A varargs annotation is on the array type that `...` creates, as in - // `void m(String @Anno ... args)`. It is not in `param.getAnnotations()`. - param.getVarArgsAnnotations().removeIf(shouldRemove); - } - } + compilationUnit.walk( + node -> { + if (node instanceof NodeWithAnnotations annotated) { + annotated.getAnnotations().removeIf(shouldRemove); + } + if (node instanceof Parameter param) { + // A varargs annotation is on the array type that `...` creates, as in + // `void m(String @Anno ... args)`. It is not in `param.getAnnotations()`. + param.getVarArgsAnnotations().removeIf(shouldRemove); + } + }); } // TODO: Move these two routines to StringUtils. From d067883d5f4ac926d8fa2f9e840fc3e82f9b7680 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Wed, 16 Sep 2026 12:53:03 -0700 Subject: [PATCH 22/58] Compute the invisible qualifier names only once Their computation is reflective, and they do not change over the lifetime of a `WholeProgramInferenceJavaParserStorage`. The computation is lazy rather than in the constructor, because `getSupportedTypeQualifiers()` might not yet yield its final result when the storage is constructed. Co-Authored-By: Claude Opus 5 --- .../WholeProgramInferenceJavaParserStorage.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java index 9eafa491d199..854767043d2d 100644 --- a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java +++ b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java @@ -148,6 +148,14 @@ public class WholeProgramInferenceJavaParserStorage /** True if the {@code -AinferOutputOriginal} option was supplied to the checker. */ private final boolean inferOutputOriginal; + /** + * The names of the invisible qualifiers supported by {@link #atypeFactory}, or null if they have + * not yet been computed. They are computed lazily because {@link + * AnnotatedTypeFactory#getSupportedTypeQualifiers} might not yet yield its final result when this + * object is constructed. + */ + private @MonotonicNonNull Set invisibleQualifierNames = null; + /** * Returns the names of all qualifiers that are marked with {@link InvisibleQualifier}, and that * are supported by the given type factory. @@ -1096,7 +1104,9 @@ private void writeAjavaFile(Path outputPath, CompilationUnitAnnos root) { // whitespace that would have separated an annotation from what follows it. The compilation // unit is cloned first, because removal side-effects it and it is shared among checkers. CompilationUnit compilationUnit = root.compilationUnit; - Set invisibleQualifierNames = getInvisibleQualifierNames(this.atypeFactory); + if (invisibleQualifierNames == null) { + invisibleQualifierNames = getInvisibleQualifierNames(this.atypeFactory); + } if (!invisibleQualifierNames.isEmpty()) { compilationUnit = compilationUnit.clone(); removeUnprintedAnnotations(compilationUnit, invisibleQualifierNames); From ac17aebdfe928f2e85e474af1559c256748b0c2a Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Wed, 16 Sep 2026 19:41:09 -0700 Subject: [PATCH 23/58] Brevity --- docs/CHANGELOG.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 68e501df9fe9..b5d4ee08440c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,11 +7,6 @@ ### User-visible changes -Whole-program inference no longer writes, into an `.ajava` file, an annotation -that is irrelevant where it appears -- that is, one written on a Java type -that the checker's `@RelevantJavaTypes` excludes. Such an annotation was -clutter; omitting it does not change the result of type-checking. - ### Changes for type system implementers `JavaParserUtil`: moved `DEFAULT_LANGUAGE_LEVEL`, `parseCompilationUnit()`, From a9f708bf7f014e68bfc1b3418d2a41443ad99e0a Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Wed, 16 Sep 2026 20:18:08 -0700 Subject: [PATCH 24/58] Address code review comments * Skip cloning and walking the AST when no annotation can be removed. * Don't crash if a declaration has no variables; be conservative instead. * Fix comments about where an annotation on a type parameter declaration lands. * Narrow a @SuppressWarnings from a method to a single expression. Co-Authored-By: Claude Opus 5 --- ...holeProgramInferenceJavaParserStorage.java | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java index 31ba83d1cd75..86d18b357ac6 100644 --- a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java +++ b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java @@ -1113,7 +1113,6 @@ private static void writeDeclarationAnnotation( * @param anno an annotation * @return true if the annotation might be relevant */ - @SuppressWarnings("interning:not.interned") boolean annotationIsRelevant(AnnotationExpr anno) { if (!(atypeFactory instanceof GenericAnnotatedTypeFactory gatf)) { return true; @@ -1161,6 +1160,9 @@ boolean annotationIsRelevant(AnnotationExpr anno) { // annotation that might appear in the AST. if (parentNode instanceof Type type) { + // JavaParser's `TypeParameter` is a `Type`, so an annotation on a type parameter + // declaration, as in `<@Anno T>`, takes this branch. `typeToTypeMirror` returns null for a + // type parameter declaration, so such an annotation is retained. return typeIsRelevant(gatf, type); } if (parentNode instanceof ArrayCreationLevel level) { @@ -1170,7 +1172,9 @@ boolean annotationIsRelevant(AnnotationExpr anno) { if (parentNode instanceof Parameter param) { // Use reference equality. `NodeList.contains()` would use structural equality, which does // not distinguish the two annotations in `void m(@Anno String @Anno ... args)`. - if (param.getVarArgsAnnotations().stream().anyMatch(a -> a == anno)) { + @SuppressWarnings("interning:not.interned") // reference equality of AST nodes + boolean isVarArgsAnnotation = param.getVarArgsAnnotations().stream().anyMatch(a -> a == anno); + if (isVarArgsAnnotation) { // The annotation is on the array type that `...` creates, as in // `void m(String @Anno ... args)`. return typeIsRelevant(gatf, param.getType(), 1); @@ -1200,14 +1204,15 @@ boolean annotationIsRelevant(AnnotationExpr anno) { // element type, even if they have different numbers of array levels as in `int i, a[];`. NodeList variables = declaration.getVariables(); if (variables.isEmpty()) { - throw new BugInCF( - "No variables in declaration %s [%s]", declaration, declaration.getClass()); + // This should not occur, but printing a superfluous annotation is better than crashing. + // Be conservative. + return true; } return typeIsRelevant(gatf, innermostComponentType(variables.get(0).getType())); } - // The annotation is on some other declaration: a type declaration, a type parameter - // declaration, a constructor, etc. Be conservative. + // The annotation is on some other declaration: a type declaration, a constructor, etc. Be + // conservative. return true; } @@ -1355,10 +1360,15 @@ private void writeAjavaFile( if (invisibleQualifierNames == null) { invisibleQualifierNames = getInvisibleQualifierNames(this.atypeFactory); } - if (!invisibleQualifierNames.isEmpty() || omitIrrelevantAnnotations) { + // Unless the checker declares `@RelevantJavaTypes`, `annotationIsRelevant` returns true for + // every annotation, so there is no need to test relevance. + boolean omitIrrelevant = + omitIrrelevantAnnotations + && atypeFactory instanceof GenericAnnotatedTypeFactory gatf + && gatf.relevantJavaTypes != null; + if (!invisibleQualifierNames.isEmpty() || omitIrrelevant) { compilationUnit = compilationUnit.clone(); - removeUnprintedAnnotations( - compilationUnit, invisibleQualifierNames, omitIrrelevantAnnotations); + removeUnprintedAnnotations(compilationUnit, invisibleQualifierNames, omitIrrelevant); } DefaultPrettyPrinter prettyPrinter = From d3a009cbccd64ad20e1ff514d4b0b7000728a6d8 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Wed, 16 Sep 2026 21:03:04 -0700 Subject: [PATCH 25/58] Ask "is there anything to remove?" before cloning the AST Also correct the comment about why the compilation unit must be cloned. Co-Authored-By: Claude Opus 5 --- ...holeProgramInferenceJavaParserStorage.java | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java index 854767043d2d..05a327d7563c 100644 --- a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java +++ b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java @@ -156,6 +156,19 @@ public class WholeProgramInferenceJavaParserStorage */ private @MonotonicNonNull Set invisibleQualifierNames = null; + /** + * Returns the names of the invisible qualifiers supported by {@link #atypeFactory}, computing + * them if they have not yet been computed. + * + * @return the names of the invisible qualifiers supported by {@link #atypeFactory} + */ + private Set getInvisibleQualifierNames() { + if (invisibleQualifierNames == null) { + invisibleQualifierNames = getInvisibleQualifierNames(this.atypeFactory); + } + return invisibleQualifierNames; + } + /** * Returns the names of all qualifiers that are marked with {@link InvisibleQualifier}, and that * are supported by the given type factory. @@ -1102,14 +1115,13 @@ private void writeAjavaFile(Path outputPath, CompilationUnitAnnos root) { // Some annotations should not be printed. They are removed from the AST, rather than // suppressed in the pretty-printer, to prevent the pretty-printer from outputting the // whitespace that would have separated an annotation from what follows it. The compilation - // unit is cloned first, because removal side-effects it and it is shared among checkers. + // unit is cloned first, because removal side-effects it and the same AST is printed more + // than once: it is shared by every CompilationUnitAnnos for the source file, and under + // `-AinferOutputOriginal` it is also printed before annotations are transferred into it. CompilationUnit compilationUnit = root.compilationUnit; - if (invisibleQualifierNames == null) { - invisibleQualifierNames = getInvisibleQualifierNames(this.atypeFactory); - } - if (!invisibleQualifierNames.isEmpty()) { + if (hasUnprintedAnnotations()) { compilationUnit = compilationUnit.clone(); - removeUnprintedAnnotations(compilationUnit, invisibleQualifierNames); + removeUnprintedAnnotations(compilationUnit); } DefaultPrettyPrinter prettyPrinter = @@ -1152,18 +1164,26 @@ public void visit(final StringLiteralExpr n, final Void arg) { } } + /** + * Returns true if {@link #removeUnprintedAnnotations} might remove an annotation. If it returns + * false, then the caller need not clone the compilation unit before printing it. + * + * @return true if {@link #removeUnprintedAnnotations} might remove an annotation + */ + private boolean hasUnprintedAnnotations() { + return !getInvisibleQualifierNames().isEmpty(); + } + /** * Removes from the given compilation unit the annotations that an ajava file should not contain, * because they would clutter it: the invisible qualifiers. * * @param compilationUnit the compilation unit to side-effect - * @param invisibleQualifierNames the canonical names of the annotations to remove; must not be - * empty */ - private void removeUnprintedAnnotations( - CompilationUnit compilationUnit, Set invisibleQualifierNames) { + private void removeUnprintedAnnotations(CompilationUnit compilationUnit) { + Set unprintedNames = getInvisibleQualifierNames(); Predicate shouldRemove = - anno -> invisibleQualifierNames.contains(anno.getNameAsString()); + anno -> unprintedNames.contains(anno.getNameAsString()); compilationUnit.walk( node -> { if (node instanceof NodeWithAnnotations annotated) { From 912e03db2751a9f2eda4ee7a272490c49c020547 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Wed, 16 Sep 2026 21:23:21 -0700 Subject: [PATCH 26/58] Move code into utility class --- ...holeProgramInferenceJavaParserStorage.java | 61 +++---------------- .../framework/util/JavaParserUtil.java | 56 +++++++++++++++++ 2 files changed, 65 insertions(+), 52 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java index 86d18b357ac6..e3f5e5d18902 100644 --- a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java +++ b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java @@ -28,9 +28,7 @@ import com.github.javaparser.ast.expr.StringLiteralExpr; import com.github.javaparser.ast.nodeTypes.NodeWithAnnotations; import com.github.javaparser.ast.nodeTypes.NodeWithVariables; -import com.github.javaparser.ast.type.ArrayType; import com.github.javaparser.ast.type.ClassOrInterfaceType; -import com.github.javaparser.ast.type.PrimitiveType; import com.github.javaparser.ast.type.Type; import com.github.javaparser.ast.type.TypeParameter; import com.github.javaparser.ast.type.VoidType; @@ -1161,8 +1159,8 @@ boolean annotationIsRelevant(AnnotationExpr anno) { if (parentNode instanceof Type type) { // JavaParser's `TypeParameter` is a `Type`, so an annotation on a type parameter - // declaration, as in `<@Anno T>`, takes this branch. `typeToTypeMirror` returns null for a - // type parameter declaration, so such an annotation is retained. + // declaration, as in `<@Anno T>`, takes this branch. `JavaParserUtil.typeToTypeMirror` + // returns null for a type parameter declaration, so such an annotation is retained. return typeIsRelevant(gatf, type); } if (parentNode instanceof ArrayCreationLevel level) { @@ -1181,10 +1179,10 @@ boolean annotationIsRelevant(AnnotationExpr anno) { } // The annotation precedes the type, so it is on the element type; in // `void m(@Anno String... args)`, `@Anno` is on `String`. - return typeIsRelevant(gatf, innermostComponentType(param.getType())); + return typeIsRelevant(gatf, param.getType().getElementType()); } if (parentNode instanceof ReceiverParameter receiverParam) { - return typeIsRelevant(gatf, innermostComponentType(receiverParam.getType())); + return typeIsRelevant(gatf, receiverParam.getType().getElementType()); } if (parentNode instanceof MethodDeclaration method) { if (method.getType() instanceof VoidType) { @@ -1194,10 +1192,10 @@ boolean annotationIsRelevant(AnnotationExpr anno) { // handled above. Be conservative. return true; } - return typeIsRelevant(gatf, innermostComponentType(method.getType())); + return typeIsRelevant(gatf, method.getType().getElementType()); } if (parentNode instanceof AnnotationMemberDeclaration member) { - return typeIsRelevant(gatf, innermostComponentType(member.getType())); + return typeIsRelevant(gatf, member.getType().getElementType()); } if (parentNode instanceof NodeWithVariables declaration) { // A field declaration or a local variable declaration. All its variables have the same @@ -1208,7 +1206,7 @@ boolean annotationIsRelevant(AnnotationExpr anno) { // Be conservative. return true; } - return typeIsRelevant(gatf, innermostComponentType(variables.get(0).getType())); + return typeIsRelevant(gatf, variables.get(0).getType().getElementType()); } // The annotation is on some other declaration: a type declaration, a constructor, etc. Be @@ -1241,12 +1239,12 @@ private boolean typeIsRelevant(GenericAnnotatedTypeFactory gatf, Typ */ private boolean typeIsRelevant( GenericAnnotatedTypeFactory gatf, Type componentType, int arrayLevels) { - TypeMirror tm = typeToTypeMirror(componentType); + Types types = atypeFactory.getProcessingEnv().getTypeUtils(); + TypeMirror tm = JavaParserUtil.typeToTypeMirror(elements, types, componentType); if (tm == null) { // The type could not be determined. Be conservative. return true; } - Types types = atypeFactory.getProcessingEnv().getTypeUtils(); for (int i = 0; i < arrayLevels; i++) { tm = types.getArrayType(tm); } @@ -1280,47 +1278,6 @@ private boolean arrayCreationLevelIsRelevant( return true; } - /** - * Returns the TypeMirror for the given JavaParser type, or null if it cannot be determined. - * - * @param type a JavaParser type - * @return the TypeMirror for {@code type}, or null if it cannot be determined - */ - private @Nullable TypeMirror typeToTypeMirror(Type type) { - Types types = atypeFactory.getProcessingEnv().getTypeUtils(); - if (type instanceof ArrayType arrayType) { - TypeMirror componentType = typeToTypeMirror(arrayType.getComponentType()); - return componentType == null ? null : types.getArrayType(componentType); - } - if (type instanceof PrimitiveType primitiveType) { - return types.getPrimitiveType(JavaParserUtil.typeKindForPrimitive(primitiveType)); - } - if (type instanceof VoidType) { - return types.getNoType(TypeKind.VOID); - } - if (type instanceof ClassOrInterfaceType classType) { - TypeElement typeElt = JavaParserUtil.resolveTypeName(elements, classType); - return typeElt == null ? null : typeElt.asType(); - } - // An intersection type, a union type, `var`, a wildcard, a type parameter declaration, etc. - return null; - } - - /** - * Returns the element type of the given type: the type itself if it is not an array type, or its - * innermost component type if it is. - * - * @param type a JavaParser type - * @return the element type of {@code type} - */ - private static Type innermostComponentType(Type type) { - Type componentType = type; - while (componentType instanceof ArrayType arrayType) { - componentType = arrayType.getComponentType(); - } - return componentType; - } - /** * Returns the relative directory that corresponds to a package name. For example, if {@code * separatorChar} is {@code '/'}, then the package name {@code "org.example"} yields {@code diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index 689025ee9d66..83e7fca3c72a 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -15,9 +15,12 @@ import com.github.javaparser.ast.expr.BinaryExpr; import com.github.javaparser.ast.expr.StringLiteralExpr; import com.github.javaparser.ast.nodeTypes.NodeWithTypeParameters; +import com.github.javaparser.ast.type.ArrayType; import com.github.javaparser.ast.type.ClassOrInterfaceType; import com.github.javaparser.ast.type.PrimitiveType; +import com.github.javaparser.ast.type.Type; import com.github.javaparser.ast.type.TypeParameter; +import com.github.javaparser.ast.type.VoidType; import com.github.javaparser.ast.visitor.VoidVisitorAdapter; import java.util.ArrayDeque; import java.util.ArrayList; @@ -32,8 +35,10 @@ import javax.lang.model.element.Modifier; import javax.lang.model.element.TypeElement; import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; import javax.lang.model.util.ElementFilter; import javax.lang.model.util.Elements; +import javax.lang.model.util.Types; import org.checkerframework.checker.nullness.qual.Nullable; import org.checkerframework.javacutil.BugInCF; import org.checkerframework.javacutil.ElementUtils; @@ -359,6 +364,57 @@ public static TypeKind typeKindForPrimitive(PrimitiveType primitiveType) { }; } + /** + * Returns the TypeMirror for the given JavaParser type, or null if it cannot be determined. It + * cannot be determined for an intersection type, a union type, {@code var}, a wildcard, a type + * parameter declaration, or a type that is not on the classpath. + * + *

A client that converts many types should call {@link #typeToTypeMirror(Elements, Types, + * Type, Map)}, which memoizes the name lookups. + * + * @param elements used for looking up names + * @param types used for creating types + * @param type a JavaParser type + * @return the TypeMirror for {@code type}, or null if it cannot be determined + */ + public static @Nullable TypeMirror typeToTypeMirror(Elements elements, Types types, Type type) { + return typeToTypeMirror(elements, types, type, new HashMap<>()); + } + + /** + * Returns the TypeMirror for the given JavaParser type, or null if it cannot be determined. It + * cannot be determined for an intersection type, a union type, {@code var}, a wildcard, a type + * parameter declaration, or a type that is not on the classpath. + * + * @param elements used for looking up names + * @param types used for creating types + * @param type a JavaParser type + * @param cache maps a name to the type it names, or to null if it names no type; this method both + * reads and writes it. See {@link #resolveTypeName(Elements, ClassOrInterfaceType, Map)} for + * restrictions on it. + * @return the TypeMirror for {@code type}, or null if it cannot be determined + */ + public static @Nullable TypeMirror typeToTypeMirror( + Elements elements, Types types, Type type, Map cache) { + if (type instanceof ArrayType arrayType) { + TypeMirror componentType = + typeToTypeMirror(elements, types, arrayType.getComponentType(), cache); + return componentType == null ? null : types.getArrayType(componentType); + } + if (type instanceof PrimitiveType primitiveType) { + return types.getPrimitiveType(typeKindForPrimitive(primitiveType)); + } + if (type instanceof VoidType) { + return types.getNoType(TypeKind.VOID); + } + if (type instanceof ClassOrInterfaceType classType) { + TypeElement typeElt = resolveTypeName(elements, classType, cache); + return typeElt == null ? null : typeElt.asType(); + } + // An intersection type, a union type, `var`, a wildcard, a type parameter declaration, etc. + return null; + } + // // Perform side effects // From ddab90232494794e766fad06be1b59eb05952292 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Thu, 17 Sep 2026 07:27:02 -0700 Subject: [PATCH 27/58] Mention the permits clause in scopeOfMemberTypes javadoc Co-Authored-By: Claude Opus 5 --- .../org/checkerframework/framework/util/JavaParserUtil.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index 8e2374031367..53f347be95e8 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -425,8 +425,8 @@ private static ClassOrInterfaceType javaLangEnum() { /** * Returns the children of the given type declaration in which its member types are in scope: its * body declarations, an enum's constants, and a record's components. The result does not include - * a child in which they are not in scope: an annotation on the declaration, a type parameter, or - * a supertype name. + * a child in which they are not in scope: an annotation on the declaration, a type parameter, a + * supertype name, or a permitted subtype name. * * @param typeDecl a JavaParser type declaration * @return the children of {@code typeDecl} in which its member types are in scope From cfd50f4b23663a37fef137f15384d919202c450f Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Thu, 17 Sep 2026 07:45:33 -0700 Subject: [PATCH 28/58] Resolve a type name only where the type's members are in scope A class's member types are in scope in its body, but not in its header: its annotations, its type parameter bounds, and its supertype names. Likewise, the member types of an anonymous class are in scope only in its body, not in the arguments to its constructor. `resolveTypeName` now tests whether the name appears in the body, rather than testing only whether it is one of the supertype names. Other fixes: * In `outer.new Inner() { ... }`, `Inner` is a member of the type of `outer` rather than a name that is resolved in the scope of the expression, so the anonymous class's supertype cannot be determined. Return null (conservative) rather than resolving `Inner` lexically. * An enum inherits the member type `EnumDesc` from its implicit supertype `java.lang.Enum`. Search `java.lang.Enum` when the enum is unnameable, rather than letting the name fall through to an enclosing scope. * Pass `typeElementCache` to `typeToTypeMirror`, which otherwise allocated a fresh cache per call, so the field was never read. Co-Authored-By: Claude Opus 5 --- .../AnonymousClassScope.ajava.goal | 102 ++++++++++++++++++ .../EnumMemberTypeShadows.ajava.goal | 51 +++++++++ .../non-annotated/AnonymousClassScope.java | 81 ++++++++++++++ .../non-annotated/EnumMemberTypeShadows.java | 46 ++++++++ ...holeProgramInferenceJavaParserStorage.java | 3 +- .../framework/util/JavaParserUtil.java | 76 ++++++++++--- 6 files changed, 342 insertions(+), 17 deletions(-) create mode 100644 checker/tests/ainfer-relevance/AnonymousClassScope.ajava.goal create mode 100644 checker/tests/ainfer-relevance/EnumMemberTypeShadows.ajava.goal create mode 100644 checker/tests/ainfer-relevance/non-annotated/AnonymousClassScope.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/EnumMemberTypeShadows.java diff --git a/checker/tests/ainfer-relevance/AnonymousClassScope.ajava.goal b/checker/tests/ainfer-relevance/AnonymousClassScope.ajava.goal new file mode 100644 index 000000000000..0ce3c3cf484b --- /dev/null +++ b/checker/tests/ainfer-relevance/AnonymousClassScope.ajava.goal @@ -0,0 +1,102 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// The body of an anonymous class is not the whole object creation expression. A name that appears +// in an argument to the constructor is resolved in the enclosing scope; the member types that the +// anonymous class declares and inherits are not in scope there. The name "Foo" in the first test +// below refers to the member class `AnonymousClassScope.Foo`, which is relevant because it is a +// subtype of `CharSequence`, rather than to the inherited member type `Base.Foo`, which is +// irrelevant. +// +// In a qualified object creation expression `outer.new Inner() { ... }`, the name "Inner" is a +// member of the type of `outer` rather than a name that is resolved in the scope of the +// expression. Inference cannot determine the anonymous class's supertype, so it cannot determine +// the member types that the anonymous class inherits, and it must conservatively retain the +// annotation. In the second test below, the name "Foo" refers to the inherited member type +// `Holder.Inner.Foo` rather than to `AnonymousClassScope.Inner.Foo`, which is irrelevant. +// +// If inference resolves either name incorrectly and therefore discards the annotation, then the +// second (validation) pass of this test issues the warnings that are written below. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class AnonymousClassScope { + + abstract static class Foo implements CharSequence { + } + + static class Base { + + @org.checkerframework.dataflow.qual.SideEffectFree + Base(@org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType Object argument) { + } + + static class Foo { + } + } + + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferTop Object inConstructorArgument = new Base(new Object() { + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 Foo field; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "this.field" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + void assignField() { + field = getSibling1(); + } + + @org.checkerframework.dataflow.qual.Impure + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + void expectsSibling1(Foo f) { + } + + @org.checkerframework.dataflow.qual.Pure + Foo getSibling1() { + return null; + } + }) { + }; + + static class Holder { + + class Inner { + + abstract class Foo implements CharSequence { + } + } + } + + static class Inner { + + static class Foo { + } + } + + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferTop Object qualified = new Holder().new Inner() { + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 Foo field; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "this.field" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + void assignField() { + field = getSibling1(); + } + + @org.checkerframework.dataflow.qual.Impure + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + void expectsSibling1(Foo f) { + } + + @org.checkerframework.dataflow.qual.Pure + Foo getSibling1() { + return null; + } + }; +} diff --git a/checker/tests/ainfer-relevance/EnumMemberTypeShadows.ajava.goal b/checker/tests/ainfer-relevance/EnumMemberTypeShadows.ajava.goal new file mode 100644 index 000000000000..fcb728c367f2 --- /dev/null +++ b/checker/tests/ainfer-relevance/EnumMemberTypeShadows.ajava.goal @@ -0,0 +1,51 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A member type of an anonymous class is unnameable, so inference resolves a name in the body of +// the enum below by searching the member types that the enum declares and inherits. An enum +// inherits the member type `EnumDesc` from its implicit supertype `java.lang.Enum`, and that +// member type shadows the member class `EnumMemberTypeShadows.EnumDesc`. The name "EnumDesc" +// below therefore refers to `java.lang.Enum.EnumDesc`, which is irrelevant, rather than to +// `EnumMemberTypeShadows.EnumDesc`, which is relevant because it is a subtype of `CharSequence`. +// +// Inference must discard the annotation on `field`, as the goal file shows. If inference instead +// resolves the name to `EnumMemberTypeShadows.EnumDesc`, then it writes the annotation. +// +// Writing `@AinferSibling1` on a `java.lang.Enum.EnumDesc` is itself a relevance violation, which +// this test suppresses because the warning would appear in both passes of the test and the +// annotated copy of this file contains no expected diagnostics. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class EnumMemberTypeShadows { + + abstract static class EnumDesc implements CharSequence { + } + + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType Object anonymous = new Object() { + + enum Nested { + + CONSTANT; + + EnumDesc field; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "this.field" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + void assignField() { + field = getSibling1(); + } + + @org.checkerframework.dataflow.qual.Impure + void useField() { + expectsSibling1(field); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + void expectsSibling1(@org.checkerframework.checker.testchecker.ainfer.qual.AinferTop Nested this, EnumDesc f) { + } + + @org.checkerframework.dataflow.qual.Pure + EnumDesc getSibling1(@org.checkerframework.checker.testchecker.ainfer.qual.AinferTop Nested this) { + return null; + } + } + }; +} diff --git a/checker/tests/ainfer-relevance/non-annotated/AnonymousClassScope.java b/checker/tests/ainfer-relevance/non-annotated/AnonymousClassScope.java new file mode 100644 index 000000000000..116fa6c99734 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/AnonymousClassScope.java @@ -0,0 +1,81 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// The body of an anonymous class is not the whole object creation expression. A name that appears +// in an argument to the constructor is resolved in the enclosing scope; the member types that the +// anonymous class declares and inherits are not in scope there. The name "Foo" in the first test +// below refers to the member class `AnonymousClassScope.Foo`, which is relevant because it is a +// subtype of `CharSequence`, rather than to the inherited member type `Base.Foo`, which is +// irrelevant. +// +// In a qualified object creation expression `outer.new Inner() { ... }`, the name "Inner" is a +// member of the type of `outer` rather than a name that is resolved in the scope of the +// expression. Inference cannot determine the anonymous class's supertype, so it cannot determine +// the member types that the anonymous class inherits, and it must conservatively retain the +// annotation. In the second test below, the name "Foo" refers to the inherited member type +// `Holder.Inner.Foo` rather than to `AnonymousClassScope.Inner.Foo`, which is irrelevant. +// +// If inference resolves either name incorrectly and therefore discards the annotation, then the +// second (validation) pass of this test issues the warnings that are written below. +public class AnonymousClassScope { + + abstract static class Foo implements CharSequence {} + + static class Base { + Base(Object argument) {} + + static class Foo {} + } + + static Object inConstructorArgument = + new Base( + new Object() { + + Foo field; + + void assignField() { + field = getSibling1(); + } + + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + void expectsSibling1(@AinferSibling1 Foo f) {} + + @AinferSibling1 Foo getSibling1() { + return null; + } + }) {}; + + static class Holder { + class Inner { + abstract class Foo implements CharSequence {} + } + } + + static class Inner { + static class Foo {} + } + + static Object qualified = + new Holder().new Inner() { + + Foo field; + + void assignField() { + field = getSibling1(); + } + + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + void expectsSibling1(@AinferSibling1 Foo f) {} + + @AinferSibling1 Foo getSibling1() { + return null; + } + }; +} diff --git a/checker/tests/ainfer-relevance/non-annotated/EnumMemberTypeShadows.java b/checker/tests/ainfer-relevance/non-annotated/EnumMemberTypeShadows.java new file mode 100644 index 000000000000..ef0ae502add0 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/EnumMemberTypeShadows.java @@ -0,0 +1,46 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A member type of an anonymous class is unnameable, so inference resolves a name in the body of +// the enum below by searching the member types that the enum declares and inherits. An enum +// inherits the member type `EnumDesc` from its implicit supertype `java.lang.Enum`, and that +// member type shadows the member class `EnumMemberTypeShadows.EnumDesc`. The name "EnumDesc" +// below therefore refers to `java.lang.Enum.EnumDesc`, which is irrelevant, rather than to +// `EnumMemberTypeShadows.EnumDesc`, which is relevant because it is a subtype of `CharSequence`. +// +// Inference must discard the annotation on `field`, as the goal file shows. If inference instead +// resolves the name to `EnumMemberTypeShadows.EnumDesc`, then it writes the annotation. +// +// Writing `@AinferSibling1` on a `java.lang.Enum.EnumDesc` is itself a relevance violation, which +// this test suppresses because the warning would appear in both passes of the test and the +// annotated copy of this file contains no expected diagnostics. +@SuppressWarnings("rawtypes") +public class EnumMemberTypeShadows { + + abstract static class EnumDesc implements CharSequence {} + + static Object anonymous = + new Object() { + + enum Nested { + CONSTANT; + + EnumDesc field; + + void assignField() { + field = getSibling1(); + } + + void useField() { + expectsSibling1(field); + } + + @SuppressWarnings("anno.on.irrelevant") + void expectsSibling1(@AinferSibling1 EnumDesc f) {} + + @SuppressWarnings("anno.on.irrelevant") + @AinferSibling1 EnumDesc getSibling1() { + return null; + } + } + }; +} diff --git a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java index f07fd2653eab..6c55f2fdff2c 100644 --- a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java +++ b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java @@ -1264,7 +1264,8 @@ private boolean typeIsRelevant(GenericAnnotatedTypeFactory gatf, Typ private boolean typeIsRelevant( GenericAnnotatedTypeFactory gatf, Type componentType, int arrayLevels) { Types types = atypeFactory.getProcessingEnv().getTypeUtils(); - TypeMirror tm = JavaParserUtil.typeToTypeMirror(elements, types, componentType); + TypeMirror tm = + JavaParserUtil.typeToTypeMirror(elements, types, componentType, typeElementCache); if (tm == null) { // The type could not be determined. Be conservative. return true; diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index 778567eade52..e78ee8f68981 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -80,7 +80,8 @@ private JavaParserUtil() { /** * Returns the element for the given JavaParser type, whose name is resolved in the scope of the * type declarations and the compilation unit that contain it. Returns null if the name cannot be - * resolved, which happens for a type variable and for a type that is not on the classpath. + * resolved: it names a type variable, a local class, a member of a local or anonymous class, or a + * type that is not on the classpath. * *

Resolving one name looks up many candidate names, most of which name no type, so a client * that resolves many names should pass the same cache to each call. A cache should not be reused @@ -117,7 +118,8 @@ private JavaParserUtil() { // // `child` is the child of `ancestor` that contains `type`. It distinguishes a use of `name` // within a class body, where the class's member types are in scope, from a use in the class's - // own supertype names, where they are not. + // header -- its annotations, its type parameter bounds, and its own supertype names -- where + // they are not. Node child = type; for (Node ancestor = type.getParentNode().orElse(null); ancestor != null; @@ -139,6 +141,7 @@ private JavaParserUtil() { } if (ancestor instanceof EnumConstantDeclaration enumConstant + && containsSame(enumConstant.getClassBody(), child) && declaresMemberType(enumConstant.getClassBody(), firstComponent)) { // The body of an enum constant declares an anonymous class. A member type of an // anonymous class has no name that `Elements` can look up. (There is no need to search @@ -155,17 +158,24 @@ && declaresMemberType(enumConstant.getClassBody(), firstComponent)) { List unnameableSupertypes = null; if (ancestor instanceof ObjectCreationExpr creation) { List body = creation.getAnonymousClassBody().orElse(null); - List supertypes = Collections.singletonList(creation.getType()); - // If `child` is the supertype name, then `type` is not in the anonymous class's body, and - // testing `child` also prevents infinite recursion on the recursive call below. - if (body != null && !containsSame(supertypes, child)) { + // The member types are in scope only in the anonymous class's body, not in the supertype + // name or in the constructor arguments. Testing `child` also prevents infinite recursion + // on the recursive call below. + if (body != null && containsSame(body, child)) { if (declaresMemberType(body, firstComponent)) { // A member type of an anonymous class has no name that `Elements` can look up. return null; } - unnameableSupertypes = supertypes; + if (creation.getScope().isPresent()) { + // In `outer.new Inner() { ... }`, `Inner` is a member of the type of `outer` rather + // than a name that is resolved in the scope of the expression, so this method cannot + // determine the member types that the anonymous class inherits. + return null; + } + unnameableSupertypes = Collections.singletonList(creation.getType()); } - } else if (ancestor instanceof TypeDeclaration enclosingType) { + } else if (ancestor instanceof TypeDeclaration enclosingType + && isInBody(enclosingType, child)) { String enclosingName = nameableFullyQualifiedName(enclosingType); if (enclosingName != null) { TypeElement result = getTypeElement(elements, enclosingName + "." + name, cache); @@ -181,16 +191,23 @@ && declaresMemberType(enumConstant.getClassBody(), firstComponent)) { } } } else { - List supertypes = supertypes(enclosingType); - // If `child` is one of the supertype names, then `type` is not in the class's body, and - // testing `child` also prevents infinite recursion on the recursive call below. - if (!containsSame(supertypes, child)) { - if (declaresMemberType(enclosingType.getMembers(), firstComponent)) { - // A member type of an unnameable class has no name that `Elements` can look up. + if (declaresMemberType(enclosingType.getMembers(), firstComponent)) { + // A member type of an unnameable class has no name that `Elements` can look up. + return null; + } + if (enclosingType instanceof EnumDeclaration) { + // An enum's implicit supertype `java.lang.Enum` declares the member type `EnumDesc`. + TypeElement enumElement = getTypeElement(elements, "java.lang.Enum", cache); + if (enumElement == null) { return null; } - unnameableSupertypes = supertypes; + TypeElement result = + resolveMemberType(elements, enumElement, firstComponent, suffix, cache); + if (result != null) { + return result; + } } + unnameableSupertypes = supertypes(enclosingType); } } if (unnameableSupertypes != null) { @@ -380,7 +397,9 @@ private static boolean declaresMemberType(List members, String n /** * Returns the supertypes that the given type declaration names: its {@code extends} clause and * its {@code implements} clause. The result does not include an implicit supertype such as {@code - * java.lang.Object}, none of which declares a member type. + * java.lang.Object} or {@code java.lang.Enum}. Of those, only {@code java.lang.Enum} declares a + * member type, so a caller that searches the result for an inherited member type must search + * {@code java.lang.Enum} itself when {@code typeDecl} is an enum. * * @param typeDecl a JavaParser type declaration * @return the supertypes that {@code typeDecl} names @@ -401,6 +420,31 @@ private static List supertypes(TypeDeclaration typeDecl return Collections.emptyList(); } + /** + * Returns true if the given child of the given type declaration is part of its body, rather than + * part of its header: its annotations, its modifiers, its name, its type parameters and their + * bounds, or its supertype names. The member types of a type declaration are in scope in its + * body, but not in its header. + * + * @param typeDecl a JavaParser type declaration + * @param child a child node of {@code typeDecl} + * @return true if {@code child} is part of the body of {@code typeDecl} + */ + private static boolean isInBody(TypeDeclaration typeDecl, Node child) { + if (containsSame(typeDecl.getMembers(), child)) { + return true; + } + if (typeDecl instanceof EnumDeclaration enumDecl) { + // An enum's constants are part of its body, but are not among its members. + return containsSame(enumDecl.getEntries(), child); + } + if (typeDecl instanceof RecordDeclaration recordDecl) { + // A record's components are part of its header, but its member types are in scope in them. + return containsSame(recordDecl.getParameters(), child); + } + return false; + } + /** * Returns true if one of the given nodes is the given node, compared by reference equality. * JavaParser's {@code equals()} is structural, so {@link List#contains} does not distinguish two From 63eeee29e64ac368333e1cf618d1fea28891a447 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Thu, 17 Sep 2026 08:52:22 -0700 Subject: [PATCH 29/58] Fix memoization loss and missing AFU failure guard `typeIsRelevant` called the 3-arg `JavaParserUtil.typeToTypeMirror`, which allocates a fresh cache per call, leaving `typeElementCache` unused. Pass the field to the 4-arg overload. In `extract-annotations` and `insert-annotations`, `AFU` silently became empty if the `cd`/`pwd` failed; fail there instead of at the eventual `java` call. Co-Authored-By: Claude Opus 5 --- annotation-file-utilities/bin/extract-annotations | 1 + annotation-file-utilities/bin/insert-annotations | 1 + .../WholeProgramInferenceJavaParserStorage.java | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/annotation-file-utilities/bin/extract-annotations b/annotation-file-utilities/bin/extract-annotations index f3d26b808374..3af3240347a0 100755 --- a/annotation-file-utilities/bin/extract-annotations +++ b/annotation-file-utilities/bin/extract-annotations @@ -28,6 +28,7 @@ while [ "$#" -gt 0 ]; do done AFU="${AFU:-$(CDPATH='' cd -P -- "$(dirname -- "$0")/.." && pwd -P)}" +[ -n "$AFU" ] || exit 1 ANNOTATION_FILE_UTILS=${AFU}/dist/annotation-file-utilities-all.jar # Need open to access CommandLine.parse dynamically to check its type: diff --git a/annotation-file-utilities/bin/insert-annotations b/annotation-file-utilities/bin/insert-annotations index c63010bc1d20..91502952154b 100755 --- a/annotation-file-utilities/bin/insert-annotations +++ b/annotation-file-utilities/bin/insert-annotations @@ -28,6 +28,7 @@ while [ "$#" -gt 0 ]; do done AFU="${AFU:-$(CDPATH='' cd -P -- "$(dirname -- "$0")/.." && pwd -P)}" +[ -n "$AFU" ] || exit 1 ANNOTATION_FILE_UTILS="${AFU}/dist/annotation-file-utilities-all.jar" if [ "$DEBUG" = "1" ]; then diff --git a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java index f07fd2653eab..6c55f2fdff2c 100644 --- a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java +++ b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java @@ -1264,7 +1264,8 @@ private boolean typeIsRelevant(GenericAnnotatedTypeFactory gatf, Typ private boolean typeIsRelevant( GenericAnnotatedTypeFactory gatf, Type componentType, int arrayLevels) { Types types = atypeFactory.getProcessingEnv().getTypeUtils(); - TypeMirror tm = JavaParserUtil.typeToTypeMirror(elements, types, componentType); + TypeMirror tm = + JavaParserUtil.typeToTypeMirror(elements, types, componentType, typeElementCache); if (tm == null) { // The type could not be determined. Be conservative. return true; From 32dfd4ae887a5df5d1fd6382fbcef9f47ef16e9e Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Thu, 17 Sep 2026 11:23:53 -0700 Subject: [PATCH 30/58] Fix the scope of local types and of inherited unnameable member types A local type declaration does not shadow a use that precedes it, and a member type that is inherited from one supertype does not hide one that is inherited from another. Co-Authored-By: Claude Opus 5 --- .../framework/util/JavaParserUtil.java | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index 773730090fdb..cc62c53b12d7 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -134,7 +134,7 @@ private JavaParserUtil() { } } - if (declaresLocalType(ancestor, firstComponent)) { + if (declaresLocalType(ancestor, firstComponent, child)) { // `name` names a local class, or is nested within one. A local class shadows any type of // the same name, and `Elements` cannot look up a local class by name. return null; @@ -203,6 +203,10 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { } } if (unnameableSupertypes != null) { + // Every direct supertype is searched, rather than returning the first member type that is + // found, because a member type that is inherited from one supertype does not hide one that + // is inherited from another. + TypeElement inherited = null; for (ClassOrInterfaceType supertype : unnameableSupertypes) { TypeElement supertypeElement = resolveTypeName(elements, supertype, cache); if (supertypeElement == null) { @@ -210,12 +214,21 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { // unnameable class inherits and that might shadow `name`. return null; } - TypeElement result = + TypeElement fromSupertype = resolveMemberType(elements, supertypeElement, firstComponent, suffix, cache); - if (result != null) { - return result; + if (fromSupertype != null) { + if (inherited == null) { + inherited = fromSupertype; + } else if (!inherited.equals(fromSupertype)) { + // The class inherits two different member types with the same simple name. Which + // one `name` refers to (if either is accessible) cannot be determined here. + return null; + } } } + if (inherited != null) { + return inherited; + } } } @@ -318,14 +331,20 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { /** * Returns true if {@code node} directly contains a statement that declares a local class, - * interface, enum, or record whose name is {@code name}. Such a declaration shadows, throughout - * the block that contains it, every type of the same name that is declared elsewhere. + * interface, enum, or record whose name is {@code name}, at or before the child {@code + * lastChild}. Such a declaration shadows, throughout the rest of the block that contains it, + * every type of the same name that is declared elsewhere. It does not shadow a type that is used + * earlier in the block, so this method ignores the children that follow {@code lastChild}. * * @param node a JavaParser node, such as a block * @param name a simple type name - * @return true if {@code node} declares a local type named {@code name} + * @param lastChild the last child of {@code node} to examine; the use whose name is being + * resolved appears within it + * @return true if {@code node} declares a local type named {@code name}, at or before {@code + * lastChild} */ - private static boolean declaresLocalType(Node node, String name) { + @SuppressWarnings("interning:not.interned") // reference equality of AST nodes + private static boolean declaresLocalType(Node node, String name, Node lastChild) { for (Node child : node.getChildNodes()) { if (child instanceof Statement) { // A local type declaration is the only kind of statement whose child is a type @@ -337,6 +356,10 @@ private static boolean declaresLocalType(Node node, String name) { } } } + if (child == lastChild) { + // A declaration that appears later in the block does not shadow a use within `lastChild`. + return false; + } } return false; } From be37f9ff2033a74f615cdcc47900bd7a047e2f94 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Thu, 17 Sep 2026 11:56:19 -0700 Subject: [PATCH 31/58] Fix merge --- .../checkerframework/framework/util/JavaParserUtil.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index 979ccd6548a4..86fb52cf8765 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -217,7 +217,7 @@ private static ResolvedName resolveName( for (Node ancestor = type.getParentNode().orElse(null); ancestor != null; child = ancestor, ancestor = ancestor.getParentNode().orElse(null)) { - if (declaresLocalType(ancestor, firstComponent)) { + if (declaresLocalType(ancestor, firstComponent, child)) { // `name` names a local class, or is nested within one. A local class shadows any type of // the same name, including a type parameter, and `Elements` cannot look up a local class // by name. @@ -284,7 +284,7 @@ && declaresMemberType(enumConstant.getClassBody(), firstComponent)) { // In `outer.new Inner() { ... }`, `Inner` is a member of the type of `outer` rather // than a name that is resolved in the scope of the expression, so this method cannot // determine the member types that the anonymous class inherits. - return null; + return ResolvedName.NONE; } unnameableSupertypes = Collections.singletonList(creation.getType()); } @@ -330,12 +330,12 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { } else if (!inherited.equals(fromSupertype)) { // The class inherits two different member types with the same simple name. Which // one `name` refers to (if either is accessible) cannot be determined here. - return null; + return ResolvedName.NONE; } } } if (inherited != null) { - return inherited; + return ResolvedName.of(inherited); } } } From 522ea4faf3676596cb3c1d903f817d1964d6d4c4 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Thu, 17 Sep 2026 12:25:57 -0700 Subject: [PATCH 32/58] Fix inheritance of package-private member types by unnameable classes When a name is used in the body of a local or an anonymous class, the class's supertypes are searched for a member type that the class inherits. The search treated a package-private member type of a supertype as inherited even when the supertype is in a different package than the unnameable class, which has no TypeElement whose package could be compared. Such a member type is not inherited, and it wrongly shadowed the type that an import or the class's own package supplies. Co-Authored-By: Claude Opus 5 --- .../framework/util/JavaParserUtil.java | 49 ++++++++++++--- .../framework/util/JavaParserUtilTest.java | 59 +++++++++++++++++++ .../util/javaparserutil/subpkg/Outer.java | 14 +++++ .../util/javaparserutil/subpkg/Shadowed.java | 14 +++++ .../util/javaparserutil/superpkg/Base.java | 7 +++ .../util/javaparserutil/superpkg/Outer.java | 12 ++++ 6 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 framework/src/test/java/org/checkerframework/framework/util/javaparserutil/subpkg/Outer.java create mode 100644 framework/src/test/java/org/checkerframework/framework/util/javaparserutil/subpkg/Shadowed.java create mode 100644 framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Outer.java diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index 86fb52cf8765..d72766d39824 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -300,8 +300,10 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { // ended, but it might inherit it. TypeElement enclosingElement = getTypeElement(elements, enclosingName, cache); if (enclosingElement != null) { + // The search starts at `enclosingElement` itself, every one of whose declared member + // types is a member of it. TypeElement result = - resolveMemberType(elements, enclosingElement, firstComponent, suffix, cache); + resolveMemberType(elements, enclosingElement, firstComponent, suffix, true, cache); if (result != null) { return ResolvedName.of(result); } @@ -322,8 +324,22 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { // unnameable class inherits and that might shadow `name`. return ResolvedName.NONE; } + // The unnameable class inherits a package-private member type of `supertypeElement` only + // if the two types are in the same package. (The unnameable class has no TypeElement, + // so its package is that of the compilation unit that declares it.) + boolean packagePrivateIsInherited = + elements + .getPackageOf(supertypeElement) + .getQualifiedName() + .contentEquals(packageNameOf(type)); TypeElement fromSupertype = - resolveMemberType(elements, supertypeElement, firstComponent, suffix, cache); + resolveMemberType( + elements, + supertypeElement, + firstComponent, + suffix, + packagePrivateIsInherited, + cache); if (fromSupertype != null) { if (inherited == null) { inherited = fromSupertype; @@ -367,7 +383,8 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { importedName.substring(0, importedName.length() - firstComponent.length() - 1); TypeElement containerElement = getTypeElement(elements, containerName, cache); if (containerElement != null) { - result = resolveMemberType(elements, containerElement, firstComponent, suffix, cache); + result = + resolveMemberType(elements, containerElement, firstComponent, suffix, true, cache); if (result != null) { return ResolvedName.of(result); } @@ -402,7 +419,7 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { TypeElement importedElement = getTypeElement(elements, importDecl.getNameAsString(), cache); if (importedElement != null) { TypeElement result = - resolveMemberType(elements, importedElement, firstComponent, suffix, cache); + resolveMemberType(elements, importedElement, firstComponent, suffix, true, cache); if (result != null) { return ResolvedName.of(result); } @@ -517,6 +534,20 @@ private static boolean declaresMemberType(List members, String n return typeDecl.getFullyQualifiedName().orElse(null); } + /** + * Returns the name of the package that declares the given JavaParser node, or "" if the node is + * in the unnamed package or is not part of a compilation unit. + * + * @param node a JavaParser node + * @return the name of the package that declares {@code node} + */ + private static String packageNameOf(Node node) { + return node.findCompilationUnit() + .flatMap(CompilationUnit::getPackageDeclaration) + .map(pkg -> pkg.getNameAsString()) + .orElse(""); + } + /** * Returns the supertypes of the given type declaration from which it can inherit a member type: * those that its {@code extends} clause and its {@code implements} clause name, plus the implicit @@ -628,6 +659,11 @@ private record SearchedType(TypeElement typeElement, boolean packagePrivateIsInh * @param firstComponent the simple name of a member type of {@code typeElement} * @param suffix the rest of the type name, which names a type nested within {@code * firstComponent}; it is empty or starts with "." + * @param packagePrivateIsInherited true if a package-private member type of {@code typeElement} + * is a member of the type on whose behalf the search is being made. This is false when the + * search is made on behalf of a type -- such as a local or an anonymous class, which has no + * {@code TypeElement} -- that is not in the package of {@code typeElement}; it is true when + * the search is made on behalf of {@code typeElement} itself. * @param cache maps a name to the type it names, or to null if it names no type; this method both * reads and writes it * @return the element for the member type, or null if it cannot be determined @@ -637,13 +673,12 @@ private record SearchedType(TypeElement typeElement, boolean packagePrivateIsInh TypeElement typeElement, String firstComponent, String suffix, + boolean packagePrivateIsInherited, Map cache) { Set visited = new HashSet<>(); visited.add(typeElement); Deque worklist = new ArrayDeque<>(); - // Every member type that `typeElement` declares is a member of `typeElement`, whatever its - // access modifier is. - worklist.add(new SearchedType(typeElement, true)); + worklist.add(new SearchedType(typeElement, packagePrivateIsInherited)); while (!worklist.isEmpty()) { SearchedType current = worklist.remove(); TypeElement currentElement = current.typeElement(); diff --git a/framework/src/test/java/org/checkerframework/framework/util/JavaParserUtilTest.java b/framework/src/test/java/org/checkerframework/framework/util/JavaParserUtilTest.java index e74716ed12f4..3a26210c67bf 100644 --- a/framework/src/test/java/org/checkerframework/framework/util/JavaParserUtilTest.java +++ b/framework/src/test/java/org/checkerframework/framework/util/JavaParserUtilTest.java @@ -78,6 +78,65 @@ public void testResolveInheritedMemberType() { "Visible"); } + /** + * Tests that {@link JavaParserUtil#resolveTypeName} resolves the simple name of a member type + * that a local class or an anonymous class inherits only when that class really inherits it. Such + * a class has no name that {@link Elements} can look up, so its own package -- that of the + * compilation unit that declares it -- determines whether it inherits a package-private member + * type of its supertype. + */ + @Test + public void testResolveMemberTypeInheritedByUnnameableClass() { + // A public member type is inherited, even across packages. + assertResolvesTo(SUPERPKG + ".Base.Visible", SUPERPKG, localClass("Visible"), "Visible"); + assertResolvesTo(SUPERPKG + ".Base.Visible", SUBPKG, localClass("Visible"), "Visible"); + assertResolvesTo(SUPERPKG + ".Base.Visible", SUPERPKG, anonymousClass("Visible"), "Visible"); + assertResolvesTo(SUPERPKG + ".Base.Visible", SUBPKG, anonymousClass("Visible"), "Visible"); + + // A package-private member type is inherited only within the package that declares it. + assertResolvesTo(SUPERPKG + ".Base.Hidden", SUPERPKG, localClass("Hidden"), "Hidden"); + assertResolvesTo(null, SUBPKG, localClass("Hidden"), "Hidden"); + assertResolvesTo(SUPERPKG + ".Base.Hidden", SUPERPKG, anonymousClass("Hidden"), "Hidden"); + assertResolvesTo(null, SUBPKG, anonymousClass("Hidden"), "Hidden"); + + // A private member type is never inherited. + assertResolvesTo(null, SUPERPKG, localClass("Secret"), "Secret"); + assertResolvesTo(null, SUPERPKG, anonymousClass("Secret"), "Secret"); + + // When the class does not inherit the package-private member type, the name resolves to the + // type of the same simple name that is declared in the class's own package. + assertResolvesTo(SUPERPKG + ".Base.Shadowed", SUPERPKG, localClass("Shadowed"), "Shadowed"); + assertResolvesTo(SUBPKG + ".Shadowed", SUBPKG, localClass("Shadowed"), "Shadowed"); + assertResolvesTo(SUPERPKG + ".Base.Shadowed", SUPERPKG, anonymousClass("Shadowed"), "Shadowed"); + assertResolvesTo(SUBPKG + ".Shadowed", SUBPKG, anonymousClass("Shadowed"), "Shadowed"); + } + + /** + * Returns the declaration of a class named {@code Outer}, which is not a subtype of {@code Base}, + * that declares a local class that extends {@code Base} and that uses the given type name. + * + * @param typeName the type name that the local class uses + * @return a type declaration that contains a local class that uses {@code typeName} + */ + private static String localClass(String typeName) { + return "class Outer { void m() { class Local extends " + + SUPERPKG + + ".Base { " + + typeName + + " f; } } }"; + } + + /** + * Returns the declaration of a class named {@code Outer}, which is not a subtype of {@code Base}, + * that declares an anonymous subclass of {@code Base} that uses the given type name. + * + * @param typeName the type name that the anonymous class uses + * @return a type declaration that contains an anonymous class that uses {@code typeName} + */ + private static String anonymousClass(String typeName) { + return "class Outer { Object o = new " + SUPERPKG + ".Base() { " + typeName + " f; }; }"; + } + /** * Tests that {@link JavaParserUtil#resolveTypeVariableName} resolves a name to a type variable * exactly when no type declaration shadows the type variable. diff --git a/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/subpkg/Outer.java b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/subpkg/Outer.java new file mode 100644 index 000000000000..34cc177986f1 --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/subpkg/Outer.java @@ -0,0 +1,14 @@ +package org.checkerframework.framework.util.javaparserutil.subpkg; + +import org.checkerframework.framework.util.javaparserutil.superpkg.Base; + +/** + * Test data for {@code JavaParserUtilTest}: a class that is not a subtype of {@link Base}, so that + * a test of a local or an anonymous class within it cannot accidentally resolve a name through the + * enclosing class. + */ +public class Outer { + + /** Creates a new Outer. */ + public Outer() {} +} diff --git a/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/subpkg/Shadowed.java b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/subpkg/Shadowed.java new file mode 100644 index 000000000000..e71d4c5452dc --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/subpkg/Shadowed.java @@ -0,0 +1,14 @@ +package org.checkerframework.framework.util.javaparserutil.subpkg; + +import org.checkerframework.framework.util.javaparserutil.superpkg.Base; + +/** + * Test data for {@code JavaParserUtilTest}: a top-level type whose simple name is also the simple + * name of a package-private member type of {@link Base}. A class in this package does not inherit + * {@code Base.Shadowed}, so the simple name {@code Shadowed} refers to this type. + */ +public class Shadowed { + + /** Creates a new Shadowed. */ + public Shadowed() {} +} diff --git a/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Base.java b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Base.java index 5ab10f4f038a..59460b47cb93 100644 --- a/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Base.java +++ b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Base.java @@ -14,6 +14,13 @@ static class Hidden {} /** A public member type, which every subtype inherits. */ public static class Visible {} + /** + * A package-private member type whose simple name is also the simple name of a top-level type in + * the {@code subpkg} package. A class in that package does not inherit this type, so the simple + * name {@code Shadowed} refers there to the top-level type instead. + */ + static class Shadowed {} + /** A private member type, which no subtype inherits. */ private static class Secret {} diff --git a/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Outer.java b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Outer.java new file mode 100644 index 000000000000..0c7cfce30730 --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/Outer.java @@ -0,0 +1,12 @@ +package org.checkerframework.framework.util.javaparserutil.superpkg; + +/** + * Test data for {@code JavaParserUtilTest}: a class that is not a subtype of {@link Base}, so that + * a test of a local or an anonymous class within it cannot accidentally resolve a name through the + * enclosing class. + */ +public class Outer { + + /** Creates a new Outer. */ + public Outer() {} +} From 09a14dac2cf39583abb9d4f5591a550f94c8cca5 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Thu, 17 Sep 2026 12:28:01 -0700 Subject: [PATCH 33/58] Address code review of name resolution * Test the kind of the ancestor and the child rather than scanning their children, making the per-ancestor work in resolveTypeName constant-time again. * Copy a record's implemented types, as the enum case already does, rather than returning the live AST NodeList. * Remove the unused no-cache overload of typeToTypeMirror. Co-Authored-By: Claude Opus 5 --- .../framework/util/JavaParserUtil.java | 62 +++++++------------ 1 file changed, 22 insertions(+), 40 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index cc62c53b12d7..bb41b2730946 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -6,9 +6,11 @@ import com.github.javaparser.ast.ImportDeclaration; import com.github.javaparser.ast.Node; import com.github.javaparser.ast.body.AnnotationDeclaration; +import com.github.javaparser.ast.body.BodyDeclaration; import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; import com.github.javaparser.ast.body.EnumConstantDeclaration; import com.github.javaparser.ast.body.EnumDeclaration; +import com.github.javaparser.ast.body.Parameter; import com.github.javaparser.ast.body.RecordDeclaration; import com.github.javaparser.ast.body.TypeDeclaration; import com.github.javaparser.ast.expr.AnnotationExpr; @@ -17,7 +19,9 @@ import com.github.javaparser.ast.expr.ObjectCreationExpr; import com.github.javaparser.ast.expr.StringLiteralExpr; import com.github.javaparser.ast.nodeTypes.NodeWithTypeParameters; +import com.github.javaparser.ast.stmt.BlockStmt; import com.github.javaparser.ast.stmt.Statement; +import com.github.javaparser.ast.stmt.SwitchEntry; import com.github.javaparser.ast.type.ArrayType; import com.github.javaparser.ast.type.ClassOrInterfaceType; import com.github.javaparser.ast.type.PrimitiveType; @@ -134,7 +138,10 @@ private JavaParserUtil() { } } - if (declaresLocalType(ancestor, firstComponent, child)) { + // A local type declaration is a block statement, so only a block or a switch entry can + // directly contain one. + if ((ancestor instanceof BlockStmt || ancestor instanceof SwitchEntry) + && declaresLocalType(ancestor, firstComponent, child)) { // `name` names a local class, or is nested within one. A local class shadows any type of // the same name, and `Elements` cannot look up a local class by name. return null; @@ -179,7 +186,7 @@ && declaresMemberType(enumConstant.getClassBody(), firstComponent)) { // The class's member types, declared and inherited, are in scope only in its body, and // not in its annotations, its type parameter section, or its supertype names. Testing // `child` also prevents infinite recursion on the recursive call below. - && containsSame(scopeOfMemberTypes(enclosingType), child)) { + && inScopeOfMemberTypes(child)) { String enclosingName = nameableFullyQualifiedName(enclosingType); if (enclosingName != null) { TypeElement result = getTypeElement(elements, enclosingName + "." + name, cache); @@ -433,7 +440,8 @@ private static List supertypes(TypeDeclaration typeDecl return result; } if (typeDecl instanceof RecordDeclaration recordDecl) { - return recordDecl.getImplementedTypes(); + // Copy the list rather than side-effecting the AST by adding to it. + return new ArrayList<>(recordDecl.getImplementedTypes()); } // An annotation declaration's only supertype is `java.lang.annotation.Annotation`. return Collections.emptyList(); @@ -452,28 +460,19 @@ private static ClassOrInterfaceType javaLangEnum() { } /** - * Returns the children of the given type declaration in which its member types are in scope: its - * body declarations, an enum's constants, and a record's components. The result does not include - * a child in which they are not in scope: an annotation on the declaration, a type parameter, a - * supertype name, or a permitted subtype name. + * Returns true if the member types of a type declaration are in scope in the given child of it: + * one of its body declarations, an enum's constant, or a record's component. They are not in + * scope in its other children: an annotation on the declaration, a type parameter, a supertype + * name, or a permitted subtype name. * - * @param typeDecl a JavaParser type declaration - * @return the children of {@code typeDecl} in which its member types are in scope + * @param child a child of a JavaParser type declaration + * @return true if the type declaration's member types are in scope in {@code child} */ - private static List scopeOfMemberTypes(TypeDeclaration typeDecl) { - if (typeDecl instanceof EnumDeclaration enumDecl) { - // A member type is in scope in a constant's arguments and in its class body. - List result = new ArrayList<>(enumDecl.getMembers()); - result.addAll(enumDecl.getEntries()); - return result; - } - if (typeDecl instanceof RecordDeclaration recordDecl) { - // A member type is in scope in the type of a record component. - List result = new ArrayList<>(recordDecl.getMembers()); - result.addAll(recordDecl.getParameters()); - return result; - } - return typeDecl.getMembers(); + private static boolean inScopeOfMemberTypes(Node child) { + // A member is a body declaration, and so is an enum constant (whose arguments and class body + // are both in the scope of the member types). A record's component is a `Parameter`; a type + // declaration has no other child of that type. + return child instanceof BodyDeclaration || child instanceof Parameter; } /** @@ -622,23 +621,6 @@ public static TypeKind typeKindForPrimitive(PrimitiveType primitiveType) { }; } - /** - * Returns the TypeMirror for the given JavaParser type, or null if it cannot be determined. It - * cannot be determined for an intersection type, a union type, {@code var}, a wildcard, a type - * parameter declaration, or a type that is not on the classpath. - * - *

A client that converts many types should call {@link #typeToTypeMirror(Elements, Types, - * Type, Map)}, which memoizes the name lookups. - * - * @param elements used for looking up names - * @param types used for creating types - * @param type a JavaParser type - * @return the TypeMirror for {@code type}, or null if it cannot be determined - */ - public static @Nullable TypeMirror typeToTypeMirror(Elements elements, Types types, Type type) { - return typeToTypeMirror(elements, types, type, new HashMap<>()); - } - /** * Returns the TypeMirror for the given JavaParser type, or null if it cannot be determined. It * cannot be determined for an intersection type, a union type, {@code var}, a wildcard, a type From c4c31d5cd80c3bfab7ba1210674d0f77f6bcf6f9 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Thu, 17 Sep 2026 12:40:12 -0700 Subject: [PATCH 34/58] Respect declaration order when resolving a local type name The scope of a local class declaration is the rest of the block that contains it, including the declaration itself (JLS 6.3). A use of the same name that appears earlier in the block is not in that scope; it refers to some other type. `declaresLocalType` scanned every statement of the block, so it treated the local type as shadowing the earlier use, and `resolveTypeName` returned null for a name that it could have resolved. It now scans only the statements up to and including the one that contains the use. Co-Authored-By: Claude Opus 5 --- .../LocalTypeDeclarationOrder.ajava.goal | 52 +++++++++++++++++++ .../LocalTypeDeclarationOrder.java | 48 +++++++++++++++++ .../framework/util/JavaParserUtil.java | 24 ++++++--- 3 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 checker/tests/ainfer-relevance/LocalTypeDeclarationOrder.ajava.goal create mode 100644 checker/tests/ainfer-relevance/non-annotated/LocalTypeDeclarationOrder.java diff --git a/checker/tests/ainfer-relevance/LocalTypeDeclarationOrder.ajava.goal b/checker/tests/ainfer-relevance/LocalTypeDeclarationOrder.ajava.goal new file mode 100644 index 000000000000..1f0bc1cc3b45 --- /dev/null +++ b/checker/tests/ainfer-relevance/LocalTypeDeclarationOrder.ajava.goal @@ -0,0 +1,52 @@ +// The scope of a local class declaration is the rest of the block that contains it, including the +// declaration itself. A use of the same name that appears earlier in the block is not in that +// scope; it refers to some other type. The name "Foo" in `UseBefore` refers to the member class +// `LocalTypeDeclarationOrder.Foo`, which is irrelevant because it is not a subtype of +// `CharSequence`, so inference must not write an annotation on it. The name "Foo" in `UseAfter` +// refers to the local class, which `Elements` cannot look up by name, so inference conservatively +// writes the annotation. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class LocalTypeDeclarationOrder { + + static class Foo { + } + + @org.checkerframework.dataflow.qual.SideEffectFree + static void declarationOrder() { + class UseBefore { + + Foo field; + + @org.checkerframework.dataflow.qual.Impure + void assignField() { + field = new Foo(); + } + } + class Foo implements CharSequence { + + @org.checkerframework.dataflow.qual.Pure + public @org.checkerframework.checker.testchecker.ainfer.qual.AinferBottom int length(@org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType Foo this) { + return 0; + } + + @org.checkerframework.dataflow.qual.Pure + public char charAt(@org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType Foo this, @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType int index) { + return ' '; + } + + @org.checkerframework.dataflow.qual.Pure + public @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType CharSequence subSequence(@org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType Foo this, @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType int start, @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType int end) { + return this; + } + } + class UseAfter { + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferDefaultType Foo field; + + @org.checkerframework.dataflow.qual.Impure + void assignField() { + field = new Foo(); + } + } + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/LocalTypeDeclarationOrder.java b/checker/tests/ainfer-relevance/non-annotated/LocalTypeDeclarationOrder.java new file mode 100644 index 000000000000..408bc4b27c3d --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/LocalTypeDeclarationOrder.java @@ -0,0 +1,48 @@ +// The scope of a local class declaration is the rest of the block that contains it, including the +// declaration itself. A use of the same name that appears earlier in the block is not in that +// scope; it refers to some other type. The name "Foo" in `UseBefore` refers to the member class +// `LocalTypeDeclarationOrder.Foo`, which is irrelevant because it is not a subtype of +// `CharSequence`, so inference must not write an annotation on it. The name "Foo" in `UseAfter` +// refers to the local class, which `Elements` cannot look up by name, so inference conservatively +// writes the annotation. +public class LocalTypeDeclarationOrder { + + static class Foo {} + + static void declarationOrder() { + + class UseBefore { + Foo field; + + void assignField() { + field = new Foo(); + } + } + + class Foo implements CharSequence { + + @Override + public int length() { + return 0; + } + + @Override + public char charAt(int index) { + return ' '; + } + + @Override + public CharSequence subSequence(int start, int end) { + return this; + } + } + + class UseAfter { + Foo field; + + void assignField() { + field = new Foo(); + } + } + } +} diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index e78ee8f68981..729693247139 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -134,7 +134,7 @@ private JavaParserUtil() { } } - if (declaresLocalType(ancestor, firstComponent)) { + if (declaresLocalType(ancestor, firstComponent, child)) { // `name` names a local class, or is nested within one. A local class shadows any type of // the same name, and `Elements` cannot look up a local class by name. return null; @@ -326,25 +326,33 @@ && isInBody(enclosingType, child)) { /** * Returns true if {@code node} directly contains a statement that declares a local class, - * interface, enum, or record whose name is {@code name}. Such a declaration shadows, throughout - * the block that contains it, every type of the same name that is declared elsewhere. + * interface, enum, or record whose name is {@code name}, and that declaration is in scope at + * {@code child}. The scope of a local type declaration is the rest of the block that contains it, + * including the declaration itself; a use that appears earlier in the block refers to some other + * type of the same name. * * @param node a JavaParser node, such as a block * @param name a simple type name - * @return true if {@code node} declares a local type named {@code name} + * @param child the child of {@code node} that contains the use of {@code name} + * @return true if {@code node} declares a local type named {@code name} that is in scope at + * {@code child} */ - private static boolean declaresLocalType(Node node, String name) { - for (Node child : node.getChildNodes()) { - if (child instanceof Statement) { + private static boolean declaresLocalType(Node node, String name, Node child) { + for (Node statement : node.getChildNodes()) { + if (statement instanceof Statement) { // A local type declaration is the only kind of statement whose child is a type // declaration. - for (Node grandchild : child.getChildNodes()) { + for (Node grandchild : statement.getChildNodes()) { if (grandchild instanceof TypeDeclaration localType && localType.getNameAsString().equals(name)) { return true; } } } + if (statement == child) { + // A local type that is declared later in the block is not in scope at `child`. + return false; + } } return false; } From 93b09c57dad7717f7eae9a112cbcd93154ec09d0 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Thu, 17 Sep 2026 12:50:24 -0700 Subject: [PATCH 35/58] Resolve a nested type name whose components are inherited `resolveMemberType` resolved the rest of a nested name by appending it to the qualified name of the type that declares the first component, then looking up the result. `Elements.getTypeElement` finds a type only by its canonical name, so that lookup failed whenever a later component named an inherited member type rather than a declared one. Fall back to resolving the components one at a time, so that each one is searched for in the supertypes of the type that contains it. Co-Authored-By: Claude Opus 5 --- .../framework/util/JavaParserUtil.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index b83b3e6c4e8c..6aaee8e6888e 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -552,7 +552,21 @@ private record SearchedType(TypeElement typeElement, boolean packagePrivateIsInh if (suffix.isEmpty()) { return declared; } - return getTypeElement(elements, declared.getQualifiedName() + suffix, cache); + TypeElement nested = + getTypeElement(elements, declared.getQualifiedName() + suffix, cache); + if (nested != null) { + return nested; + } + // `declared.getQualifiedName() + suffix` is not a canonical name if any component of + // `suffix` names an inherited member type, and `getTypeElement` finds a type only by its + // canonical name. Resolve the components of `suffix` one at a time instead, so that each + // one is searched for in the supertypes of the type that contains it. + int dot = suffix.indexOf('.', 1); + if (dot == -1) { + return resolveMemberType(elements, declared, suffix.substring(1), "", cache); + } + return resolveMemberType( + elements, declared, suffix.substring(1, dot), suffix.substring(dot), cache); } // `declared` is not a member of the type at which the search started, and it hides every // member type of the same name that `currentElement` would otherwise inherit, so do not From ed8181bed4ef1398561250550d5fbf10992bb7a9 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Thu, 17 Sep 2026 13:17:44 -0700 Subject: [PATCH 36/58] Suppress interning warning --- .../org/checkerframework/framework/util/JavaParserUtil.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index 729693247139..746c38948a25 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -349,7 +349,9 @@ private static boolean declaresLocalType(Node node, String name, Node child) { } } } - if (statement == child) { + @SuppressWarnings("interning:not.interned") + boolean sameNode = statement == child; + if (sameNode) { // A local type that is declared later in the block is not in scope at `child`. return false; } From 2ac6b475767a9a2187347694eb9e771bec3464bf Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Thu, 17 Sep 2026 13:32:58 -0700 Subject: [PATCH 37/58] Fix the scope of private and cross-package member types A search for a member type does not always start at the type in whose scope the name appears, so `resolveMemberType` now takes the starting `SearchedType` from its caller rather than assuming that every member type that the starting type declares is in scope. * When a name appears in an anonymous or local class, the search starts at a supertype of that class. The class inherits a package-private member type of the supertype only if the two are in the same package, which is now tested rather than assumed. It never inherits a private one. * When a name appears in a type that can be looked up by name, the search starts at that type, whose private member types are in scope. `isInherited`, now `isMember`, no longer rejects them. Also memoize, under the identity of the AST node, the recursion through the supertypes of enclosing anonymous and local classes. Without it, resolving one name resolves the same supertype name once for every path through the enclosing classes, which is exponential in the nesting depth. Co-Authored-By: Claude Opus 5 --- ...gePrivateNotInheritedUnnameable.ajava.goal | 71 ++++++ .../PrivateMemberTypeInScope.ajava.goal | 26 +++ .../PackagePrivateNotInheritedUnnameable.java | 58 +++++ .../PrivateMemberTypeInScope.java | 21 ++ .../framework/util/JavaParserUtil.java | 205 ++++++++++++++---- 5 files changed, 337 insertions(+), 44 deletions(-) create mode 100644 checker/tests/ainfer-relevance/PackagePrivateNotInheritedUnnameable.ajava.goal create mode 100644 checker/tests/ainfer-relevance/PrivateMemberTypeInScope.ajava.goal create mode 100644 checker/tests/ainfer-relevance/non-annotated/PackagePrivateNotInheritedUnnameable.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/PrivateMemberTypeInScope.java diff --git a/checker/tests/ainfer-relevance/PackagePrivateNotInheritedUnnameable.ajava.goal b/checker/tests/ainfer-relevance/PackagePrivateNotInheritedUnnameable.ajava.goal new file mode 100644 index 000000000000..461ccd831fff --- /dev/null +++ b/checker/tests/ainfer-relevance/PackagePrivateNotInheritedUnnameable.ajava.goal @@ -0,0 +1,71 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// Like `PackagePrivateNotInherited.java`, but the subclass of `otherpackage.OtherPackageSuperclass` +// is an anonymous class and a local class, neither of which has a name that inference can look up. +// Inference must search the supertype, but it must not conclude that the unnameable class inherits +// the supertype's package-private member type `otherpackage.OtherPackageSuperclass.List`, because +// this compilation unit is in the unnamed package. Nor does the unnameable class inherit +// `otherpackage.OtherPackageGrandparent.List`, which `OtherPackageSuperclass` hides. Inference +// must therefore resolve the name "List" to the top-level class `List` in the unnamed package, +// which is relevant because it is a subtype of `CharSequence`; both member classes named `List` +// are irrelevant. If inference resolves the name incorrectly and therefore discards the +// annotation, then the second (validation) pass of this test issues the warnings that are written +// below. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class PackagePrivateNotInheritedUnnameable { + + static @org.checkerframework.checker.testchecker.ainfer.qual.AinferTop Object anonymous = new otherpackage.OtherPackageSuperclass() { + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 List field; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "this.field" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + void assignField() { + field = getSibling1(); + } + + @org.checkerframework.dataflow.qual.Impure + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + void expectsSibling1(List l) { + } + + @org.checkerframework.dataflow.qual.Pure + List getSibling1() { + return null; + } + }; + + @org.checkerframework.dataflow.qual.SideEffectFree + static void declareLocalClass() { + class Local extends otherpackage.OtherPackageSuperclass { + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 List field; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "this.field" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + void assignField() { + field = getSibling1(); + } + + @org.checkerframework.dataflow.qual.Impure + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + void expectsSibling1(@org.checkerframework.checker.testchecker.ainfer.qual.AinferTop Local this, List l) { + } + + @org.checkerframework.dataflow.qual.Pure + List getSibling1(@org.checkerframework.checker.testchecker.ainfer.qual.AinferTop Local this) { + return null; + } + } + } +} diff --git a/checker/tests/ainfer-relevance/PrivateMemberTypeInScope.ajava.goal b/checker/tests/ainfer-relevance/PrivateMemberTypeInScope.ajava.goal new file mode 100644 index 000000000000..3d386d662418 --- /dev/null +++ b/checker/tests/ainfer-relevance/PrivateMemberTypeInScope.ajava.goal @@ -0,0 +1,26 @@ +// A class's private member type is a member of that class, even though no other class inherits it, +// so a name in the class's body can refer to it. Inference must resolve the name +// "Private.Inherited" to `PrivateMemberTypeInScope.Base.Inherited`, which is irrelevant, and must +// therefore omit the annotation that it infers for `field`; the goal file shows that it does. The +// name is not canonical, because `Private` inherits `Inherited` rather than declaring it, so +// inference must search `Private`'s supertypes for `Inherited`. If inference cannot resolve the +// name, then it conservatively writes the annotation. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class PrivateMemberTypeInScope { + + static class Base { + + static class Inherited { + } + } + + private static class Private extends Base { + } + + static Private.Inherited field; + + @org.checkerframework.dataflow.qual.Impure + static void assignField() { + field = new Private.Inherited(); + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/PackagePrivateNotInheritedUnnameable.java b/checker/tests/ainfer-relevance/non-annotated/PackagePrivateNotInheritedUnnameable.java new file mode 100644 index 000000000000..1096510c87d5 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/PackagePrivateNotInheritedUnnameable.java @@ -0,0 +1,58 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// Like `PackagePrivateNotInherited.java`, but the subclass of `otherpackage.OtherPackageSuperclass` +// is an anonymous class and a local class, neither of which has a name that inference can look up. +// Inference must search the supertype, but it must not conclude that the unnameable class inherits +// the supertype's package-private member type `otherpackage.OtherPackageSuperclass.List`, because +// this compilation unit is in the unnamed package. Nor does the unnameable class inherit +// `otherpackage.OtherPackageGrandparent.List`, which `OtherPackageSuperclass` hides. Inference +// must therefore resolve the name "List" to the top-level class `List` in the unnamed package, +// which is relevant because it is a subtype of `CharSequence`; both member classes named `List` +// are irrelevant. If inference resolves the name incorrectly and therefore discards the +// annotation, then the second (validation) pass of this test issues the warnings that are written +// below. +public class PackagePrivateNotInheritedUnnameable { + + static Object anonymous = + new otherpackage.OtherPackageSuperclass() { + + List field; + + void assignField() { + field = getSibling1(); + } + + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + void expectsSibling1(@AinferSibling1 List l) {} + + @AinferSibling1 List getSibling1() { + return null; + } + }; + + static void declareLocalClass() { + class Local extends otherpackage.OtherPackageSuperclass { + + List field; + + void assignField() { + field = getSibling1(); + } + + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + void expectsSibling1(@AinferSibling1 List l) {} + + @AinferSibling1 List getSibling1() { + return null; + } + } + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/PrivateMemberTypeInScope.java b/checker/tests/ainfer-relevance/non-annotated/PrivateMemberTypeInScope.java new file mode 100644 index 000000000000..230355170e33 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/PrivateMemberTypeInScope.java @@ -0,0 +1,21 @@ +// A class's private member type is a member of that class, even though no other class inherits it, +// so a name in the class's body can refer to it. Inference must resolve the name +// "Private.Inherited" to `PrivateMemberTypeInScope.Base.Inherited`, which is irrelevant, and must +// therefore omit the annotation that it infers for `field`; the goal file shows that it does. The +// name is not canonical, because `Private` inherits `Inherited` rather than declaring it, so +// inference must search `Private`'s supertypes for `Inherited`. If inference cannot resolve the +// name, then it conservatively writes the annotation. +public class PrivateMemberTypeInScope { + + static class Base { + static class Inherited {} + } + + private static class Private extends Base {} + + static Private.Inherited field; + + static void assignField() { + field = new Private.Inherited(); + } +} diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index 6aaee8e6888e..d4e7add04b75 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -31,6 +31,7 @@ import java.util.Deque; import java.util.HashMap; import java.util.HashSet; +import java.util.IdentityHashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -97,6 +98,26 @@ private JavaParserUtil() { */ public static @Nullable TypeElement resolveTypeName( Elements elements, ClassOrInterfaceType type, Map cache) { + return resolveTypeName(elements, type, cache, new IdentityHashMap<>(4)); + } + + /** + * Returns the element for the given JavaParser type, as {@link #resolveTypeName(Elements, + * ClassOrInterfaceType, Map)} does. + * + * @param elements used for looking up names + * @param type a JavaParser class or interface type + * @param cache maps a name to the type it names, or to null if it names no type; this method both + * reads and writes it + * @param nodeCache memoizes the recursive calls that this method makes; this method both reads + * and writes it + * @return the element for {@code type}, or null if it cannot be determined + */ + private static @Nullable TypeElement resolveTypeName( + Elements elements, + ClassOrInterfaceType type, + Map cache, + IdentityHashMap nodeCache) { String name = type.getNameWithScope(); // `firstComponent` is what a single-type import must import; the rest of `name` names a @@ -112,6 +133,12 @@ private JavaParserUtil() { suffix = name.substring(dotIndex); } + CompilationUnit cu = type.findCompilationUnit().orElse(null); + // The package that contains `type`. The empty string names the unnamed package, which is also + // the fallback when `type` is not in a compilation unit. + String packageName = + cu == null ? "" : cu.getPackageDeclaration().map(pkg -> pkg.getNameAsString()).orElse(""); + // A type parameter, a local class, or a type that is lexically enclosed in a type declaration, // takes precedence over an import, over a type in the same package, over a type in // `java.lang`, and over the interpretation of `name` as a fully-qualified name. @@ -189,7 +216,13 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { // The enclosing type might inherit the member type rather than declare it. TypeElement enclosingElement = getTypeElement(elements, enclosingName, cache); if (enclosingElement != null) { - result = resolveMemberType(elements, enclosingElement, firstComponent, suffix, cache); + result = + resolveMemberType( + elements, + new SearchedType(enclosingElement, true, true), + firstComponent, + suffix, + cache); if (result != null) { return result; } @@ -208,14 +241,25 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { // is inherited from another. TypeElement inherited = null; for (ClassOrInterfaceType supertype : unnameableSupertypes) { - TypeElement supertypeElement = resolveTypeName(elements, supertype, cache); + TypeElement supertypeElement = + resolveTypeNameMemoized(elements, supertype, cache, nodeCache); if (supertypeElement == null) { // The supertype could not be determined, so neither could the member types that the // unnameable class inherits and that might shadow `name`. return null; } TypeElement fromSupertype = - resolveMemberType(elements, supertypeElement, firstComponent, suffix, cache); + resolveMemberType( + elements, + // The unnameable class inherits a package-private member type of + // `supertypeElement` only if the two are in the same package. The unnameable + // class is declared in the compilation unit that contains `type`, so its package + // is `packageName`. + new SearchedType( + supertypeElement, false, inPackage(elements, supertypeElement, packageName)), + firstComponent, + suffix, + cache); if (fromSupertype != null) { if (inherited == null) { inherited = fromSupertype; @@ -232,7 +276,6 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { } } - CompilationUnit cu = type.findCompilationUnit().orElse(null); if (cu == null) { // The name might be fully-qualified. return getTypeElement(elements, name, cache); @@ -259,7 +302,15 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { importedName.substring(0, importedName.length() - firstComponent.length() - 1); TypeElement containerElement = getTypeElement(elements, containerName, cache); if (containerElement != null) { - result = resolveMemberType(elements, containerElement, firstComponent, suffix, cache); + // An import imports only the member types that `containerElement` inherits; the ones + // that it declares have canonical names, which the lookup above already tried. + result = + resolveMemberType( + elements, + new SearchedType(containerElement, false, true), + firstComponent, + suffix, + cache); if (result != null) { return result; } @@ -272,8 +323,7 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { // in `java.lang`. A type in the same package shadows the others, so it is looked up first. A // name in the unnamed package has no prefix. List containerPrefixes = new ArrayList<>(); - containerPrefixes.add( - cu.getPackageDeclaration().map(pkg -> pkg.getNameAsString() + ".").orElse("")); + containerPrefixes.add(packageName.isEmpty() ? "" : packageName + "."); for (ImportDeclaration importDecl : cu.getImports()) { if (importDecl.isAsterisk()) { containerPrefixes.add(importDecl.getNameAsString() + "."); @@ -293,8 +343,15 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { if (importDecl.isAsterisk()) { TypeElement importedElement = getTypeElement(elements, importDecl.getNameAsString(), cache); if (importedElement != null) { + // An import imports only the member types that `importedElement` inherits; the ones that + // it declares have canonical names, which the lookup above already tried. TypeElement result = - resolveMemberType(elements, importedElement, firstComponent, suffix, cache); + resolveMemberType( + elements, + new SearchedType(importedElement, false, true), + firstComponent, + suffix, + cache); if (result != null) { return result; } @@ -308,6 +365,38 @@ && containsSame(scopeOfMemberTypes(enclosingType), child)) { return getTypeElement(elements, name, cache); } + /** + * Returns the element for the given JavaParser type, memoizing the result under the identity of + * the AST node. + * + *

Resolving a name that appears in the body of an anonymous or local class resolves that + * class's supertype names, each of which may itself appear in the body of an anonymous or local + * class. Without memoization, the same supertype name would be resolved once for every path + * through the enclosing classes, which is exponential in the nesting depth. + * + * @param elements used for looking up names + * @param type a JavaParser class or interface type + * @param cache maps a name to the type it names, or to null if it names no type; this method both + * reads and writes it + * @param nodeCache maps an AST node to the type it names, or to null if that cannot be + * determined; this method both reads and writes it. Its keys are compared by reference + * equality, because JavaParser's {@code equals()} is structural but two occurrences of the + * same type name can name different types. + * @return the element for {@code type}, or null if it cannot be determined + */ + private static @Nullable TypeElement resolveTypeNameMemoized( + Elements elements, + ClassOrInterfaceType type, + Map cache, + IdentityHashMap nodeCache) { + if (nodeCache.containsKey(type)) { + return nodeCache.get(type); + } + TypeElement result = resolveTypeName(elements, type, cache, nodeCache); + nodeCache.put(type, result); + return result; + } + /** * Returns the element for the type that {@code name} names, or null if it names no type. The * result of every lookup, including a lookup that finds no type, is memoized in {@code cache}. @@ -496,28 +585,41 @@ private static boolean containsSame(List nodes, Node node) { } /** - * A type whose member types {@link #resolveMemberType} searches, together with whether the type's - * package-private member types are inherited by the type at which the search started. + * A type whose member types {@link #resolveMemberType} searches, together with which of its + * member types are members at the place where the name is being resolved -- that is, of the type + * in whose scope the name appears, or, for a name that an import declaration resolves, of the + * imported type. + * + *

The search starts at that type itself only when the type can be looked up by name. The + * search for a name that appears in an anonymous or local class starts at a supertype of that + * class instead, and the search for a name that an import declaration resolves starts at the + * imported type. In those cases a member type is a member at the place where the name is being + * resolved only if it is inherited. * * @param typeElement the type whose member types to search - * @param packagePrivateIsInherited true if a package-private member type of {@code typeElement} - * is a member of the type at which the search started + * @param privateIsMember true if a private member type of {@code typeElement} is a member at the + * place where the name is being resolved; true only if the search starts at {@code + * typeElement} itself, because a private member type is inherited by no type + * @param packagePrivateIsMember true if a package-private member type of {@code typeElement} is a + * member at the place where the name is being resolved */ - private record SearchedType(TypeElement typeElement, boolean packagePrivateIsInherited) {} + private record SearchedType( + TypeElement typeElement, boolean privateIsMember, boolean packagePrivateIsMember) {} /** * Returns the element for the member type named {@code firstComponent + suffix} that {@code - * typeElement} declares or inherits, or null if there is no such member type. + * start}'s type declares or inherits, or null if there is no such member type. * - *

{@code typeElement} and its supertypes are searched in breadth-first order, so a member type - * that is declared in a nearer supertype hides one that is declared in a farther supertype. A - * declaration hides whatever its declaring type would otherwise inherit, even if the declaration - * is not itself inherited: a private member type is inherited by no type, and a package-private - * member type is inherited only within its own package. + *

{@code start}'s type and its supertypes are searched in breadth-first order, so a member + * type that is declared in a nearer supertype hides one that is declared in a farther supertype. + * A declaration hides whatever its declaring type would otherwise inherit, even if the + * declaration is not itself inherited: a private member type is inherited by no type, and a + * package-private member type is inherited only within its own package. * * @param elements used for looking up names - * @param typeElement the type whose member types to search - * @param firstComponent the simple name of a member type of {@code typeElement} + * @param start the type whose member types to search, together with which of its member types are + * members at the place where the name is being resolved + * @param firstComponent the simple name of a member type of {@code start}'s type * @param suffix the rest of the type name, which names a type nested within {@code * firstComponent}; it is empty or starts with "." * @param cache maps a name to the type it names, or to null if it names no type; this method both @@ -526,16 +628,14 @@ private record SearchedType(TypeElement typeElement, boolean packagePrivateIsInh */ private static @Nullable TypeElement resolveMemberType( Elements elements, - TypeElement typeElement, + SearchedType start, String firstComponent, String suffix, Map cache) { Set visited = new HashSet<>(); - visited.add(typeElement); + visited.add(start.typeElement()); Deque worklist = new ArrayDeque<>(); - // Every member type that `typeElement` declares is a member of `typeElement`, whatever its - // access modifier is. - worklist.add(new SearchedType(typeElement, true)); + worklist.add(start); while (!worklist.isEmpty()) { SearchedType current = worklist.remove(); TypeElement currentElement = current.typeElement(); @@ -548,7 +648,7 @@ private record SearchedType(TypeElement typeElement, boolean packagePrivateIsInh } } if (declared != null) { - if (isInherited(declared, current.packagePrivateIsInherited())) { + if (isMember(declared, current)) { if (suffix.isEmpty()) { return declared; } @@ -562,11 +662,14 @@ private record SearchedType(TypeElement typeElement, boolean packagePrivateIsInh // canonical name. Resolve the components of `suffix` one at a time instead, so that each // one is searched for in the supertypes of the type that contains it. int dot = suffix.indexOf('.', 1); + // Every member type that `declared` declares is a member of `declared`, whatever its + // access modifier is. + SearchedType nestedStart = new SearchedType(declared, true, true); if (dot == -1) { - return resolveMemberType(elements, declared, suffix.substring(1), "", cache); + return resolveMemberType(elements, nestedStart, suffix.substring(1), "", cache); } return resolveMemberType( - elements, declared, suffix.substring(1, dot), suffix.substring(dot), cache); + elements, nestedStart, suffix.substring(1, dot), suffix.substring(dot), cache); } // `declared` is not a member of the type at which the search started, and it hides every // member type of the same name that `currentElement` would otherwise inherit, so do not @@ -576,12 +679,14 @@ private record SearchedType(TypeElement typeElement, boolean packagePrivateIsInh for (TypeElement supertype : ElementUtils.getDirectSuperTypeElements(currentElement, elements)) { if (visited.add(supertype)) { - // `currentElement` inherits a package-private member type of `supertype` only if the two - // types are in the same package. worklist.add( new SearchedType( supertype, - current.packagePrivateIsInherited() + // A private member type is inherited by no type. + false, + // `currentElement` inherits a package-private member type of `supertype` only if + // the two types are in the same package. + current.packagePrivateIsMember() && inSamePackage(elements, supertype, currentElement))); } } @@ -590,25 +695,37 @@ private record SearchedType(TypeElement typeElement, boolean packagePrivateIsInh } /** - * Returns true if {@code member} is a member of the type at which a search by {@link - * #resolveMemberType} started -- that is, if every type between that type and the type that - * declares {@code member} inherits it. - * - * @param member a member type of the type that is currently being searched - * @param packagePrivateIsInherited true if a package-private member type of the type that is - * currently being searched is a member of the type at which the search started - * @return true if {@code member} is a member of the type at which the search started + * Returns true if {@code member} is a member at the place where {@link #resolveMemberType} is + * resolving a name -- that is, if every type between {@code searchedType}'s type and the type + * that declares {@code member} inherits it, and {@code searchedType}'s type either declares + * {@code member} or inherits it. + * + * @param member a member type that {@code searchedType}'s type declares + * @param searchedType the type that is currently being searched + * @return true if {@code member} is a member at the place where the name is being resolved */ - private static boolean isInherited(TypeElement member, boolean packagePrivateIsInherited) { + private static boolean isMember(TypeElement member, SearchedType searchedType) { Set modifiers = member.getModifiers(); if (modifiers.contains(Modifier.PRIVATE)) { - // A private member type is not inherited. - return false; + return searchedType.privateIsMember(); } if (modifiers.contains(Modifier.PUBLIC) || modifiers.contains(Modifier.PROTECTED)) { return true; } - return packagePrivateIsInherited; + return searchedType.packagePrivateIsMember(); + } + + /** + * Returns true if the given type is declared in the given package. + * + * @param elements used for looking up the package that contains a type + * @param type a type + * @param packageName the fully-qualified name of a package; the empty string names the unnamed + * package + * @return true if {@code type} is declared in the package named {@code packageName} + */ + private static boolean inPackage(Elements elements, TypeElement type, String packageName) { + return elements.getPackageOf(type).getQualifiedName().contentEquals(packageName); } /** From 7fbb028a2b63e0b6ee26180bf4823a162d1bd670 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Thu, 17 Sep 2026 14:00:29 -0700 Subject: [PATCH 38/58] Restore the type-variable upper bound when deciding relevance Commit 78c8c264 ("Use a type variable's upper bound when deciding relevance") resolved a use of a type variable to the type variable's upper bound, in a private `typeToTypeMirror` in `WholeProgramInferenceJavaParserStorage`. Commit 912e03db ("Move code into utility class") moved an older copy of that method to `JavaParserUtil`, and the merge of the two kept the moved copy, so the type variable handling was lost: `typeToTypeMirror` returned null for a use of a type variable, and `typeIsRelevant` conservatively retained the annotation. Restore the behavior as `JavaParserUtil.typeToTypeMirrorErasingTypeVariables`, alongside `resolveTypeVariableName`, which it uses. `typeToTypeMirror` keeps its meaning: it returns null for a use of a type variable. Co-Authored-By: Claude Opus 5 --- ...holeProgramInferenceJavaParserStorage.java | 12 ++- .../framework/util/JavaParserUtil.java | 83 +++++++++++++++++++ 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java index 6c55f2fdff2c..2e9307e29b19 100644 --- a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java +++ b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java @@ -1183,8 +1183,9 @@ boolean annotationIsRelevant(AnnotationExpr anno) { if (parentNode instanceof Type type) { // JavaParser's `TypeParameter` is a `Type`, so an annotation on a type parameter - // declaration, as in `<@Anno T>`, takes this branch. `JavaParserUtil.typeToTypeMirror` - // returns null for a type parameter declaration, so such an annotation is retained. + // declaration, as in `<@Anno T>`, takes this branch. + // `JavaParserUtil.typeToTypeMirrorErasingTypeVariables` returns null for a type parameter + // declaration, so such an annotation is retained. return typeIsRelevant(gatf, type); } if (parentNode instanceof ArrayCreationLevel level) { @@ -1256,6 +1257,10 @@ private boolean typeIsRelevant(GenericAnnotatedTypeFactory gatf, Typ * type is {@code componentType} wrapped in {@code arrayLevels} array levels. This implementation * is conservative and only returns false if such a qualifier is definitely not relevant. * + *

A use of a type variable is resolved to the type variable's upper bound, which is sound + * because {@link GenericAnnotatedTypeFactory#isRelevant} treats a type variable as relevant + * exactly when its upper bound is. + * * @param gatf the type factory associated with this * @param componentType a JavaParser type * @param arrayLevels the number of array levels to wrap {@code componentType} in; may be 0 @@ -1265,7 +1270,8 @@ private boolean typeIsRelevant( GenericAnnotatedTypeFactory gatf, Type componentType, int arrayLevels) { Types types = atypeFactory.getProcessingEnv().getTypeUtils(); TypeMirror tm = - JavaParserUtil.typeToTypeMirror(elements, types, componentType, typeElementCache); + JavaParserUtil.typeToTypeMirrorErasingTypeVariables( + elements, types, componentType, typeElementCache); if (tm == null) { // The type could not be determined. Be conservative. return true; diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index d72766d39824..d1d90bd6ff22 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -886,6 +886,89 @@ public static TypeKind typeKindForPrimitive(PrimitiveType primitiveType) { return null; } + /** + * Returns the TypeMirror for the given JavaParser type, except that a use of a type variable + * yields the TypeMirror for the type variable's upper bound. For example, given the declaration + * {@code class C}, this method returns {@code java.lang.Number} for the type + * {@code T} and {@code java.lang.Number[]} for the type {@code T[]}. + * + *

The upper bound stands in for the type variable itself, which has no TypeMirror here, + * because a type variable has no {@code TypeElement} that {@code Elements} can look up. A caller + * that must distinguish a type variable from its bound should call {@link + * #resolveTypeVariableName(Elements, ClassOrInterfaceType, Map)}. + * + *

Returns null if the TypeMirror cannot be determined. In addition to the types for which + * {@link #typeToTypeMirror(Elements, Types, Type, Map)} returns null, that is a type variable + * whose upper bound is an intersection type, which {@code Types} cannot create. + * + * @param elements used for looking up names + * @param types used for creating types + * @param type a JavaParser type + * @param cache maps a name to the type it names, or to null if it names no type; this method both + * reads and writes it. See {@link #resolveTypeName(Elements, ClassOrInterfaceType, Map)} for + * restrictions on it. + * @return the TypeMirror for {@code type}, with a type variable replaced by its upper bound, or + * null if it cannot be determined + */ + public static @Nullable TypeMirror typeToTypeMirrorErasingTypeVariables( + Elements elements, Types types, Type type, Map cache) { + if (type instanceof ArrayType arrayType) { + TypeMirror componentType = + typeToTypeMirrorErasingTypeVariables( + elements, types, arrayType.getComponentType(), cache); + return componentType == null ? null : types.getArrayType(componentType); + } + if (type instanceof ClassOrInterfaceType classType) { + ResolvedName resolved = resolveName(elements, classType, cache); + TypeElement typeElt = resolved.typeElement(); + if (typeElt != null) { + return typeElt.asType(); + } + TypeParameter typeParameter = resolved.typeParameter(); + if (typeParameter == null) { + return null; + } + if (classType.getNameWithScope().indexOf('.') != -1) { + // A type variable has no member types, so a qualified name does not name a type variable; + // `resolveName` resolved only the name's first component. + return null; + } + return typeVariableUpperBound(elements, types, typeParameter, cache); + } + return typeToTypeMirror(elements, types, type, cache); + } + + /** + * Returns the TypeMirror for the upper bound of the given type variable declaration, or null if + * the upper bound cannot be determined. + * + * @param elements used for looking up names + * @param types used for creating types + * @param typeParameter the declaration of a type variable + * @param cache maps a name to the type it names, or to null if it names no type; this method both + * reads and writes it + * @return the TypeMirror for the upper bound of {@code typeParameter}, or null + */ + private static @Nullable TypeMirror typeVariableUpperBound( + Elements elements, + Types types, + TypeParameter typeParameter, + Map cache) { + List bounds = typeParameter.getTypeBound(); + if (bounds.isEmpty()) { + // The implicit upper bound is `Object`. + TypeElement objectElt = elements.getTypeElement("java.lang.Object"); + return objectElt == null ? null : objectElt.asType(); + } + if (bounds.size() > 1) { + // The upper bound is an intersection type, which `Types` cannot create. Be conservative. + return null; + } + // The bound may itself be a type variable, as in ``; the + // recursion terminates because Java forbids a cycle among type variable bounds. + return typeToTypeMirrorErasingTypeVariables(elements, types, bounds.get(0), cache); + } + // // Perform side effects // From e6a6d51cb4d08824268f739da9eeccf5ef638da0 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Thu, 17 Sep 2026 14:53:53 -0700 Subject: [PATCH 39/58] Search supertypes when a name's first component names a declared member type The scope walk searches a type declaration's declared member types before its type parameters, because a declared member type shadows a type parameter of the same name. That search looked up `enclosingName + "." + name` directly, which finds nothing when a later component of `name` is inherited rather than declared, as in `Private.Inherited` where `Private extends Base` and `Base` declares `Inherited`. Use `resolveMemberType`, which resolves the components of the suffix one at a time and searches each one's supertypes. Co-Authored-By: Claude Opus 5 --- .../framework/util/JavaParserUtil.java | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index 72529129c82f..cac682f0d2ab 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -270,10 +270,22 @@ && inScopeOfMemberTypes(child)) { // A member type of an unnameable class has no name that `Elements` can look up. return ResolvedName.NONE; } + TypeElement enclosingElement = getTypeElement(elements, enclosingName, cache); + if (enclosingElement == null) { + return ResolvedName.NONE; + } // If `name` has a suffix, then the suffix names a type that is nested within the member // type. If there is no such type, then `name` names nothing, because the member type // shadows every other type whose name starts with `firstComponent`. - return ResolvedName.of(getTypeElement(elements, enclosingName + "." + name, cache)); + return ResolvedName.of( + resolveMemberType( + elements, + // Every member type that `enclosingElement` declares is a member of it, whatever + // its access modifier is. + new SearchedType(enclosingElement, true, true), + firstComponent, + suffix, + cache)); } if (ancestor instanceof NodeWithTypeParameters genericDeclaration) { From ba93bda17fc485537cf532e3edfcd4179c55f79f Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Thu, 17 Sep 2026 14:54:03 -0700 Subject: [PATCH 40/58] Restore resolving a type variable to its upper bound when deciding relevance Commit 692d7a18f5 made a use of a type variable resolve to the type variable's upper bound, so that inference does not write an annotation on a use of a type variable whose bound is irrelevant. Merge 574c20b0c4 moved `typeToTypeMirror` into `JavaParserUtil` and dropped that behavior, which broke the `IrrelevantTypeVariable` ainfer test. `JavaParserUtil.typeToTypeMirror` keeps its contract of returning the TypeMirror for the type it is given, or null; the substitution of the upper bound, which is sound only for deciding relevance, is done by a wrapper here. Co-Authored-By: Claude Opus 5 --- ...holeProgramInferenceJavaParserStorage.java | 74 +++++++++++++++++-- 1 file changed, 69 insertions(+), 5 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java index 6c55f2fdff2c..3fdc66540120 100644 --- a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java +++ b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java @@ -1183,8 +1183,8 @@ boolean annotationIsRelevant(AnnotationExpr anno) { if (parentNode instanceof Type type) { // JavaParser's `TypeParameter` is a `Type`, so an annotation on a type parameter - // declaration, as in `<@Anno T>`, takes this branch. `JavaParserUtil.typeToTypeMirror` - // returns null for a type parameter declaration, so such an annotation is retained. + // declaration, as in `<@Anno T>`, takes this branch. `typeToTypeMirror` returns null for a + // type parameter declaration, so such an annotation is retained. return typeIsRelevant(gatf, type); } if (parentNode instanceof ArrayCreationLevel level) { @@ -1263,13 +1263,12 @@ private boolean typeIsRelevant(GenericAnnotatedTypeFactory gatf, Typ */ private boolean typeIsRelevant( GenericAnnotatedTypeFactory gatf, Type componentType, int arrayLevels) { - Types types = atypeFactory.getProcessingEnv().getTypeUtils(); - TypeMirror tm = - JavaParserUtil.typeToTypeMirror(elements, types, componentType, typeElementCache); + TypeMirror tm = typeToTypeMirror(componentType); if (tm == null) { // The type could not be determined. Be conservative. return true; } + Types types = atypeFactory.getProcessingEnv().getTypeUtils(); for (int i = 0; i < arrayLevels; i++) { tm = types.getArrayType(tm); } @@ -1303,6 +1302,71 @@ private boolean arrayCreationLevelIsRelevant( return true; } + /** + * Returns the TypeMirror for the given JavaParser type, or null if it cannot be determined. + * + *

This differs from {@link JavaParserUtil#typeToTypeMirror} in that a use of a type variable, + * which has no TypeMirror here, yields the type variable's upper bound. That is sound for + * deciding relevance, because {@code GenericAnnotatedTypeFactory.isRelevant} treats a type + * variable as relevant exactly when its upper bound is. + * + * @param type a JavaParser type + * @return the TypeMirror for {@code type}, or null if it cannot be determined + */ + private @Nullable TypeMirror typeToTypeMirror(Type type) { + Types types = atypeFactory.getProcessingEnv().getTypeUtils(); + TypeMirror result = JavaParserUtil.typeToTypeMirror(elements, types, type, typeElementCache); + if (result != null) { + return result; + } + // The element type of `type` might name a type variable, which has no TypeElement and + // therefore no TypeMirror that `JavaParserUtil.typeToTypeMirror` can return. + if (!(type.getElementType() instanceof ClassOrInterfaceType elementType)) { + return null; + } + TypeMirror bound = typeVariableUpperBound(elementType); + if (bound == null) { + return null; + } + for (int i = type.getArrayLevel(); i > 0; i--) { + bound = types.getArrayType(bound); + } + return bound; + } + + /** + * If the given JavaParser type names a type variable, returns the TypeMirror for the type + * variable's upper bound. Otherwise, or if the upper bound cannot be determined, returns null. + * + * @param type a JavaParser class or interface type + * @return the TypeMirror for the upper bound of the type variable that {@code type} names, or + * null + */ + private @Nullable TypeMirror typeVariableUpperBound(ClassOrInterfaceType type) { + if (type.getNameWithScope().indexOf('.') != -1) { + // A type variable has no member types, so a qualified name does not name a type variable. + return null; + } + TypeParameter typeParameter = + JavaParserUtil.resolveTypeVariableName(elements, type, typeElementCache); + if (typeParameter == null) { + return null; + } + NodeList bounds = typeParameter.getTypeBound(); + if (bounds.isEmpty()) { + // The implicit upper bound is `Object`. + TypeElement objectElt = elements.getTypeElement("java.lang.Object"); + return objectElt == null ? null : objectElt.asType(); + } + if (bounds.size() > 1) { + // The upper bound is an intersection type, which `Types` cannot create. Be conservative. + return null; + } + // The bound may itself be a type variable, as in ``; the + // recursion terminates because Java forbids a cycle among type variable bounds. + return typeToTypeMirror(bounds.get(0)); + } + /** * Returns the relative directory that corresponds to a package name. For example, if {@code * separatorChar} is {@code '/'}, then the package name {@code "org.example"} yields {@code From 8ddf773b8f1817c8ee284763e6bfd18a97d0ffd4 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Thu, 17 Sep 2026 14:59:13 -0700 Subject: [PATCH 41/58] Remove duplicate warning suppression --- .../java/org/checkerframework/framework/util/JavaParserUtil.java | 1 - 1 file changed, 1 deletion(-) diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index 74624ce0539e..e68611575d8d 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -349,7 +349,6 @@ && inScopeOfMemberTypes(child)) { * @return true if {@code node} declares a local type named {@code name} that is in scope at * {@code child} */ - @SuppressWarnings("interning:not.interned") // reference equality of AST nodes private static boolean declaresLocalType(Node node, String name, Node child) { for (Node statement : node.getChildNodes()) { if (statement instanceof Statement) { From 3ca45ef4ca5281a7538f05f6399c6c337f6563bd Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Thu, 17 Sep 2026 15:07:55 -0700 Subject: [PATCH 42/58] Fix bad merge that resurrected the `packagePrivateIsInherited` parameter A merge brought back an obsolete version of `resolveMemberType`'s signature, whose `packagePrivateIsInherited` parameter the `SearchedType` record had replaced. The parameter was unused in the method body, and two of the four call sites did not pass it, so the file did not compile. Also remove `packageNameOf`, which only the resurrected code used. Co-Authored-By: Claude Opus 5 --- .../framework/util/JavaParserUtil.java | 34 ------------------- 1 file changed, 34 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index ce41e99ad627..29b6bc26996e 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -334,8 +334,6 @@ && inScopeOfMemberTypes(child)) { // ended, but it might inherit it. TypeElement enclosingElement = getTypeElement(elements, enclosingName, cache); if (enclosingElement != null) { - // The search starts at `enclosingElement` itself, every one of whose declared member - // types is a member of it. TypeElement result = resolveMemberType( elements, @@ -344,7 +342,6 @@ && inScopeOfMemberTypes(child)) { new SearchedType(enclosingElement, true, true), firstComponent, suffix, - true, cache); if (result != null) { return ResolvedName.of(result); @@ -367,14 +364,6 @@ && inScopeOfMemberTypes(child)) { // unnameable class inherits and that might shadow `name`. return ResolvedName.NONE; } - // The unnameable class inherits a package-private member type of `supertypeElement` only - // if the two types are in the same package. (The unnameable class has no TypeElement, - // so its package is that of the compilation unit that declares it.) - boolean packagePrivateIsInherited = - elements - .getPackageOf(supertypeElement) - .getQualifiedName() - .contentEquals(packageNameOf(type)); TypeElement fromSupertype = resolveMemberType( elements, @@ -386,7 +375,6 @@ && inScopeOfMemberTypes(child)) { supertypeElement, false, inPackage(elements, supertypeElement, packageName)), firstComponent, suffix, - packagePrivateIsInherited, cache); if (fromSupertype != null) { if (inherited == null) { @@ -438,7 +426,6 @@ supertypeElement, false, inPackage(elements, supertypeElement, packageName)), new SearchedType(containerElement, false, true), firstComponent, suffix, - true, cache); if (result != null) { return ResolvedName.of(result); @@ -480,7 +467,6 @@ supertypeElement, false, inPackage(elements, supertypeElement, packageName)), new SearchedType(importedElement, false, true), firstComponent, suffix, - true, cache); if (result != null) { return ResolvedName.of(result); @@ -629,20 +615,6 @@ private static boolean declaresMemberType(List members, String n return typeDecl.getFullyQualifiedName().orElse(null); } - /** - * Returns the name of the package that declares the given JavaParser node, or "" if the node is - * in the unnamed package or is not part of a compilation unit. - * - * @param node a JavaParser node - * @return the name of the package that declares {@code node} - */ - private static String packageNameOf(Node node) { - return node.findCompilationUnit() - .flatMap(CompilationUnit::getPackageDeclaration) - .map(pkg -> pkg.getNameAsString()) - .orElse(""); - } - /** * Returns the supertypes of the given type declaration from which it can inherit a member type: * those that its {@code extends} clause and its {@code implements} clause name, plus the implicit @@ -759,11 +731,6 @@ private record SearchedType( * @param firstComponent the simple name of a member type of {@code start}'s type * @param suffix the rest of the type name, which names a type nested within {@code * firstComponent}; it is empty or starts with "." - * @param packagePrivateIsInherited true if a package-private member type of {@code typeElement} - * is a member of the type on whose behalf the search is being made. This is false when the - * search is made on behalf of a type -- such as a local or an anonymous class, which has no - * {@code TypeElement} -- that is not in the package of {@code typeElement}; it is true when - * the search is made on behalf of {@code typeElement} itself. * @param cache maps a name to the type it names, or to null if it names no type; this method both * reads and writes it * @return the element for the member type, or null if it cannot be determined @@ -773,7 +740,6 @@ private record SearchedType( SearchedType start, String firstComponent, String suffix, - boolean packagePrivateIsInherited, Map cache) { Set visited = new HashSet<>(); visited.add(start.typeElement()); From e1be74bdce0b8981400a7caa203a6c6488e681e0 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Thu, 17 Sep 2026 15:17:56 -0700 Subject: [PATCH 43/58] Do not resolve a type name to an inaccessible package-private member type A subclass does not inherit a package-private member type that is declared in a class in a different package, and such a member type is not accessible at the use site. `resolveMemberType` ignored only private member types, so it could return such a member type, which then took precedence over a single-type import that actually resolves the name. Ignoring the member type also lets the breadth-first search continue, so it hides nothing. A protected member type is still inherited from a different package. `AinferRelevanceAjavaGenerationTest` previously required every test input to be in the unnamed package. It now names a goal file after the class's fully-qualified name, so that a test input may declare a package. Co-Authored-By: Claude Opus 5 --- .../AinferRelevanceAjavaGenerationTest.java | 34 ++++++----- .../PackagePrivateNotInherited.ajava.goal | 57 +++++++++++++++++++ .../PackagePrivateNotInherited.java | 46 +++++++++++++++ .../otherpkg/PackagePrivateMemberTypes.java | 20 +++++++ ...erpkg.PackagePrivateMemberTypes.ajava.goal | 23 ++++++++ .../framework/util/JavaParserUtil.java | 57 ++++++++++++++----- 6 files changed, 205 insertions(+), 32 deletions(-) create mode 100644 checker/tests/ainfer-relevance/PackagePrivateNotInherited.ajava.goal create mode 100644 checker/tests/ainfer-relevance/non-annotated/PackagePrivateNotInherited.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/otherpkg/PackagePrivateMemberTypes.java create mode 100644 checker/tests/ainfer-relevance/otherpkg.PackagePrivateMemberTypes.ajava.goal diff --git a/checker/src/test/java/org/checkerframework/checker/test/junit/ainferrunners/AinferRelevanceAjavaGenerationTest.java b/checker/src/test/java/org/checkerframework/checker/test/junit/ainferrunners/AinferRelevanceAjavaGenerationTest.java index 4b32dd1554ee..be2da26b7505 100644 --- a/checker/src/test/java/org/checkerframework/checker/test/junit/ainferrunners/AinferRelevanceAjavaGenerationTest.java +++ b/checker/src/test/java/org/checkerframework/checker/test/junit/ainferrunners/AinferRelevanceAjavaGenerationTest.java @@ -65,9 +65,9 @@ public static String[] getTestDirs() { "-" + AinferRelevanceTestChecker.class.getCanonicalName() + ".ajava"; /** - * Compares each generated ajava file to its goal file. A goal file is named {@code - * .ajava.goal}. Every generated ajava file must have a goal file and vice versa, so - * that no inference result goes unexamined. + * Compares each generated ajava file to its goal file; see {@link #goalFileFor} for a goal file's + * name. Every generated ajava file must have a goal file and vice versa, so that no inference + * result goes unexamined. * *

Unlike the second (validation) pass of this test, this comparison detects an annotation that * inference wrote even though the annotation is irrelevant where it appears. Such an annotation @@ -140,26 +140,24 @@ private static List generatedAjavaFiles() throws IOException { * Returns the goal file that corresponds to the given generated ajava file. The goal file need * not exist. * + *

Inference writes a class's ajava file into a subdirectory that corresponds to the class's + * package, but the goal files are all in one directory. Therefore, a goal file is named for the + * class's fully-qualified name: {@code .ajava.goal} for a class in the unnamed + * package, and {@code ..ajava.goal} for a class in a named package. + * * @param ajavaFile a generated ajava file * @return the goal file that corresponds to {@code ajavaFile} */ private static Path goalFileFor(Path ajavaFile) { - if (!inferenceOutputDir.equals(ajavaFile.getParent())) { - // Inference writes a class's ajava file into a subdirectory that corresponds to the class's - // package, but the goal files are all in one directory, so a goal file for a class in a - // named package could never match. Every test input is in the unnamed package, so this - // failure means that a new test input declares a package. - Assert.fail( - String.format( - "%s is not directly in %s, so its class is not in the unnamed package. Either put" - + " the test input in the unnamed package, or generalize" - + " AinferRelevanceAjavaGenerationTest to give each goal file a name that" - + " includes the package.%n", - ajavaFile.toAbsolutePath(), inferenceOutputDir.toAbsolutePath())); - } - String ajavaFileName = ajavaFile.getFileName().toString(); + Path relative = inferenceOutputDir.relativize(ajavaFile); + String ajavaFileName = relative.getFileName().toString(); String className = ajavaFileName.substring(0, ajavaFileName.length() - ajavaSuffix.length()); - return goalDir.resolve(className + goalSuffix); + Path packageDir = relative.getParent(); + String qualifiedName = + packageDir == null + ? className + : packageDir.toString().replace(File.separatorChar, '.') + "." + className; + return goalDir.resolve(qualifiedName + goalSuffix); } /** diff --git a/checker/tests/ainfer-relevance/PackagePrivateNotInherited.ajava.goal b/checker/tests/ainfer-relevance/PackagePrivateNotInherited.ajava.goal new file mode 100644 index 000000000000..254bc9607449 --- /dev/null +++ b/checker/tests/ainfer-relevance/PackagePrivateNotInherited.ajava.goal @@ -0,0 +1,57 @@ +import java.nio.CharBuffer; +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; +import otherpkg.PackagePrivateMemberTypes; + +// A subclass does not inherit a package-private member type of a superclass that is declared in a +// different package, so such a member type shadows nothing. Inference must resolve the name +// "CharBuffer" to the single-type import `java.nio.CharBuffer`, which is relevant because it is a +// subtype of `CharSequence`, rather than to `otherpkg.PackagePrivateMemberTypes.CharBuffer`, which +// is package-private, is not inherited, and is irrelevant. +// +// By contrast, a protected member type is inherited even from a different package, so inference +// must resolve the name "Protected" to `otherpkg.PackagePrivateMemberTypes.Protected`, which is +// relevant. +// +// If inference resolves either name incorrectly and therefore discards the annotation, then the +// second (validation) pass of this test issues the warnings that are written below. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class PackagePrivateNotInherited extends PackagePrivateMemberTypes { + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 CharBuffer charBufferField; + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 Protected protectedField; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "this.protectedField" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + void assignFields() { + charBufferField = getSibling1CharBuffer(); + protectedField = getSibling1Protected(); + } + + @org.checkerframework.dataflow.qual.Impure + void useFields() { + // :: warning: [argument] + expectsSibling1CharBuffer(charBufferField); + // :: warning: [argument] + expectsSibling1Protected(protectedField); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + void expectsSibling1CharBuffer(PackagePrivateNotInherited this, CharBuffer b) { + } + + @org.checkerframework.dataflow.qual.SideEffectFree + void expectsSibling1Protected(PackagePrivateNotInherited this, Protected p) { + } + + @org.checkerframework.dataflow.qual.Pure + CharBuffer getSibling1CharBuffer(PackagePrivateNotInherited this) { + return null; + } + + @org.checkerframework.framework.qual.RequiresQualifier(expression = { "this.charBufferField" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Pure + Protected getSibling1Protected(PackagePrivateNotInherited this) { + return null; + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/PackagePrivateNotInherited.java b/checker/tests/ainfer-relevance/non-annotated/PackagePrivateNotInherited.java new file mode 100644 index 000000000000..6ec5ce5fe134 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/PackagePrivateNotInherited.java @@ -0,0 +1,46 @@ +import java.nio.CharBuffer; +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; +import otherpkg.PackagePrivateMemberTypes; + +// A subclass does not inherit a package-private member type of a superclass that is declared in a +// different package, so such a member type shadows nothing. Inference must resolve the name +// "CharBuffer" to the single-type import `java.nio.CharBuffer`, which is relevant because it is a +// subtype of `CharSequence`, rather than to `otherpkg.PackagePrivateMemberTypes.CharBuffer`, which +// is package-private, is not inherited, and is irrelevant. +// +// By contrast, a protected member type is inherited even from a different package, so inference +// must resolve the name "Protected" to `otherpkg.PackagePrivateMemberTypes.Protected`, which is +// relevant. +// +// If inference resolves either name incorrectly and therefore discards the annotation, then the +// second (validation) pass of this test issues the warnings that are written below. +public class PackagePrivateNotInherited extends PackagePrivateMemberTypes { + + CharBuffer charBufferField; + + Protected protectedField; + + void assignFields() { + charBufferField = getSibling1CharBuffer(); + protectedField = getSibling1Protected(); + } + + void useFields() { + // :: warning: [argument] + expectsSibling1CharBuffer(charBufferField); + // :: warning: [argument] + expectsSibling1Protected(protectedField); + } + + void expectsSibling1CharBuffer(@AinferSibling1 CharBuffer b) {} + + void expectsSibling1Protected(@AinferSibling1 Protected p) {} + + @AinferSibling1 CharBuffer getSibling1CharBuffer() { + return null; + } + + @AinferSibling1 Protected getSibling1Protected() { + return null; + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/otherpkg/PackagePrivateMemberTypes.java b/checker/tests/ainfer-relevance/non-annotated/otherpkg/PackagePrivateMemberTypes.java new file mode 100644 index 000000000000..bd1ed02a17a2 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/otherpkg/PackagePrivateMemberTypes.java @@ -0,0 +1,20 @@ +package otherpkg; + +// This class exists so that a class in the unnamed package can extend a class, in a different +// package, that declares member types with different accessibilities. It is used by +// `PackagePrivateNotInherited.java`. +// +// This is the only test input that is in a named package, so its goal file is the only one whose +// name includes a package name. +public class PackagePrivateMemberTypes { + + // A package-private member type is not inherited by a subclass in another package, so the + // simple name "CharBuffer" does not refer to this type in such a subclass. This type is + // irrelevant, because it is not a subtype of any type in the checker's `@RelevantJavaTypes`. + static class CharBuffer {} + + // A protected member type is inherited by a subclass in another package, so the simple name + // "Protected" does refer to this type in such a subclass. This type is relevant, because it is + // a subtype of `CharSequence`, which the checker's `@RelevantJavaTypes` lists. + protected abstract static class Protected implements CharSequence {} +} diff --git a/checker/tests/ainfer-relevance/otherpkg.PackagePrivateMemberTypes.ajava.goal b/checker/tests/ainfer-relevance/otherpkg.PackagePrivateMemberTypes.ajava.goal new file mode 100644 index 000000000000..7511479780e6 --- /dev/null +++ b/checker/tests/ainfer-relevance/otherpkg.PackagePrivateMemberTypes.ajava.goal @@ -0,0 +1,23 @@ +package otherpkg; + +// This class exists so that a class in the unnamed package can extend a class, in a different +// package, that declares member types with different accessibilities. It is used by +// `PackagePrivateNotInherited.java`. +// +// This is the only test input that is in a named package, so its goal file is the only one whose +// name includes a package name. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class PackagePrivateMemberTypes { + + // A package-private member type is not inherited by a subclass in another package, so the + // simple name "CharBuffer" does not refer to this type in such a subclass. This type is + // irrelevant, because it is not a subtype of any type in the checker's `@RelevantJavaTypes`. + static class CharBuffer { + } + + // A protected member type is inherited by a subclass in another package, so the simple name + // "Protected" does refer to this type in such a subclass. This type is relevant, because it is + // a subtype of `CharSequence`, which the checker's `@RelevantJavaTypes` lists. + protected abstract static class Protected implements CharSequence { + } +} diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index e68611575d8d..fb13cd8088b0 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -116,6 +116,13 @@ private JavaParserUtil() { suffix = name.substring(dotIndex); } + CompilationUnit cu = type.findCompilationUnit().orElse(null); + // The package that contains the use of `name`. A use in the unnamed package, or in a node + // that is not part of a compilation unit, has no package name. Accessibility of a + // package-private member type depends on this package. + String usePackage = + cu == null ? "" : cu.getPackageDeclaration().map(pkg -> pkg.getNameAsString()).orElse(""); + // A type parameter, a local class, or a type that is lexically enclosed in a type declaration, // takes precedence over an import, over a type in the same package, over a type in // `java.lang`, and over the interpretation of `name` as a fully-qualified name. @@ -196,7 +203,9 @@ && inScopeOfMemberTypes(child)) { // The enclosing type might inherit the member type rather than declare it. TypeElement enclosingElement = getTypeElement(elements, enclosingName, cache); if (enclosingElement != null) { - result = resolveMemberType(elements, enclosingElement, firstComponent, suffix, cache); + result = + resolveMemberType( + elements, enclosingElement, firstComponent, suffix, usePackage, cache); if (result != null) { return result; } @@ -222,7 +231,8 @@ && inScopeOfMemberTypes(child)) { return null; } TypeElement fromSupertype = - resolveMemberType(elements, supertypeElement, firstComponent, suffix, cache); + resolveMemberType( + elements, supertypeElement, firstComponent, suffix, usePackage, cache); if (fromSupertype != null) { if (inherited == null) { inherited = fromSupertype; @@ -239,7 +249,6 @@ && inScopeOfMemberTypes(child)) { } } - CompilationUnit cu = type.findCompilationUnit().orElse(null); if (cu == null) { // The name might be fully-qualified. return getTypeElement(elements, name, cache); @@ -266,7 +275,9 @@ && inScopeOfMemberTypes(child)) { importedName.substring(0, importedName.length() - firstComponent.length() - 1); TypeElement containerElement = getTypeElement(elements, containerName, cache); if (containerElement != null) { - result = resolveMemberType(elements, containerElement, firstComponent, suffix, cache); + result = + resolveMemberType( + elements, containerElement, firstComponent, suffix, usePackage, cache); if (result != null) { return result; } @@ -279,8 +290,7 @@ && inScopeOfMemberTypes(child)) { // in `java.lang`. A type in the same package shadows the others, so it is looked up first. A // name in the unnamed package has no prefix. List containerPrefixes = new ArrayList<>(); - containerPrefixes.add( - cu.getPackageDeclaration().map(pkg -> pkg.getNameAsString() + ".").orElse("")); + containerPrefixes.add(usePackage.isEmpty() ? "" : usePackage + "."); for (ImportDeclaration importDecl : cu.getImports()) { if (importDecl.isAsterisk()) { containerPrefixes.add(importDecl.getNameAsString() + "."); @@ -301,7 +311,8 @@ && inScopeOfMemberTypes(child)) { TypeElement importedElement = getTypeElement(elements, importDecl.getNameAsString(), cache); if (importedElement != null) { TypeElement result = - resolveMemberType(elements, importedElement, firstComponent, suffix, cache); + resolveMemberType( + elements, importedElement, firstComponent, suffix, usePackage, cache); if (result != null) { return result; } @@ -501,11 +512,17 @@ private static boolean containsSame(List nodes, Node node) { *

{@code typeElement} and its supertypes are searched in breadth-first order, so a member type * that is declared in a nearer supertype hides one that is declared in a farther supertype. * + *

A member type that is not inherited is ignored, and therefore hides nothing: a private + * member type, and a package-private member type that is declared in a package other than {@code + * usePackage}. A protected member type is inherited even from a different package. + * * @param elements used for looking up names * @param typeElement the type whose member types to search * @param firstComponent the simple name of a member type of {@code typeElement} * @param suffix the rest of the type name, which names a type nested within {@code * firstComponent}; it is empty or starts with "." + * @param usePackage the name of the package that contains the use of the type name, or "" for the + * unnamed package * @param cache maps a name to the type it names, or to null if it names no type; this method both * reads and writes it * @return the element for the member type, or null if it cannot be determined @@ -515,6 +532,7 @@ private static boolean containsSame(List nodes, Node node) { TypeElement typeElement, String firstComponent, String suffix, + String usePackage, Map cache) { Set visited = new HashSet<>(); visited.add(typeElement); @@ -523,14 +541,25 @@ private static boolean containsSame(List nodes, Node node) { while (!worklist.isEmpty()) { TypeElement current = worklist.remove(); for (TypeElement member : ElementFilter.typesIn(current.getEnclosedElements())) { - // A private member type is not inherited. - if (member.getSimpleName().contentEquals(firstComponent) - && !member.getModifiers().contains(Modifier.PRIVATE)) { - if (suffix.isEmpty()) { - return member; - } - return getTypeElement(elements, member.getQualifiedName() + suffix, cache); + if (!member.getSimpleName().contentEquals(firstComponent)) { + continue; + } + Set modifiers = member.getModifiers(); + if (modifiers.contains(Modifier.PRIVATE)) { + // A private member type is not inherited. + continue; + } + if (!modifiers.contains(Modifier.PUBLIC) + && !modifiers.contains(Modifier.PROTECTED) + && !elements.getPackageOf(member).getQualifiedName().contentEquals(usePackage)) { + // A package-private member type is not inherited by a class in another package, and it + // is not accessible at the use site. + continue; + } + if (suffix.isEmpty()) { + return member; } + return getTypeElement(elements, member.getQualifiedName() + suffix, cache); } for (TypeElement supertype : ElementUtils.getDirectSuperTypeElements(current, elements)) { if (visited.add(supertype)) { From 3b9c9f1d9cd2e33294ecef7245b600b189c70e55 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Thu, 17 Sep 2026 15:58:37 -0700 Subject: [PATCH 44/58] Do not resolve an ambiguous type name to an arbitrary member type `resolveMemberType` searches a type and its supertypes in breadth-first order, returning the first member type that it finds. It processed one supertype at a time, so when two supertypes that are the same distance away declare different member types with the name, it returned whichever one the worklist happened to reach first, even though neither one hides the other. It now searches all the supertypes at a given distance before choosing among what they declare, and it returns null -- that is, it is conservative -- if they declare different member types with the name. Co-Authored-By: Claude Opus 5 --- .../AmbiguousInheritedTypes.ajava.goal | 64 ++++++++++++++++ .../AmbiguousInheritedTypes.java | 50 +++++++++++++ .../framework/util/JavaParserUtil.java | 75 ++++++++++++------- 3 files changed, 161 insertions(+), 28 deletions(-) create mode 100644 checker/tests/ainfer-relevance/AmbiguousInheritedTypes.ajava.goal create mode 100644 checker/tests/ainfer-relevance/non-annotated/AmbiguousInheritedTypes.java diff --git a/checker/tests/ainfer-relevance/AmbiguousInheritedTypes.ajava.goal b/checker/tests/ainfer-relevance/AmbiguousInheritedTypes.ajava.goal new file mode 100644 index 000000000000..69b83c2149b5 --- /dev/null +++ b/checker/tests/ainfer-relevance/AmbiguousInheritedTypes.ajava.goal @@ -0,0 +1,64 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A class does not inherit a member type through a supertype that hides it: `Hides.Foo` hides +// `Irrelevant.Foo`, and `Hides.Foo` is private and therefore is not inherited either. Thus, the +// name "Foo" in `Use` refers to `Relevant.Foo`, which is relevant because it is a subtype of +// `CharSequence`. +// +// Inference searches the supertypes of `Use` in breadth-first order, and it does not model hiding +// by a member type that is not inherited. Therefore, it finds both `Irrelevant.Foo` and +// `Relevant.Foo`, which are the same distance from `Use`. Neither one hides the other, so +// inference must not arbitrarily choose one of them; it must be conservative and assume that the +// type might be relevant. If inference instead resolved the name to `Irrelevant.Foo` and +// therefore discarded the annotation, then the second (validation) pass of this test would issue +// the warning that is written below. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class AmbiguousInheritedTypes { + + static class Irrelevant { + + static class Foo { + } + } + + static class Hides extends Irrelevant { + + private static class Foo { + } + } + + interface Relevant { + + abstract class Foo implements CharSequence { + } + } + + interface SubRelevant extends Relevant { + } + + static class Use extends Hides implements SubRelevant { + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 Foo field; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "this.field" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + void assignField() { + field = getSibling1(); + } + + @org.checkerframework.dataflow.qual.Impure + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + void expectsSibling1(Use this, Foo f) { + } + + @org.checkerframework.dataflow.qual.Pure + Foo getSibling1(Use this) { + return null; + } + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/AmbiguousInheritedTypes.java b/checker/tests/ainfer-relevance/non-annotated/AmbiguousInheritedTypes.java new file mode 100644 index 000000000000..7c2010336abf --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/AmbiguousInheritedTypes.java @@ -0,0 +1,50 @@ +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A class does not inherit a member type through a supertype that hides it: `Hides.Foo` hides +// `Irrelevant.Foo`, and `Hides.Foo` is private and therefore is not inherited either. Thus, the +// name "Foo" in `Use` refers to `Relevant.Foo`, which is relevant because it is a subtype of +// `CharSequence`. +// +// Inference searches the supertypes of `Use` in breadth-first order, and it does not model hiding +// by a member type that is not inherited. Therefore, it finds both `Irrelevant.Foo` and +// `Relevant.Foo`, which are the same distance from `Use`. Neither one hides the other, so +// inference must not arbitrarily choose one of them; it must be conservative and assume that the +// type might be relevant. If inference instead resolved the name to `Irrelevant.Foo` and +// therefore discarded the annotation, then the second (validation) pass of this test would issue +// the warning that is written below. +public class AmbiguousInheritedTypes { + + static class Irrelevant { + static class Foo {} + } + + static class Hides extends Irrelevant { + private static class Foo {} + } + + interface Relevant { + abstract class Foo implements CharSequence {} + } + + interface SubRelevant extends Relevant {} + + static class Use extends Hides implements SubRelevant { + + Foo field; + + void assignField() { + field = getSibling1(); + } + + void useField() { + // :: warning: [argument] + expectsSibling1(field); + } + + void expectsSibling1(@AinferSibling1 Foo f) {} + + @AinferSibling1 Foo getSibling1() { + return null; + } + } +} diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index fb13cd8088b0..18abaf646072 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -29,10 +29,8 @@ import com.github.javaparser.ast.type.TypeParameter; import com.github.javaparser.ast.type.VoidType; import com.github.javaparser.ast.visitor.VoidVisitorAdapter; -import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; -import java.util.Deque; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -510,7 +508,9 @@ private static boolean containsSame(List nodes, Node node) { * typeElement} declares or inherits, or null if there is no such member type. * *

{@code typeElement} and its supertypes are searched in breadth-first order, so a member type - * that is declared in a nearer supertype hides one that is declared in a farther supertype. + * that is declared in a nearer supertype hides one that is declared in a farther supertype. If + * two supertypes that are equally near declare different member types with this name, then + * neither hides the other, the name is ambiguous, and this method returns null. * *

A member type that is not inherited is ignored, and therefore hides nothing: a private * member type, and a package-private member type that is declared in a package other than {@code @@ -525,7 +525,7 @@ private static boolean containsSame(List nodes, Node node) { * unnamed package * @param cache maps a name to the type it names, or to null if it names no type; this method both * reads and writes it - * @return the element for the member type, or null if it cannot be determined + * @return the element for the member type, or null if there is none or it cannot be determined */ private static @Nullable TypeElement resolveMemberType( Elements elements, @@ -536,36 +536,55 @@ private static boolean containsSame(List nodes, Node node) { Map cache) { Set visited = new HashSet<>(); visited.add(typeElement); - Deque worklist = new ArrayDeque<>(); - worklist.add(typeElement); - while (!worklist.isEmpty()) { - TypeElement current = worklist.remove(); - for (TypeElement member : ElementFilter.typesIn(current.getEnclosedElements())) { - if (!member.getSimpleName().contentEquals(firstComponent)) { - continue; - } - Set modifiers = member.getModifiers(); - if (modifiers.contains(Modifier.PRIVATE)) { - // A private member type is not inherited. - continue; - } - if (!modifiers.contains(Modifier.PUBLIC) - && !modifiers.contains(Modifier.PROTECTED) - && !elements.getPackageOf(member).getQualifiedName().contentEquals(usePackage)) { - // A package-private member type is not inherited by a class in another package, and it - // is not accessible at the use site. - continue; + // The types that are the same distance from `typeElement`: first `typeElement` itself, then + // its direct supertypes, and so forth. + List currentTypes = Collections.singletonList(typeElement); + while (!currentTypes.isEmpty()) { + // Every type at the current distance is searched, rather than returning the first member + // type that is found, because a member type that is declared in one of them does not hide + // one that is declared in another. + TypeElement found = null; + for (TypeElement current : currentTypes) { + for (TypeElement member : ElementFilter.typesIn(current.getEnclosedElements())) { + if (!member.getSimpleName().contentEquals(firstComponent)) { + continue; + } + Set modifiers = member.getModifiers(); + if (modifiers.contains(Modifier.PRIVATE)) { + // A private member type is not inherited. + continue; + } + if (!modifiers.contains(Modifier.PUBLIC) + && !modifiers.contains(Modifier.PROTECTED) + && !elements.getPackageOf(member).getQualifiedName().contentEquals(usePackage)) { + // A package-private member type is not inherited by a class in another package, and + // it is not accessible at the use site. + continue; + } + if (found == null) { + found = member; + } else if (!found.equals(member)) { + // Two equally near supertypes declare different member types with this name, so the + // name is ambiguous. + return null; + } } + } + if (found != null) { if (suffix.isEmpty()) { - return member; + return found; } - return getTypeElement(elements, member.getQualifiedName() + suffix, cache); + return getTypeElement(elements, found.getQualifiedName() + suffix, cache); } - for (TypeElement supertype : ElementUtils.getDirectSuperTypeElements(current, elements)) { - if (visited.add(supertype)) { - worklist.add(supertype); + List nextTypes = new ArrayList<>(); + for (TypeElement current : currentTypes) { + for (TypeElement supertype : ElementUtils.getDirectSuperTypeElements(current, elements)) { + if (visited.add(supertype)) { + nextTypes.add(supertype); + } } } + currentTypes = nextTypes; } return null; } From f094882cbd170676e32536bc3ac793a287bcf0ce Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Thu, 17 Sep 2026 16:02:35 -0700 Subject: [PATCH 45/58] Resolve a nested name whose first component is a declared member type When a name's first component names a member type that an enclosing class declares, `resolveName` looked the whole name up as if it were canonical. `Elements.getTypeElement` finds a type only by its canonical name, so the lookup failed whenever a later component named a member type that the first component inherits rather than declares. Inference then wrote a conservative annotation on an irrelevant type, which the `PrivateMemberTypeInScope` goal file says it must omit. Fall back to `resolveMemberType`, which resolves the components one at a time and so searches each component's supertypes. Co-Authored-By: Claude Opus 5 --- .../framework/util/JavaParserUtil.java | 22 +++++++++++++++++- .../framework/util/JavaParserUtilTest.java | 23 +++++++++++++++++++ .../javaparserutil/superpkg/MemberOwner.java | 17 ++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/MemberOwner.java diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index 29b6bc26996e..e661d0859f3b 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -273,7 +273,27 @@ && inScopeOfMemberTypes(child)) { // If `name` has a suffix, then the suffix names a type that is nested within the member // type. If there is no such type, then `name` names nothing, because the member type // shadows every other type whose name starts with `firstComponent`. - return ResolvedName.of(getTypeElement(elements, enclosingName + "." + name, cache)); + TypeElement result = getTypeElement(elements, enclosingName + "." + name, cache); + if (result != null || suffix.isEmpty()) { + return ResolvedName.of(result); + } + // `enclosingName + "." + name` is not a canonical name if any component of `suffix` names + // an inherited member type, and `getTypeElement` finds a type only by its canonical name. + // Search `enclosingType`'s member types instead, which resolves the components of `suffix` + // one at a time. + TypeElement enclosingElement = getTypeElement(elements, enclosingName, cache); + if (enclosingElement == null) { + return ResolvedName.NONE; + } + return ResolvedName.of( + resolveMemberType( + elements, + // Every member type that `enclosingElement` declares is a member of it, whatever + // its access modifier is. + new SearchedType(enclosingElement, true, true), + firstComponent, + suffix, + cache)); } if (ancestor instanceof NodeWithTypeParameters genericDeclaration) { diff --git a/framework/src/test/java/org/checkerframework/framework/util/JavaParserUtilTest.java b/framework/src/test/java/org/checkerframework/framework/util/JavaParserUtilTest.java index 3a26210c67bf..2098734ed865 100644 --- a/framework/src/test/java/org/checkerframework/framework/util/JavaParserUtilTest.java +++ b/framework/src/test/java/org/checkerframework/framework/util/JavaParserUtilTest.java @@ -111,6 +111,29 @@ public void testResolveMemberTypeInheritedByUnnameableClass() { assertResolvesTo(SUBPKG + ".Shadowed", SUBPKG, anonymousClass("Shadowed"), "Shadowed"); } + /** + * Tests that {@link JavaParserUtil#resolveTypeName} resolves a nested type name whose first + * component names a member type of an enclosing class and whose later components name inherited + * member types. Such a name is not canonical, so it cannot be looked up directly. + */ + @Test + public void testResolveNestedNameOfDeclaredMemberType() { + String memberOwner = + "class MemberOwner { private static class Member extends Base {}" + + " Member.Visible f; Member.Secret g; }"; + + // The name of the member type itself is canonical. + assertResolvesTo(SUPERPKG + ".MemberOwner.Member", SUPERPKG, memberOwner, "Member"); + + // `Member` inherits `Visible` rather than declaring it, so `Member.Visible` is not canonical + // and the member types of `Member`'s supertypes must be searched. + assertResolvesTo(SUPERPKG + ".Base.Visible", SUPERPKG, memberOwner, "Member.Visible"); + + // A member type of an enclosing class shadows every other type whose name starts with the + // member type's simple name, so a suffix that names no member type of `Member` names nothing. + assertResolvesTo(null, SUPERPKG, memberOwner, "Member.Secret"); + } + /** * Returns the declaration of a class named {@code Outer}, which is not a subtype of {@code Base}, * that declares a local class that extends {@code Base} and that uses the given type name. diff --git a/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/MemberOwner.java b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/MemberOwner.java new file mode 100644 index 000000000000..36413cb7c7fd --- /dev/null +++ b/framework/src/test/java/org/checkerframework/framework/util/javaparserutil/superpkg/MemberOwner.java @@ -0,0 +1,17 @@ +package org.checkerframework.framework.util.javaparserutil.superpkg; + +/** + * Test data for {@code JavaParserUtilTest}: a class that declares a member type that inherits the + * member types of {@link Base}. A name such as {@code Member.Visible} is therefore not canonical. + */ +public class MemberOwner { + + /** Creates a new MemberOwner. */ + public MemberOwner() {} + + /** A private member type, which is in scope in this class's body but in no other class. */ + private static class Member extends Base {} + + /** Uses {@code Member.Visible}, whose name is not canonical. */ + Member.Visible f; +} From cad0da1c1b44c1f1e2be9635eece986563ac3e09 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Thu, 17 Sep 2026 23:31:06 -0700 Subject: [PATCH 46/58] Model hiding by an uninherited member type, and per-package inheritance `resolveMemberType` searched the supertypes of a type that declares a member type with the name being resolved, so an inaccessible declaration -- a private one, or a package-private one in another package -- did not prevent a declaration in a supertype from being found. A member type declaration hides every declaration of the same name in a supertype, even if the declaration is not inherited or is not accessible, so the search now ends at the type that contains such a declaration, and that type contributes no member type of its own. `resolveMemberType` also determined whether a package-private member type is inherited by comparing its package to the package that contains the use. A package-private member type is inherited only by a subclass in the package that declares it, so the comparison now happens at every inheritance edge: the member type is used only if every type from the starting type to the type that declares it is in the package of the use. `AmbiguousInheritedTypes` now asserts the result of Java's name lookup: the name refers to the single member type that the class inherits, which is irrelevant, so inference discards the annotation. A new test, `otherpkg/NotInheritedThroughOtherPackage`, covers a package-private member type that is not inherited because a class in another package is between the declaration and the use. Co-Authored-By: Claude Opus 5 --- .../AmbiguousInheritedTypes.ajava.goal | 46 +++--- .../middlepkg.Middle.ajava.goal | 10 ++ .../AmbiguousInheritedTypes.java | 46 +++--- .../non-annotated/middlepkg/Middle.java | 8 ++ .../NotInheritedThroughOtherPackage.java | 34 +++++ .../otherpkg/PackagePrivateMemberTypes.java | 8 +- ...NotInheritedThroughOtherPackage.ajava.goal | 38 +++++ ...erpkg.PackagePrivateMemberTypes.ajava.goal | 8 +- .../framework/util/JavaParserUtil.java | 136 +++++++++++++----- 9 files changed, 252 insertions(+), 82 deletions(-) create mode 100644 checker/tests/ainfer-relevance/middlepkg.Middle.ajava.goal create mode 100644 checker/tests/ainfer-relevance/non-annotated/middlepkg/Middle.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/otherpkg/NotInheritedThroughOtherPackage.java create mode 100644 checker/tests/ainfer-relevance/otherpkg.NotInheritedThroughOtherPackage.ajava.goal diff --git a/checker/tests/ainfer-relevance/AmbiguousInheritedTypes.ajava.goal b/checker/tests/ainfer-relevance/AmbiguousInheritedTypes.ajava.goal index 69b83c2149b5..cf9cbaf80d06 100644 --- a/checker/tests/ainfer-relevance/AmbiguousInheritedTypes.ajava.goal +++ b/checker/tests/ainfer-relevance/AmbiguousInheritedTypes.ajava.goal @@ -1,44 +1,51 @@ import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; -// A class does not inherit a member type through a supertype that hides it: `Hides.Foo` hides -// `Irrelevant.Foo`, and `Hides.Foo` is private and therefore is not inherited either. Thus, the -// name "Foo" in `Use` refers to `Relevant.Foo`, which is relevant because it is a subtype of -// `CharSequence`. +// A class does not inherit a member type through a supertype that declares a member type with the +// same name, even if that declaration is not itself inherited: `Hides.Foo` hides `Relevant.Foo`, +// and `Hides.Foo` is private and therefore is not inherited by `Use`. Thus, `Use` inherits +// exactly one member type named "Foo", and the name "Foo" in `Use` refers to `Irrelevant.Foo`, +// which is irrelevant because it is not a subtype of any type in the checker's +// `@RelevantJavaTypes`. // -// Inference searches the supertypes of `Use` in breadth-first order, and it does not model hiding -// by a member type that is not inherited. Therefore, it finds both `Irrelevant.Foo` and -// `Relevant.Foo`, which are the same distance from `Use`. Neither one hides the other, so -// inference must not arbitrarily choose one of them; it must be conservative and assume that the -// type might be relevant. If inference instead resolved the name to `Irrelevant.Foo` and -// therefore discarded the annotation, then the second (validation) pass of this test would issue -// the warning that is written below. +// Inference searches the supertypes of `Use` in breadth-first order, and `Relevant` and +// `Irrelevant` are the same distance from `Use`. If inference did not model hiding by a member +// type that is not inherited, then it would find both `Relevant.Foo` and `Irrelevant.Foo`, neither +// of which hides the other, so it would have to be conservative and assume that the type might be +// relevant. +// +// Inference must discard the annotation on `field`, as the goal file shows. If inference instead +// treats the name as ambiguous, or resolves it to `Relevant.Foo`, then it writes the annotation. +// +// Writing `@AinferSibling1` on an `Irrelevant.Foo` is itself a relevance violation, which this +// test suppresses because the warning would appear in both passes of the test and the annotated +// copy of this file contains no expected diagnostics. @org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") public class AmbiguousInheritedTypes { - static class Irrelevant { + static class Relevant { - static class Foo { + abstract static class Foo implements CharSequence { } } - static class Hides extends Irrelevant { + static class Hides extends Relevant { private static class Foo { } } - interface Relevant { + interface Irrelevant { - abstract class Foo implements CharSequence { + class Foo { } } - interface SubRelevant extends Relevant { + interface SubIrrelevant extends Irrelevant { } - static class Use extends Hides implements SubRelevant { + static class Use extends Hides implements SubIrrelevant { - @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 Foo field; + Foo field; @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "this.field" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) @org.checkerframework.dataflow.qual.Impure @@ -48,7 +55,6 @@ public class AmbiguousInheritedTypes { @org.checkerframework.dataflow.qual.Impure void useField() { - // :: warning: [argument] expectsSibling1(field); } diff --git a/checker/tests/ainfer-relevance/middlepkg.Middle.ajava.goal b/checker/tests/ainfer-relevance/middlepkg.Middle.ajava.goal new file mode 100644 index 000000000000..4b3a98aa8e04 --- /dev/null +++ b/checker/tests/ainfer-relevance/middlepkg.Middle.ajava.goal @@ -0,0 +1,10 @@ +package middlepkg; + +import otherpkg.PackagePrivateMemberTypes; + +// This class exists so that a class in package `otherpkg` can extend, indirectly, another class in +// package `otherpkg`, with a class in a different package between them. It is used by +// `otherpkg/NotInheritedThroughOtherPackage.java`. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class Middle extends PackagePrivateMemberTypes { +} diff --git a/checker/tests/ainfer-relevance/non-annotated/AmbiguousInheritedTypes.java b/checker/tests/ainfer-relevance/non-annotated/AmbiguousInheritedTypes.java index 7c2010336abf..c68537ee837b 100644 --- a/checker/tests/ainfer-relevance/non-annotated/AmbiguousInheritedTypes.java +++ b/checker/tests/ainfer-relevance/non-annotated/AmbiguousInheritedTypes.java @@ -1,34 +1,41 @@ import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; -// A class does not inherit a member type through a supertype that hides it: `Hides.Foo` hides -// `Irrelevant.Foo`, and `Hides.Foo` is private and therefore is not inherited either. Thus, the -// name "Foo" in `Use` refers to `Relevant.Foo`, which is relevant because it is a subtype of -// `CharSequence`. +// A class does not inherit a member type through a supertype that declares a member type with the +// same name, even if that declaration is not itself inherited: `Hides.Foo` hides `Relevant.Foo`, +// and `Hides.Foo` is private and therefore is not inherited by `Use`. Thus, `Use` inherits +// exactly one member type named "Foo", and the name "Foo" in `Use` refers to `Irrelevant.Foo`, +// which is irrelevant because it is not a subtype of any type in the checker's +// `@RelevantJavaTypes`. // -// Inference searches the supertypes of `Use` in breadth-first order, and it does not model hiding -// by a member type that is not inherited. Therefore, it finds both `Irrelevant.Foo` and -// `Relevant.Foo`, which are the same distance from `Use`. Neither one hides the other, so -// inference must not arbitrarily choose one of them; it must be conservative and assume that the -// type might be relevant. If inference instead resolved the name to `Irrelevant.Foo` and -// therefore discarded the annotation, then the second (validation) pass of this test would issue -// the warning that is written below. +// Inference searches the supertypes of `Use` in breadth-first order, and `Relevant` and +// `Irrelevant` are the same distance from `Use`. If inference did not model hiding by a member +// type that is not inherited, then it would find both `Relevant.Foo` and `Irrelevant.Foo`, neither +// of which hides the other, so it would have to be conservative and assume that the type might be +// relevant. +// +// Inference must discard the annotation on `field`, as the goal file shows. If inference instead +// treats the name as ambiguous, or resolves it to `Relevant.Foo`, then it writes the annotation. +// +// Writing `@AinferSibling1` on an `Irrelevant.Foo` is itself a relevance violation, which this +// test suppresses because the warning would appear in both passes of the test and the annotated +// copy of this file contains no expected diagnostics. public class AmbiguousInheritedTypes { - static class Irrelevant { - static class Foo {} + static class Relevant { + abstract static class Foo implements CharSequence {} } - static class Hides extends Irrelevant { + static class Hides extends Relevant { private static class Foo {} } - interface Relevant { - abstract class Foo implements CharSequence {} + interface Irrelevant { + class Foo {} } - interface SubRelevant extends Relevant {} + interface SubIrrelevant extends Irrelevant {} - static class Use extends Hides implements SubRelevant { + static class Use extends Hides implements SubIrrelevant { Foo field; @@ -37,12 +44,13 @@ void assignField() { } void useField() { - // :: warning: [argument] expectsSibling1(field); } + @SuppressWarnings("anno.on.irrelevant") void expectsSibling1(@AinferSibling1 Foo f) {} + @SuppressWarnings("anno.on.irrelevant") @AinferSibling1 Foo getSibling1() { return null; } diff --git a/checker/tests/ainfer-relevance/non-annotated/middlepkg/Middle.java b/checker/tests/ainfer-relevance/non-annotated/middlepkg/Middle.java new file mode 100644 index 000000000000..65a1a1bdf13b --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/middlepkg/Middle.java @@ -0,0 +1,8 @@ +package middlepkg; + +import otherpkg.PackagePrivateMemberTypes; + +// This class exists so that a class in package `otherpkg` can extend, indirectly, another class in +// package `otherpkg`, with a class in a different package between them. It is used by +// `otherpkg/NotInheritedThroughOtherPackage.java`. +public class Middle extends PackagePrivateMemberTypes {} diff --git a/checker/tests/ainfer-relevance/non-annotated/otherpkg/NotInheritedThroughOtherPackage.java b/checker/tests/ainfer-relevance/non-annotated/otherpkg/NotInheritedThroughOtherPackage.java new file mode 100644 index 000000000000..f5e590d0a396 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/otherpkg/NotInheritedThroughOtherPackage.java @@ -0,0 +1,34 @@ +package otherpkg; + +import java.nio.CharBuffer; +import middlepkg.Middle; +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A package-private member type is inherited only by a subclass in the package that declares it, +// so whether a class inherits one depends on the package of every class between the two, not only +// on the package that contains the use of its name. `PackagePrivateMemberTypes.CharBuffer` is +// package-private, so `middlepkg.Middle` does not inherit it, and therefore this class does not +// inherit it either -- even though this class is in the package that declares it. +// +// Inference must resolve the name "CharBuffer" to the single-type import `java.nio.CharBuffer`, +// which is relevant because it is a subtype of `CharSequence`, rather than to +// `PackagePrivateMemberTypes.CharBuffer`, which is irrelevant. Inference must therefore write the +// annotation on `charBufferField`, as the goal file shows. If inference instead resolves the name +// to `PackagePrivateMemberTypes.CharBuffer`, then it discards the annotation. +// +// The second (validation) pass of this test does not detect a discarded annotation here, because +// the test harness passes to that pass only the ajava files that inference wrote into the +// top-level output directory, and inference writes the ajava file for a class in a named package +// into a subdirectory. The goal file is therefore this test's only assertion. +public class NotInheritedThroughOtherPackage extends Middle { + + CharBuffer charBufferField; + + void assignField() { + charBufferField = getSibling1CharBuffer(); + } + + @AinferSibling1 CharBuffer getSibling1CharBuffer() { + return null; + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/otherpkg/PackagePrivateMemberTypes.java b/checker/tests/ainfer-relevance/non-annotated/otherpkg/PackagePrivateMemberTypes.java index bd1ed02a17a2..8fd161a6a191 100644 --- a/checker/tests/ainfer-relevance/non-annotated/otherpkg/PackagePrivateMemberTypes.java +++ b/checker/tests/ainfer-relevance/non-annotated/otherpkg/PackagePrivateMemberTypes.java @@ -1,10 +1,10 @@ package otherpkg; -// This class exists so that a class in the unnamed package can extend a class, in a different -// package, that declares member types with different accessibilities. It is used by -// `PackagePrivateNotInherited.java`. +// This class exists so that a class in another package can extend a class that declares member +// types with different accessibilities. It is used by `PackagePrivateNotInherited.java` and by +// `otherpkg/NotInheritedThroughOtherPackage.java`. // -// This is the only test input that is in a named package, so its goal file is the only one whose +// The goal file of a test input that is in a named package is the only kind of goal file whose // name includes a package name. public class PackagePrivateMemberTypes { diff --git a/checker/tests/ainfer-relevance/otherpkg.NotInheritedThroughOtherPackage.ajava.goal b/checker/tests/ainfer-relevance/otherpkg.NotInheritedThroughOtherPackage.ajava.goal new file mode 100644 index 000000000000..bad56cae8380 --- /dev/null +++ b/checker/tests/ainfer-relevance/otherpkg.NotInheritedThroughOtherPackage.ajava.goal @@ -0,0 +1,38 @@ +package otherpkg; + +import java.nio.CharBuffer; +import middlepkg.Middle; +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A package-private member type is inherited only by a subclass in the package that declares it, +// so whether a class inherits one depends on the package of every class between the two, not only +// on the package that contains the use of its name. `PackagePrivateMemberTypes.CharBuffer` is +// package-private, so `middlepkg.Middle` does not inherit it, and therefore this class does not +// inherit it either -- even though this class is in the package that declares it. +// +// Inference must resolve the name "CharBuffer" to the single-type import `java.nio.CharBuffer`, +// which is relevant because it is a subtype of `CharSequence`, rather than to +// `PackagePrivateMemberTypes.CharBuffer`, which is irrelevant. Inference must therefore write the +// annotation on `charBufferField`, as the goal file shows. If inference instead resolves the name +// to `PackagePrivateMemberTypes.CharBuffer`, then it discards the annotation. +// +// The second (validation) pass of this test does not detect a discarded annotation here, because +// the test harness passes to that pass only the ajava files that inference wrote into the +// top-level output directory, and inference writes the ajava file for a class in a named package +// into a subdirectory. The goal file is therefore this test's only assertion. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class NotInheritedThroughOtherPackage extends Middle { + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 CharBuffer charBufferField; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "this.charBufferField" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + void assignField() { + charBufferField = getSibling1CharBuffer(); + } + + @org.checkerframework.dataflow.qual.Pure + CharBuffer getSibling1CharBuffer(NotInheritedThroughOtherPackage this) { + return null; + } +} diff --git a/checker/tests/ainfer-relevance/otherpkg.PackagePrivateMemberTypes.ajava.goal b/checker/tests/ainfer-relevance/otherpkg.PackagePrivateMemberTypes.ajava.goal index 7511479780e6..2f4a9b69dfe0 100644 --- a/checker/tests/ainfer-relevance/otherpkg.PackagePrivateMemberTypes.ajava.goal +++ b/checker/tests/ainfer-relevance/otherpkg.PackagePrivateMemberTypes.ajava.goal @@ -1,10 +1,10 @@ package otherpkg; -// This class exists so that a class in the unnamed package can extend a class, in a different -// package, that declares member types with different accessibilities. It is used by -// `PackagePrivateNotInherited.java`. +// This class exists so that a class in another package can extend a class that declares member +// types with different accessibilities. It is used by `PackagePrivateNotInherited.java` and by +// `otherpkg/NotInheritedThroughOtherPackage.java`. // -// This is the only test input that is in a named package, so its goal file is the only one whose +// The goal file of a test input that is in a named package is the only kind of goal file whose // name includes a package name. @org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") public class PackagePrivateMemberTypes { diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index 18abaf646072..32dd88bcb182 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -33,6 +33,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -512,9 +513,17 @@ private static boolean containsSame(List nodes, Node node) { * two supertypes that are equally near declare different member types with this name, then * neither hides the other, the name is ambiguous, and this method returns null. * - *

A member type that is not inherited is ignored, and therefore hides nothing: a private - * member type, and a package-private member type that is declared in a package other than {@code - * usePackage}. A protected member type is inherited even from a different package. + *

A member type declaration hides every declaration of the same name in a supertype of the + * type that declares it, even if the declaration is not inherited or is not accessible. Such a + * declaration therefore ends the search through the type that contains it: the supertypes of that + * type are not searched, and if the declaration is not inherited or is not accessible, then that + * type contributes no member type at all. + * + *

A private member type is not inherited. A package-private member type is inherited only by a + * subclass in the package that declares it, and it is accessible only within that package; this + * method therefore uses one only if every type from {@code typeElement} to the type that declares + * it is in package {@code usePackage}. A protected member type is inherited even from a different + * package. * * @param elements used for looking up names * @param typeElement the type whose member types to search @@ -537,36 +546,44 @@ private static boolean containsSame(List nodes, Node node) { Set visited = new HashSet<>(); visited.add(typeElement); // The types that are the same distance from `typeElement`: first `typeElement` itself, then - // its direct supertypes, and so forth. - List currentTypes = Collections.singletonList(typeElement); + // its direct supertypes, and so forth. Each one maps to true if it, and every type between it + // and `typeElement`, is in package `usePackage` -- which is what makes a package-private member + // type that it declares both inherited by `typeElement` and accessible at the use site. + Map currentTypes = new LinkedHashMap<>(); + currentTypes.put(typeElement, isInPackage(elements, typeElement, usePackage)); while (!currentTypes.isEmpty()) { // Every type at the current distance is searched, rather than returning the first member // type that is found, because a member type that is declared in one of them does not hide // one that is declared in another. TypeElement found = null; - for (TypeElement current : currentTypes) { - for (TypeElement member : ElementFilter.typesIn(current.getEnclosedElements())) { - if (!member.getSimpleName().contentEquals(firstComponent)) { - continue; - } - Set modifiers = member.getModifiers(); - if (modifiers.contains(Modifier.PRIVATE)) { - // A private member type is not inherited. - continue; - } - if (!modifiers.contains(Modifier.PUBLIC) - && !modifiers.contains(Modifier.PROTECTED) - && !elements.getPackageOf(member).getQualifiedName().contentEquals(usePackage)) { - // A package-private member type is not inherited by a class in another package, and - // it is not accessible at the use site. - continue; + Map nextTypes = new LinkedHashMap<>(); + for (Map.Entry entry : currentTypes.entrySet()) { + TypeElement current = entry.getKey(); + boolean samePackagePath = entry.getValue(); + TypeElement member = declaredMemberType(current, firstComponent); + if (member != null) { + // This declaration hides every declaration of the same name in a supertype of `current`, + // so the supertypes of `current` are not searched. + if (isInheritedAndAccessible(member, samePackagePath)) { + if (found == null) { + found = member; + } else if (!found.equals(member)) { + // Two equally near supertypes declare different member types with this name, so the + // name is ambiguous. + return null; + } } - if (found == null) { - found = member; - } else if (!found.equals(member)) { - // Two equally near supertypes declare different member types with this name, so the - // name is ambiguous. - return null; + continue; + } + for (TypeElement supertype : ElementUtils.getDirectSuperTypeElements(current, elements)) { + if (!visited.contains(supertype)) { + // A supertype that more than one path reaches at this distance is searched once. A + // package-private member type that it declares is inherited if any of those paths + // stays within package `usePackage`. + nextTypes.merge( + supertype, + samePackagePath && isInPackage(elements, supertype, usePackage), + (b1, b2) -> b1 || b2); } } } @@ -576,19 +593,68 @@ private static boolean containsSame(List nodes, Node node) { } return getTypeElement(elements, found.getQualifiedName() + suffix, cache); } - List nextTypes = new ArrayList<>(); - for (TypeElement current : currentTypes) { - for (TypeElement supertype : ElementUtils.getDirectSuperTypeElements(current, elements)) { - if (visited.add(supertype)) { - nextTypes.add(supertype); - } - } - } + visited.addAll(nextTypes.keySet()); currentTypes = nextTypes; } return null; } + /** + * Returns the member type that {@code typeElement} declares with the given simple name, or null + * if it declares none. A type declares at most one member type with a given simple name. + * + * @param typeElement a type + * @param name a simple name + * @return the member type that {@code typeElement} declares with the given simple name, or null + * if it declares none + */ + private static @Nullable TypeElement declaredMemberType(TypeElement typeElement, String name) { + for (TypeElement member : ElementFilter.typesIn(typeElement.getEnclosedElements())) { + if (member.getSimpleName().contentEquals(name)) { + return member; + } + } + return null; + } + + /** + * Returns true if the given type is in the given package. + * + * @param elements used for looking up names + * @param typeElement a type + * @param packageName the name of a package, or "" for the unnamed package + * @return true if {@code typeElement} is in the package named {@code packageName} + */ + private static boolean isInPackage( + Elements elements, TypeElement typeElement, String packageName) { + return elements.getPackageOf(typeElement).getQualifiedName().contentEquals(packageName); + } + + /** + * Returns true if the given member type is inherited by the subtypes of the type that declares + * it, and is accessible at the use site. + * + * @param member a member type + * @param samePackagePath true if the type that declares {@code member}, and every type between it + * and the type whose member types are being searched, is in the package that contains the use + * of the type name + * @return true if {@code member} is inherited and is accessible at the use site + */ + private static boolean isInheritedAndAccessible(TypeElement member, boolean samePackagePath) { + Set modifiers = member.getModifiers(); + if (modifiers.contains(Modifier.PRIVATE)) { + // A private member type is not inherited. + return false; + } + if (modifiers.contains(Modifier.PUBLIC) || modifiers.contains(Modifier.PROTECTED)) { + // A protected member type is inherited even by a subclass in a different package. + return true; + } + // A package-private member type is inherited only by a subclass in the package that declares + // it, and it is accessible only within that package. + return samePackagePath; + } + /** * Given the compilation unit node for a source file, returns the top level type definition with * the given name. From f34100742e493b637303dc192e1bb1d753bd414d Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Fri, 18 Sep 2026 00:02:03 -0700 Subject: [PATCH 47/58] Use the leftmost bound for an intersection type upper bound `typeVariableUpperBound` gave up on a type variable whose upper bound is an intersection type, so a use of such a type variable was treated as relevant and an inferred annotation was written into the .ajava file. The erasure of an intersection type is its leftmost bound, and `isRelevant` erases before testing relevance, so the leftmost bound is exact here. Co-Authored-By: Claude Opus 5 --- .../IrrelevantTypeVariable.ajava.goal | 14 ++++++++++++++ .../non-annotated/IrrelevantTypeVariable.java | 12 ++++++++++++ .../WholeProgramInferenceJavaParserStorage.java | 7 +++---- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/checker/tests/ainfer-relevance/IrrelevantTypeVariable.ajava.goal b/checker/tests/ainfer-relevance/IrrelevantTypeVariable.ajava.goal index 6939e06fa497..b3c0d4cc79e8 100644 --- a/checker/tests/ainfer-relevance/IrrelevantTypeVariable.ajava.goal +++ b/checker/tests/ainfer-relevance/IrrelevantTypeVariable.ajava.goal @@ -17,3 +17,17 @@ public class IrrelevantTypeVariable { field = t; } } + +// Likewise when the upper bound is an intersection type. Its erasure is its leftmost bound, +// `Number`, which is not relevant. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +class IrrelevantIntersectionTypeVariable { + + T field; + + // :: warning: [anno.on.irrelevant] + @org.checkerframework.dataflow.qual.Impure + void assignField(T t) { + field = t; + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/IrrelevantTypeVariable.java b/checker/tests/ainfer-relevance/non-annotated/IrrelevantTypeVariable.java index adf36d827687..e3c8d4f7ea7b 100644 --- a/checker/tests/ainfer-relevance/non-annotated/IrrelevantTypeVariable.java +++ b/checker/tests/ainfer-relevance/non-annotated/IrrelevantTypeVariable.java @@ -15,3 +15,15 @@ void assignField(@AinferSibling1 T t) { field = t; } } + +// Likewise when the upper bound is an intersection type. Its erasure is its leftmost bound, +// `Number`, which is not relevant. +class IrrelevantIntersectionTypeVariable { + + T field; + + // :: warning: [anno.on.irrelevant] + void assignField(@AinferSibling1 T t) { + field = t; + } +} diff --git a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java index 3fdc66540120..01eb80c66a28 100644 --- a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java +++ b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java @@ -1358,10 +1358,9 @@ private boolean arrayCreationLevelIsRelevant( TypeElement objectElt = elements.getTypeElement("java.lang.Object"); return objectElt == null ? null : objectElt.asType(); } - if (bounds.size() > 1) { - // The upper bound is an intersection type, which `Types` cannot create. Be conservative. - return null; - } + // If there are multiple bounds, the upper bound is an intersection type, which `Types` cannot + // create. Use its leftmost bound, which is its erasure; that is sufficient because the client, + // `GenericAnnotatedTypeFactory.isRelevant`, erases the type before testing relevance. // The bound may itself be a type variable, as in ``; the // recursion terminates because Java forbids a cycle among type variable bounds. return typeToTypeMirror(bounds.get(0)); From 3d66e1f81561ea1b080d2a24b12271d797fb5d75 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Fri, 18 Sep 2026 00:03:01 -0700 Subject: [PATCH 48/58] An import does not import an inaccessible protected member type `resolveMemberType` accepted every protected member type, because a protected member type is inherited even by a subclass in a different package. Outside the package that declares it, however, such a member type is accessible only within the body of a subclass, and an import declaration is not within the body of any class. An import on demand of a class that inherits a protected member type from another package therefore appeared to import it, and the name resolved to it rather than to the type that another import really does import. `resolveMemberType` now takes a boolean that distinguishes an ordinary lookup in the scope of a class, where a protected member type is accessible, from the lookup that an import performs, where one is accessible only if the type that declares it is in the package that contains the import. The new test `StaticImportNotAccessible` covers two static imports on demand, the first of which imports no type of the name being resolved. Co-Authored-By: Claude Opus 5 --- .../StaticImportNotAccessible.ajava.goal | 44 +++++++++++++++++++ .../StaticImportNotAccessible.java | 38 ++++++++++++++++ .../otherpkg/ProtectedMemberType.java | 18 ++++++++ .../otherpkg/PublicMemberType.java | 16 +++++++ .../framework/util/JavaParserUtil.java | 34 ++++++++++---- 5 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 checker/tests/ainfer-relevance/StaticImportNotAccessible.ajava.goal create mode 100644 checker/tests/ainfer-relevance/non-annotated/StaticImportNotAccessible.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/otherpkg/ProtectedMemberType.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/otherpkg/PublicMemberType.java diff --git a/checker/tests/ainfer-relevance/StaticImportNotAccessible.ajava.goal b/checker/tests/ainfer-relevance/StaticImportNotAccessible.ajava.goal new file mode 100644 index 000000000000..886244fcdc5a --- /dev/null +++ b/checker/tests/ainfer-relevance/StaticImportNotAccessible.ajava.goal @@ -0,0 +1,44 @@ +import static otherpkg.ProtectedMemberType.Subclass.*; +import static otherpkg.PublicMemberType.Subclass.*; +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// An import on demand imports only the member types that are accessible where the import appears. +// `otherpkg.ProtectedMemberType.Subclass` inherits the protected member type +// `otherpkg.ProtectedMemberType.MemberType`, which is not accessible in this compilation unit: +// this compilation unit is in a different package than the type that declares it, and an import +// declaration is not within the body of a subclass of that type. The first import therefore +// imports no type named "MemberType", and the name refers to the type that the second import +// imports. +// +// Inference must resolve the name "MemberType" to `otherpkg.PublicMemberType.MemberType`, which is +// relevant because it is a subtype of `CharSequence`, rather than to +// `otherpkg.ProtectedMemberType.MemberType`, which is inaccessible and is irrelevant. +// +// If inference resolves the name incorrectly and therefore discards the annotation, then the +// second (validation) pass of this test issues the warning that is written below. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class StaticImportNotAccessible { + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 MemberType memberTypeField; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "this.memberTypeField" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + void assignField() { + memberTypeField = getSibling1MemberType(); + } + + @org.checkerframework.dataflow.qual.Impure + void useField() { + // :: warning: [argument] + expectsSibling1MemberType(memberTypeField); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + void expectsSibling1MemberType(StaticImportNotAccessible this, MemberType m) { + } + + @org.checkerframework.dataflow.qual.Pure + MemberType getSibling1MemberType(StaticImportNotAccessible this) { + return null; + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/StaticImportNotAccessible.java b/checker/tests/ainfer-relevance/non-annotated/StaticImportNotAccessible.java new file mode 100644 index 000000000000..88d7397d2d57 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/StaticImportNotAccessible.java @@ -0,0 +1,38 @@ +import static otherpkg.ProtectedMemberType.Subclass.*; +import static otherpkg.PublicMemberType.Subclass.*; + +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// An import on demand imports only the member types that are accessible where the import appears. +// `otherpkg.ProtectedMemberType.Subclass` inherits the protected member type +// `otherpkg.ProtectedMemberType.MemberType`, which is not accessible in this compilation unit: +// this compilation unit is in a different package than the type that declares it, and an import +// declaration is not within the body of a subclass of that type. The first import therefore +// imports no type named "MemberType", and the name refers to the type that the second import +// imports. +// +// Inference must resolve the name "MemberType" to `otherpkg.PublicMemberType.MemberType`, which is +// relevant because it is a subtype of `CharSequence`, rather than to +// `otherpkg.ProtectedMemberType.MemberType`, which is inaccessible and is irrelevant. +// +// If inference resolves the name incorrectly and therefore discards the annotation, then the +// second (validation) pass of this test issues the warning that is written below. +public class StaticImportNotAccessible { + + MemberType memberTypeField; + + void assignField() { + memberTypeField = getSibling1MemberType(); + } + + void useField() { + // :: warning: [argument] + expectsSibling1MemberType(memberTypeField); + } + + void expectsSibling1MemberType(@AinferSibling1 MemberType m) {} + + @AinferSibling1 MemberType getSibling1MemberType() { + return null; + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/otherpkg/ProtectedMemberType.java b/checker/tests/ainfer-relevance/non-annotated/otherpkg/ProtectedMemberType.java new file mode 100644 index 000000000000..d6cb1c4173dc --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/otherpkg/ProtectedMemberType.java @@ -0,0 +1,18 @@ +package otherpkg; + +// This class exists so that a compilation unit in another package can import on demand, from a +// subclass, a protected member type that this class declares. A protected member type is +// inherited by a subclass in another package, but outside the package that declares it, the member +// type is accessible only within the body of such a subclass -- and an import declaration is not +// within the body of any class. This class is used by `StaticImportNotAccessible.java`. +public class ProtectedMemberType { + + // This type is irrelevant, because it is not a subtype of any type in the checker's + // `@RelevantJavaTypes`. + protected static class MemberType {} + + // `Subclass` inherits `MemberType` rather than declaring it, so `Elements` cannot look up the + // name "otherpkg.ProtectedMemberType.Subclass.MemberType"; inference must search the supertypes + // of `Subclass` to determine what an import on demand of `Subclass` imports. + public static class Subclass extends ProtectedMemberType {} +} diff --git a/checker/tests/ainfer-relevance/non-annotated/otherpkg/PublicMemberType.java b/checker/tests/ainfer-relevance/non-annotated/otherpkg/PublicMemberType.java new file mode 100644 index 000000000000..3f3d131f8171 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/otherpkg/PublicMemberType.java @@ -0,0 +1,16 @@ +package otherpkg; + +// This class exists so that a compilation unit in another package can import on demand, from a +// subclass, a public member type whose simple name is also the simple name of a member type of +// `ProtectedMemberType`. This class is used by `StaticImportNotAccessible.java`. +public class PublicMemberType { + + // This type is relevant, because it is a subtype of `CharSequence`, which the checker's + // `@RelevantJavaTypes` lists. + public abstract static class MemberType implements CharSequence {} + + // `Subclass` inherits `MemberType` rather than declaring it, so `Elements` cannot look up the + // name "otherpkg.PublicMemberType.Subclass.MemberType"; inference must search the supertypes of + // `Subclass` to determine what an import on demand of `Subclass` imports. + public static class Subclass extends PublicMemberType {} +} diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index 32dd88bcb182..c1031fad81cf 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -204,7 +204,7 @@ && inScopeOfMemberTypes(child)) { if (enclosingElement != null) { result = resolveMemberType( - elements, enclosingElement, firstComponent, suffix, usePackage, cache); + elements, enclosingElement, firstComponent, suffix, usePackage, true, cache); if (result != null) { return result; } @@ -231,7 +231,7 @@ && inScopeOfMemberTypes(child)) { } TypeElement fromSupertype = resolveMemberType( - elements, supertypeElement, firstComponent, suffix, usePackage, cache); + elements, supertypeElement, firstComponent, suffix, usePackage, true, cache); if (fromSupertype != null) { if (inherited == null) { inherited = fromSupertype; @@ -276,7 +276,7 @@ && inScopeOfMemberTypes(child)) { if (containerElement != null) { result = resolveMemberType( - elements, containerElement, firstComponent, suffix, usePackage, cache); + elements, containerElement, firstComponent, suffix, usePackage, false, cache); if (result != null) { return result; } @@ -311,7 +311,7 @@ && inScopeOfMemberTypes(child)) { if (importedElement != null) { TypeElement result = resolveMemberType( - elements, importedElement, firstComponent, suffix, usePackage, cache); + elements, importedElement, firstComponent, suffix, usePackage, false, cache); if (result != null) { return result; } @@ -523,7 +523,9 @@ private static boolean containsSame(List nodes, Node node) { * subclass in the package that declares it, and it is accessible only within that package; this * method therefore uses one only if every type from {@code typeElement} to the type that declares * it is in package {@code usePackage}. A protected member type is inherited even from a different - * package. + * package, but outside the package that declares it, it is accessible only within the body of a + * subclass of the type that declares it; this method therefore uses one that is declared in + * another package only if {@code inSubclassBody} is true. * * @param elements used for looking up names * @param typeElement the type whose member types to search @@ -532,6 +534,9 @@ private static boolean containsSame(List nodes, Node node) { * firstComponent}; it is empty or starts with "." * @param usePackage the name of the package that contains the use of the type name, or "" for the * unnamed package + * @param inSubclassBody true if the use of the type name is within the body of {@code + * typeElement} or of a subclass of it, as it is for an ordinary lookup in the scope of a + * class, but not for the lookup that an import performs * @param cache maps a name to the type it names, or to null if it names no type; this method both * reads and writes it * @return the element for the member type, or null if there is none or it cannot be determined @@ -542,6 +547,7 @@ private static boolean containsSame(List nodes, Node node) { String firstComponent, String suffix, String usePackage, + boolean inSubclassBody, Map cache) { Set visited = new HashSet<>(); visited.add(typeElement); @@ -564,7 +570,9 @@ private static boolean containsSame(List nodes, Node node) { if (member != null) { // This declaration hides every declaration of the same name in a supertype of `current`, // so the supertypes of `current` are not searched. - if (isInheritedAndAccessible(member, samePackagePath)) { + boolean protectedIsAccessible = + inSubclassBody || isInPackage(elements, current, usePackage); + if (isInheritedAndAccessible(member, samePackagePath, protectedIsAccessible)) { if (found == null) { found = member; } else if (!found.equals(member)) { @@ -638,18 +646,26 @@ private static boolean isInPackage( * @param samePackagePath true if the type that declares {@code member}, and every type between it * and the type whose member types are being searched, is in the package that contains the use * of the type name + * @param protectedIsAccessible true if a protected member of the type that declares {@code + * member} is accessible at the use site * @return true if {@code member} is inherited and is accessible at the use site */ - private static boolean isInheritedAndAccessible(TypeElement member, boolean samePackagePath) { + private static boolean isInheritedAndAccessible( + TypeElement member, boolean samePackagePath, boolean protectedIsAccessible) { Set modifiers = member.getModifiers(); if (modifiers.contains(Modifier.PRIVATE)) { // A private member type is not inherited. return false; } - if (modifiers.contains(Modifier.PUBLIC) || modifiers.contains(Modifier.PROTECTED)) { - // A protected member type is inherited even by a subclass in a different package. + if (modifiers.contains(Modifier.PUBLIC)) { return true; } + if (modifiers.contains(Modifier.PROTECTED)) { + // A protected member type is inherited even by a subclass in a different package, but + // outside the package that declares it, it is accessible only within the body of such a + // subclass. + return protectedIsAccessible; + } // A package-private member type is inherited only by a subclass in the package that declares // it, and it is accessible only within that package. return samePackagePath; From f81001ac2005fd2da933056c25696658346333cc Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Fri, 18 Sep 2026 00:05:49 -0700 Subject: [PATCH 49/58] Distance between supertypes creates no hiding relationship The javadoc of `resolveMemberType` said that a member type that is declared in a nearer supertype hides one that is declared in a farther supertype. That is not a Java rule: a member type declaration hides only the declarations of the same name in the supertypes of the type that declares it, so javac reports that the name is ambiguous no matter which of two unrelated supertypes is nearer. The breadth-first search returns the nearer declaration, which affects no valid program, because a use of an ambiguous name does not compile. Co-Authored-By: Claude Opus 5 --- .../framework/util/JavaParserUtil.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index c1031fad81cf..bb681b7209f4 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -508,10 +508,12 @@ private static boolean containsSame(List nodes, Node node) { * Returns the element for the member type named {@code firstComponent + suffix} that {@code * typeElement} declares or inherits, or null if there is no such member type. * - *

{@code typeElement} and its supertypes are searched in breadth-first order, so a member type - * that is declared in a nearer supertype hides one that is declared in a farther supertype. If - * two supertypes that are equally near declare different member types with this name, then - * neither hides the other, the name is ambiguous, and this method returns null. + *

{@code typeElement} and its supertypes are searched in breadth-first order. If two + * supertypes that are equally near declare different member types with this name, then neither + * hides the other, the name is ambiguous, and this method returns null. Java considers the name + * ambiguous even if the two supertypes are at different distances, because neither declaration + * hides the other (see below); this method returns the nearer declaration, which affects no valid + * program, because a use of an ambiguous name does not compile. * *

A member type declaration hides every declaration of the same name in a supertype of the * type that declares it, even if the declaration is not inherited or is not accessible. Such a From 18950d449691caab987909e642fe9fa1f4021838 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Fri, 18 Sep 2026 00:10:38 -0700 Subject: [PATCH 50/58] Do not import an inaccessible package-private member type `resolveMemberType` searches the member types of the type that an import declaration names. Its `SearchedType` argument records which member types are members of that type, but an import declaration imports only the member types that are *accessible* where it appears, and a package-private member type is accessible only in the package that declares it. Both import call sites passed `packagePrivateIsMember = true` unconditionally, so a name could resolve to a package-private member type that the imported type inherits but that the importing compilation unit cannot use. When another import on demand supplied the accessible member type, the name resolved to the wrong type, which changes whether inference considers the type relevant and therefore whether it writes or discards an annotation. Each import call site now passes `packagePrivateIsMember = true` only if the imported type is in the package that contains the import declaration. A package-private member type is a member of the imported type only if every type from the imported type to the type that declares it is in the imported type's package, so this is exactly the condition under which such a member type is both inherited and accessible. Co-Authored-By: Claude Opus 5 --- .../OnDemandImportAccessibility.ajava.goal | 47 +++++++++++++++++++ .../OnDemandImportAccessibility.java | 41 ++++++++++++++++ .../otherpkg/SamePackageSubclass.java | 7 +++ .../publicpkg/PublicCharBufferHolder.java | 10 ++++ .../publicpkg/PublicCharBufferSubclass.java | 7 +++ .../framework/util/JavaParserUtil.java | 25 ++++++++-- 6 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 checker/tests/ainfer-relevance/OnDemandImportAccessibility.ajava.goal create mode 100644 checker/tests/ainfer-relevance/non-annotated/OnDemandImportAccessibility.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/otherpkg/SamePackageSubclass.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/publicpkg/PublicCharBufferHolder.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/publicpkg/PublicCharBufferSubclass.java diff --git a/checker/tests/ainfer-relevance/OnDemandImportAccessibility.ajava.goal b/checker/tests/ainfer-relevance/OnDemandImportAccessibility.ajava.goal new file mode 100644 index 000000000000..3e14cb8137bc --- /dev/null +++ b/checker/tests/ainfer-relevance/OnDemandImportAccessibility.ajava.goal @@ -0,0 +1,47 @@ +import static otherpkg.SamePackageSubclass.*; +import static publicpkg.PublicCharBufferSubclass.*; +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A static import on demand imports the member types that the named class inherits, but only the +// ones that are accessible where the import appears. A package-private member type is accessible +// only in the package that declares it, so `import static otherpkg.SamePackageSubclass.*;` does not +// import `otherpkg.PackagePrivateMemberTypes.CharBuffer` into this compilation unit, which is in +// the unnamed package -- even though `SamePackageSubclass` does inherit that member type, because +// `SamePackageSubclass` is in the package that declares it. +// +// Inference must therefore resolve the name "CharBuffer" to +// `publicpkg.PublicCharBufferHolder.CharBuffer`, which the other static import on demand does +// import and which is relevant because it is a subtype of `CharSequence`, rather than to +// `otherpkg.PackagePrivateMemberTypes.CharBuffer`, which is irrelevant. Neither member type has a +// canonical name that starts with the name of the class that its import names, so neither is found +// before the imports on demand are searched, and the import that names `SamePackageSubclass` is +// searched first. +// +// If inference resolves the name incorrectly and therefore discards the annotation, then the +// second (validation) pass of this test issues the warning that is written below. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class OnDemandImportAccessibility { + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 CharBuffer charBufferField; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "this.charBufferField" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + void assignField() { + charBufferField = getSibling1CharBuffer(); + } + + @org.checkerframework.dataflow.qual.Impure + void useField() { + // :: warning: [argument] + expectsSibling1CharBuffer(charBufferField); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + void expectsSibling1CharBuffer(OnDemandImportAccessibility this, CharBuffer b) { + } + + @org.checkerframework.dataflow.qual.Pure + CharBuffer getSibling1CharBuffer(OnDemandImportAccessibility this) { + return null; + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/OnDemandImportAccessibility.java b/checker/tests/ainfer-relevance/non-annotated/OnDemandImportAccessibility.java new file mode 100644 index 000000000000..71677dad63db --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/OnDemandImportAccessibility.java @@ -0,0 +1,41 @@ +import static otherpkg.SamePackageSubclass.*; +import static publicpkg.PublicCharBufferSubclass.*; + +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; + +// A static import on demand imports the member types that the named class inherits, but only the +// ones that are accessible where the import appears. A package-private member type is accessible +// only in the package that declares it, so `import static otherpkg.SamePackageSubclass.*;` does not +// import `otherpkg.PackagePrivateMemberTypes.CharBuffer` into this compilation unit, which is in +// the unnamed package -- even though `SamePackageSubclass` does inherit that member type, because +// `SamePackageSubclass` is in the package that declares it. +// +// Inference must therefore resolve the name "CharBuffer" to +// `publicpkg.PublicCharBufferHolder.CharBuffer`, which the other static import on demand does +// import and which is relevant because it is a subtype of `CharSequence`, rather than to +// `otherpkg.PackagePrivateMemberTypes.CharBuffer`, which is irrelevant. Neither member type has a +// canonical name that starts with the name of the class that its import names, so neither is found +// before the imports on demand are searched, and the import that names `SamePackageSubclass` is +// searched first. +// +// If inference resolves the name incorrectly and therefore discards the annotation, then the +// second (validation) pass of this test issues the warning that is written below. +public class OnDemandImportAccessibility { + + CharBuffer charBufferField; + + void assignField() { + charBufferField = getSibling1CharBuffer(); + } + + void useField() { + // :: warning: [argument] + expectsSibling1CharBuffer(charBufferField); + } + + void expectsSibling1CharBuffer(@AinferSibling1 CharBuffer b) {} + + @AinferSibling1 CharBuffer getSibling1CharBuffer() { + return null; + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/otherpkg/SamePackageSubclass.java b/checker/tests/ainfer-relevance/non-annotated/otherpkg/SamePackageSubclass.java new file mode 100644 index 000000000000..825ab72fb827 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/otherpkg/SamePackageSubclass.java @@ -0,0 +1,7 @@ +package otherpkg; + +// This class exists so that a static import on demand in another package can name a class that +// inherits a package-private member type. It is in the same package as +// `PackagePrivateMemberTypes`, so it inherits `PackagePrivateMemberTypes.CharBuffer`. It is used +// by `OnDemandImportAccessibility.java`. +public class SamePackageSubclass extends PackagePrivateMemberTypes {} diff --git a/checker/tests/ainfer-relevance/non-annotated/publicpkg/PublicCharBufferHolder.java b/checker/tests/ainfer-relevance/non-annotated/publicpkg/PublicCharBufferHolder.java new file mode 100644 index 000000000000..aefc50d32c91 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/publicpkg/PublicCharBufferHolder.java @@ -0,0 +1,10 @@ +package publicpkg; + +// This class exists so that a class in this package can inherit, rather than declare, a public +// member type named "CharBuffer". It is used by `OnDemandImportAccessibility.java`. +public class PublicCharBufferHolder { + + // This type is relevant, because it is a subtype of `CharSequence`, which the checker's + // `@RelevantJavaTypes` lists. + public abstract static class CharBuffer implements CharSequence {} +} diff --git a/checker/tests/ainfer-relevance/non-annotated/publicpkg/PublicCharBufferSubclass.java b/checker/tests/ainfer-relevance/non-annotated/publicpkg/PublicCharBufferSubclass.java new file mode 100644 index 000000000000..a9d80d09939f --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/publicpkg/PublicCharBufferSubclass.java @@ -0,0 +1,7 @@ +package publicpkg; + +// This class exists so that a static import on demand in another package can name a class that +// inherits a public member type, rather than declaring it. A member type that this class +// declared would have a canonical name, which name resolution looks up before it searches the +// types that an import on demand names. It is used by `OnDemandImportAccessibility.java`. +public class PublicCharBufferSubclass extends PublicCharBufferHolder {} diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index 00c1917738ae..0b9cc325c297 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -310,11 +310,18 @@ supertypeElement, false, inPackage(elements, supertypeElement, packageName)), TypeElement containerElement = getTypeElement(elements, containerName, cache); if (containerElement != null) { // An import imports only the member types that `containerElement` inherits; the ones - // that it declares have canonical names, which the lookup above already tried. + // that it declares have canonical names, which the lookup above already tried. A + // package-private member type is a member of `containerElement` only if every type + // from `containerElement` to the type that declares it is in `containerElement`'s + // package; such a member type is accessible at this use, and therefore imported, only + // if that package is also `packageName`. result = resolveMemberType( elements, - new SearchedType(containerElement, false, true), + new SearchedType( + containerElement, + false, + inPackage(elements, containerElement, packageName)), firstComponent, suffix, cache); @@ -351,11 +358,16 @@ supertypeElement, false, inPackage(elements, supertypeElement, packageName)), TypeElement importedElement = getTypeElement(elements, importDecl.getNameAsString(), cache); if (importedElement != null) { // An import imports only the member types that `importedElement` inherits; the ones that - // it declares have canonical names, which the lookup above already tried. + // it declares have canonical names, which the lookup above already tried. A + // package-private member type is a member of `importedElement` only if every type from + // `importedElement` to the type that declares it is in `importedElement`'s package; such + // a member type is accessible at this use, and therefore imported, only if that package + // is also `packageName`. TypeElement result = resolveMemberType( elements, - new SearchedType(importedElement, false, true), + new SearchedType( + importedElement, false, inPackage(elements, importedElement, packageName)), firstComponent, suffix, cache); @@ -595,6 +607,11 @@ private static boolean containsSame(List nodes, Node node) { * imported type. In those cases a member type is a member at the place where the name is being * resolved only if it is inherited. * + *

An import declaration imports only the member types that are accessible where it appears, + * and a package-private member type is accessible only in the package that declares it. For a + * name that an import declaration resolves, {@code packagePrivateIsMember} is therefore true only + * if the imported type is in the package that contains the import declaration. + * * @param typeElement the type whose member types to search * @param privateIsMember true if a private member type of {@code typeElement} is a member at the * place where the name is being resolved; true only if the search starts at {@code From cc8389688e7d6b5a3d9632438e19e8a299151faf Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Fri, 18 Sep 2026 07:43:01 -0700 Subject: [PATCH 51/58] Code review --- ...nDemandImportPackagePrivateType.ajava.goal | 40 ++++++++++++ .../OnDemandImportPackagePrivateType.java | 33 ++++++++++ .../non-annotated/irrelevantpkg/Region.java | 7 ++ .../non-annotated/relevantpkg/Region.java | 6 ++ .../framework/util/JavaParserUtil.java | 65 +++++++++++++++---- 5 files changed, 139 insertions(+), 12 deletions(-) create mode 100644 checker/tests/ainfer-relevance/OnDemandImportPackagePrivateType.ajava.goal create mode 100644 checker/tests/ainfer-relevance/non-annotated/OnDemandImportPackagePrivateType.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/irrelevantpkg/Region.java create mode 100644 checker/tests/ainfer-relevance/non-annotated/relevantpkg/Region.java diff --git a/checker/tests/ainfer-relevance/OnDemandImportPackagePrivateType.ajava.goal b/checker/tests/ainfer-relevance/OnDemandImportPackagePrivateType.ajava.goal new file mode 100644 index 000000000000..75d5f84d0518 --- /dev/null +++ b/checker/tests/ainfer-relevance/OnDemandImportPackagePrivateType.ajava.goal @@ -0,0 +1,40 @@ +import irrelevantpkg.*; +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; +import relevantpkg.*; + +// An import on demand imports only the types that are accessible where it appears, and a +// package-private type is accessible only within the package that declares it. This compilation +// unit is in the unnamed package, so `import irrelevantpkg.*;` does not import +// `irrelevantpkg.Region`. An inaccessible type does not resolve the name, and it does not prevent +// a later import on demand from resolving it. +// +// Inference must therefore resolve the name "Region" to `relevantpkg.Region`, which is relevant +// because it is a subtype of `CharSequence`, rather than to `irrelevantpkg.Region`, which is +// irrelevant. If inference resolves the name incorrectly and therefore discards the annotation, +// then the second (validation) pass of this test issues the warning that is written below. +@org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") +public class OnDemandImportPackagePrivateType { + + @org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1 Region regionField; + + @org.checkerframework.framework.qual.EnsuresQualifier(expression = { "this.regionField" }, qualifier = org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1.class) + @org.checkerframework.dataflow.qual.Impure + void assignField() { + regionField = getSibling1Region(); + } + + @org.checkerframework.dataflow.qual.Impure + void useField() { + // :: warning: [argument] + expectsSibling1Region(regionField); + } + + @org.checkerframework.dataflow.qual.SideEffectFree + void expectsSibling1Region(OnDemandImportPackagePrivateType this, Region r) { + } + + @org.checkerframework.dataflow.qual.Pure + Region getSibling1Region(OnDemandImportPackagePrivateType this) { + return null; + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/OnDemandImportPackagePrivateType.java b/checker/tests/ainfer-relevance/non-annotated/OnDemandImportPackagePrivateType.java new file mode 100644 index 000000000000..d6a1dd41f031 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/OnDemandImportPackagePrivateType.java @@ -0,0 +1,33 @@ +import irrelevantpkg.*; +import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; +import relevantpkg.*; + +// An import on demand imports only the types that are accessible where it appears, and a +// package-private type is accessible only within the package that declares it. This compilation +// unit is in the unnamed package, so `import irrelevantpkg.*;` does not import +// `irrelevantpkg.Region`. An inaccessible type does not resolve the name, and it does not prevent +// a later import on demand from resolving it. +// +// Inference must therefore resolve the name "Region" to `relevantpkg.Region`, which is relevant +// because it is a subtype of `CharSequence`, rather than to `irrelevantpkg.Region`, which is +// irrelevant. If inference resolves the name incorrectly and therefore discards the annotation, +// then the second (validation) pass of this test issues the warning that is written below. +public class OnDemandImportPackagePrivateType { + + Region regionField; + + void assignField() { + regionField = getSibling1Region(); + } + + void useField() { + // :: warning: [argument] + expectsSibling1Region(regionField); + } + + void expectsSibling1Region(@AinferSibling1 Region r) {} + + @AinferSibling1 Region getSibling1Region() { + return null; + } +} diff --git a/checker/tests/ainfer-relevance/non-annotated/irrelevantpkg/Region.java b/checker/tests/ainfer-relevance/non-annotated/irrelevantpkg/Region.java new file mode 100644 index 000000000000..dd8c019307fa --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/irrelevantpkg/Region.java @@ -0,0 +1,7 @@ +package irrelevantpkg; + +// This class is package-private, so a compilation unit in another package cannot access it, and an +// import on demand imports only the types that are accessible where it appears. This class is +// irrelevant, because it is not a subtype of any type in the checker's `@RelevantJavaTypes`. It is +// used by `OnDemandImportPackagePrivateType.java`. +class Region {} diff --git a/checker/tests/ainfer-relevance/non-annotated/relevantpkg/Region.java b/checker/tests/ainfer-relevance/non-annotated/relevantpkg/Region.java new file mode 100644 index 000000000000..a4165026cbe0 --- /dev/null +++ b/checker/tests/ainfer-relevance/non-annotated/relevantpkg/Region.java @@ -0,0 +1,6 @@ +package relevantpkg; + +// This class has the same simple name as `irrelevantpkg.Region`, but it is public, so an import on +// demand does import it. It is relevant, because it is a subtype of `CharSequence`, which the +// checker's `@RelevantJavaTypes` lists. It is used by `OnDemandImportPackagePrivateType.java`. +public abstract class Region implements CharSequence {} diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index 0b9cc325c297..712f7bae91d2 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -41,6 +41,7 @@ import java.util.Optional; import java.util.Set; import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.element.Element; import javax.lang.model.element.Modifier; import javax.lang.model.element.TypeElement; import javax.lang.model.type.TypeKind; @@ -107,7 +108,7 @@ private JavaParserUtil() { /** * Returns the element for the given JavaParser type, as {@link #resolveTypeName(Elements, - * ClassOrInterfaceType, Map)} does. + * ClassOrInterfaceType, Map)} does. This method also reads and writes a cache, for efficiency. * * @param elements used for looking up names * @param type a JavaParser class or interface type @@ -333,11 +334,19 @@ supertypeElement, false, inPackage(elements, supertypeElement, packageName)), } } - // The type might be in the same package, in a package or type that is imported on demand, or - // in `java.lang`. A type in the same package shadows the others, so it is looked up first. A - // name in the unnamed package has no prefix. + // The type might be in the same package. A type in the same package shadows one that is + // imported on demand, so it is looked up first. A name in the unnamed package has no prefix. + TypeElement samePackage = + getTypeElement(elements, packageName.isEmpty() ? name : packageName + "." + name, cache); + if (samePackage != null) { + return samePackage; + } + + // The type might be in a package or type that is imported on demand, or in `java.lang`, which + // is imported on demand implicitly. An import on demand imports only the types that are + // accessible where it appears, so an inaccessible type does not resolve the name and does not + // prevent a later import on demand from resolving it. List containerPrefixes = new ArrayList<>(); - containerPrefixes.add(packageName.isEmpty() ? "" : packageName + "."); for (ImportDeclaration importDecl : cu.getImports()) { if (importDecl.isAsterisk()) { containerPrefixes.add(importDecl.getNameAsString() + "."); @@ -346,20 +355,19 @@ supertypeElement, false, inPackage(elements, supertypeElement, packageName)), containerPrefixes.add("java.lang."); for (String containerPrefix : containerPrefixes) { TypeElement result = getTypeElement(elements, containerPrefix + name, cache); - if (result != null) { + if (result != null && isAccessible(elements, result, packageName)) { return result; } } - // An import on demand, whether static or not, also imports the member types that the named - // type inherits. + // A static import on demand also imports the member types that the named type inherits. A + // type import on demand does not: it imports only the member types that the named type + // declares, which the lookup above already tried, because they have canonical names. for (ImportDeclaration importDecl : cu.getImports()) { - if (importDecl.isAsterisk()) { + if (importDecl.isAsterisk() && importDecl.isStatic()) { TypeElement importedElement = getTypeElement(elements, importDecl.getNameAsString(), cache); if (importedElement != null) { - // An import imports only the member types that `importedElement` inherits; the ones that - // it declares have canonical names, which the lookup above already tried. A - // package-private member type is a member of `importedElement` only if every type from + // A package-private member type is a member of `importedElement` only if every type from // `importedElement` to the type that declares it is in `importedElement`'s package; such // a member type is accessible at this use, and therefore imported, only if that package // is also `packageName`. @@ -769,6 +777,39 @@ private static boolean isMember(TypeElement member, SearchedType searchedType) { return searchedType.packagePrivateIsMember(); } + /** + * Returns true if a use in package {@code usePackage} can access {@code type}: no type from + * {@code type} to the top-level type that contains it is private, and every one of them that is + * package-private is in package {@code usePackage}. + * + *

This method is for a type that an import on demand might import. An import declaration is + * not within the body of a type, so a private member type is never accessible to one. A protected + * member type is not accessible to one either, but this method treats one as accessible, as + * {@link #isMember} does. + * + * @param elements used for looking up the package that contains a type + * @param type a type + * @param usePackage the fully-qualified name of the package that contains the use of the type's + * name; the empty string names the unnamed package + * @return true if a use in package {@code usePackage} can access {@code type} + */ + private static boolean isAccessible(Elements elements, TypeElement type, String usePackage) { + for (Element element = type; + element instanceof TypeElement; + element = element.getEnclosingElement()) { + Set modifiers = element.getModifiers(); + if (modifiers.contains(Modifier.PRIVATE)) { + return false; + } + if (!modifiers.contains(Modifier.PUBLIC) + && !modifiers.contains(Modifier.PROTECTED) + && !inPackage(elements, (TypeElement) element, usePackage)) { + return false; + } + } + return true; + } + /** * Returns true if the given type is declared in the given package. * From 7041b18855a877621c9417fea2ffad896b1645e4 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Fri, 18 Sep 2026 08:22:17 -0700 Subject: [PATCH 52/58] An import on demand does not import an inaccessible member type When a type-import-on-demand names a type, resolve the member type with resolveMemberType, which applies the inheritance and accessibility rules, rather than looking up the qualified name directly. A prefix that names a package still uses the direct lookup. If the first candidate is inaccessible, a later import on demand may still supply an accessible one. Co-Authored-By: Claude Opus 5 --- .../framework/util/JavaParserUtil.java | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index bb681b7209f4..33d50d144ea4 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -287,7 +287,8 @@ && inScopeOfMemberTypes(child)) { // The type might be in the same package, in a package or type that is imported on demand, or // in `java.lang`. A type in the same package shadows the others, so it is looked up first. A - // name in the unnamed package has no prefix. + // name in the unnamed package has no prefix. An import on demand, whether static or not, + // imports the member types that the named type inherits as well as those it declares. List containerPrefixes = new ArrayList<>(); containerPrefixes.add(usePackage.isEmpty() ? "" : usePackage + "."); for (ImportDeclaration importDecl : cu.getImports()) { @@ -297,24 +298,27 @@ && inScopeOfMemberTypes(child)) { } containerPrefixes.add("java.lang."); for (String containerPrefix : containerPrefixes) { - TypeElement result = getTypeElement(elements, containerPrefix + name, cache); - if (result != null) { - return result; - } - } - - // An import on demand, whether static or not, also imports the member types that the named - // type inherits. - for (ImportDeclaration importDecl : cu.getImports()) { - if (importDecl.isAsterisk()) { - TypeElement importedElement = getTypeElement(elements, importDecl.getNameAsString(), cache); - if (importedElement != null) { - TypeElement result = - resolveMemberType( - elements, importedElement, firstComponent, suffix, usePackage, false, cache); - if (result != null) { - return result; - } + // If the prefix names a type rather than a package, then the import on demand imports only + // the member types that the type declares or inherits *and* that are accessible at the use + // site, so the member type is looked up rather than merely its qualified name. + TypeElement containerElement = + containerPrefix.isEmpty() + ? null + : getTypeElement( + elements, containerPrefix.substring(0, containerPrefix.length() - 1), cache); + if (containerElement != null) { + TypeElement result = + resolveMemberType( + elements, containerElement, firstComponent, suffix, usePackage, false, cache); + if (result != null) { + return result; + } + // This type imports no accessible member type with this name, but another import on demand + // might, so the search continues. + } else { + TypeElement result = getTypeElement(elements, containerPrefix + name, cache); + if (result != null) { + return result; } } } From 2b99f94523eb303779fe0daa156283a39964efe9 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Fri, 18 Sep 2026 08:22:18 -0700 Subject: [PATCH 53/58] Clarify documentation of the effective upper bound of a type variable Co-Authored-By: Claude Opus 5 --- .../WholeProgramInferenceJavaParserStorage.java | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java index 01eb80c66a28..c67c3d75ea04 100644 --- a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java +++ b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java @@ -1306,9 +1306,10 @@ private boolean arrayCreationLevelIsRelevant( * Returns the TypeMirror for the given JavaParser type, or null if it cannot be determined. * *

This differs from {@link JavaParserUtil#typeToTypeMirror} in that a use of a type variable, - * which has no TypeMirror here, yields the type variable's upper bound. That is sound for - * deciding relevance, because {@code GenericAnnotatedTypeFactory.isRelevant} treats a type - * variable as relevant exactly when its upper bound is. + * which has no TypeMirror here, yields the type variable's effective upper bound as computed by + * {@link #typeVariableUpperBound}. That is sound for deciding relevance, because {@code + * GenericAnnotatedTypeFactory.isRelevant} erases the type and treats a type variable as relevant + * exactly when its upper bound is. * * @param type a JavaParser type * @return the TypeMirror for {@code type}, or null if it cannot be determined @@ -1338,9 +1339,12 @@ private boolean arrayCreationLevelIsRelevant( * If the given JavaParser type names a type variable, returns the TypeMirror for the type * variable's upper bound. Otherwise, or if the upper bound cannot be determined, returns null. * + *

When the upper bound is an intersection type, which {@code Types} cannot create, this + * returns the intersection type's erasure -- that is, its leftmost bound. + * * @param type a JavaParser class or interface type - * @return the TypeMirror for the upper bound of the type variable that {@code type} names, or - * null + * @return the TypeMirror for the upper bound of the type variable that {@code type} names, erased + * if that bound is an intersection type, or null */ private @Nullable TypeMirror typeVariableUpperBound(ClassOrInterfaceType type) { if (type.getNameWithScope().indexOf('.') != -1) { From 72813fd28f4532c82c502bdba0bfa7748b8ae45d Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Fri, 18 Sep 2026 13:27:26 -0700 Subject: [PATCH 54/58] Address code review of the type-variable relevance change Fix the `anno.on.irrelevant` warning in `IrrelevantTypeVariable.java` with `@SuppressWarnings`, the idiom that the other tests in this directory use, rather than by deleting the file from the validation pass in `build.gradle`. The test now runs in both passes. `resolveName` no longer returns a TypeParameter for a name with more than one component, such as `T.Inner`: a type variable has no member types, so such a name names nothing. This moves the check from the caller into the resolver. Make `ResolvedName` and `resolveName` public, so that a client that needs to know both whether a name names a type and whether it names a type variable can resolve it once. `WholeProgramInferenceJavaParserStorage.typeToTypeMirror` walked the enclosing scopes twice for every such name. Co-Authored-By: Claude Opus 5 --- checker/build.gradle | 16 ------ .../IrrelevantTypeVariable.ajava.goal | 5 +- .../non-annotated/IrrelevantTypeVariable.java | 7 +-- ...holeProgramInferenceJavaParserStorage.java | 57 ++++++++++--------- .../framework/util/JavaParserUtil.java | 22 ++++--- 5 files changed, 49 insertions(+), 58 deletions(-) diff --git a/checker/build.gradle b/checker/build.gradle index 426a7db8a3a0..832a95c2f90e 100644 --- a/checker/build.gradle +++ b/checker/build.gradle @@ -742,27 +742,11 @@ tasks.register("ainferRelevanceGenerateAjava", Test) { file("tests/ainfer-relevance/annotated"), wpiOutputDirectory(), ] - // Resolved at configuration time, and deleted with `File.delete()` rather than with - // `Project.delete()`, because the Gradle configuration cache forbids using a project object - // from within a task action. - File irrelevantTypeVariableAnnotated = - file("tests/ainfer-relevance/annotated/IrrelevantTypeVariable.java") doFirst { DirectoryDeleter.deleteDirectories(dirsToDelete) } doLast { copyNonannotatedToAnnotatedDirectory("ainfer-relevance") - - // This test writes an annotation on a use of a type variable whose upper bound is - // irrelevant, so the checker issues an `anno.on.irrelevant` warning for it in both passes of - // the test. The `// ::` comment that states the expectation is removed by - // `copyNonannotatedToAnnotatedDirectory`, so the second (validation) pass would report the - // warning as unexpected. - // `File.delete()` reports failure by returning false rather than by throwing. Without this - // test, the validation pass would report a confusing unexpected diagnostic instead. - if (!irrelevantTypeVariableAnnotated.delete()) { - throw new GradleException("Could not delete ${irrelevantTypeVariableAnnotated}") - } } } diff --git a/checker/tests/ainfer-relevance/IrrelevantTypeVariable.ajava.goal b/checker/tests/ainfer-relevance/IrrelevantTypeVariable.ajava.goal index b3c0d4cc79e8..4da20a7a8ca0 100644 --- a/checker/tests/ainfer-relevance/IrrelevantTypeVariable.ajava.goal +++ b/checker/tests/ainfer-relevance/IrrelevantTypeVariable.ajava.goal @@ -4,14 +4,12 @@ import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; // `Number` is not listed in the checker's `@RelevantJavaTypes` and is not related to a listed type // by subtyping, so inference must not write, into the .ajava file, an annotation that it infers // for a use of `T`. The checker rejects the annotation that is written below, for the same -// reason; that is why the Gradle build does not run the second (validation) pass of this test on -// this file. +// reason, in both passes of this test. @org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") public class IrrelevantTypeVariable { T field; - // :: warning: [anno.on.irrelevant] @org.checkerframework.dataflow.qual.Impure void assignField(T t) { field = t; @@ -25,7 +23,6 @@ class IrrelevantIntersectionTypeVariable { T field; - // :: warning: [anno.on.irrelevant] + @SuppressWarnings("anno.on.irrelevant") // intentional: `T`'s upper bound is irrelevant void assignField(@AinferSibling1 T t) { field = t; } @@ -22,7 +21,7 @@ class IrrelevantIntersectionTypeVariable 0; i--) { - bound = types.getArrayType(bound); + result = types.getArrayType(result); } - return bound; + return result; } /** - * If the given JavaParser type names a type variable, returns the TypeMirror for the type - * variable's upper bound. Otherwise, or if the upper bound cannot be determined, returns null. + * Returns the TypeMirror for the given type variable's upper bound, or null if the upper bound + * cannot be determined. * *

When the upper bound is an intersection type, which {@code Types} cannot create, this * returns the intersection type's erasure -- that is, its leftmost bound. * - * @param type a JavaParser class or interface type - * @return the TypeMirror for the upper bound of the type variable that {@code type} names, erased - * if that bound is an intersection type, or null + * @param typeParameter the declaration of a type variable + * @return the TypeMirror for the upper bound of {@code typeParameter}, erased if that bound is an + * intersection type, or null */ - private @Nullable TypeMirror typeVariableUpperBound(ClassOrInterfaceType type) { - if (type.getNameWithScope().indexOf('.') != -1) { - // A type variable has no member types, so a qualified name does not name a type variable. - return null; - } - TypeParameter typeParameter = - JavaParserUtil.resolveTypeVariableName(elements, type, typeElementCache); - if (typeParameter == null) { - return null; - } + private @Nullable TypeMirror typeVariableUpperBound(TypeParameter typeParameter) { NodeList bounds = typeParameter.getTypeBound(); if (bounds.isEmpty()) { // The implicit upper bound is `Object`. diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index 8baa734d4f73..5d3a2a6bab91 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -133,8 +133,8 @@ private JavaParserUtil() { * declarations and the compilation unit that contain it, so the result is null if a type * declaration shadows a type variable of the same name. * - *

If the name has more than one component, as in {@code T.Inner}, then its first component is - * the one that might name a type variable. + *

A name with more than one component, as in {@code T.Inner}, never names a type variable, + * because a type variable has no member types. * * @param elements used for looking up names * @param type a JavaParser class or interface type @@ -156,7 +156,7 @@ private JavaParserUtil() { * @param typeParameter the declaration of the type variable that the name refers to, or null if * it refers to no type variable */ - private record ResolvedName( + public record ResolvedName( @Nullable TypeElement typeElement, @Nullable TypeParameter typeParameter) { /** A name that refers to neither a type nor a type variable. */ @@ -188,13 +188,20 @@ private static ResolvedName of(TypeParameter typeParameter) { * The name is resolved in the scope of the type declarations and the compilation unit that * contain it. * + *

Resolving a name walks the enclosing scopes and searches supertypes, which is far more work + * than the cache avoids. A client that needs to know both whether the name names a type and + * whether it names a type variable should call this method once, rather than calling both {@link + * #resolveTypeName(Elements, ClassOrInterfaceType, Map)} and {@link + * #resolveTypeVariableName(Elements, ClassOrInterfaceType, Map)}, each of which repeats the walk. + * * @param elements used for looking up names * @param type a JavaParser class or interface type * @param cache maps a name to the type it names, or to null if it names no type; this method both - * reads and writes it + * reads and writes it. See {@link #resolveTypeName(Elements, ClassOrInterfaceType, Map)} for + * restrictions on it. * @return what the name of {@code type} refers to */ - private static ResolvedName resolveName( + public static ResolvedName resolveName( Elements elements, ClassOrInterfaceType type, Map cache) { return resolveName(elements, type, cache, new IdentityHashMap<>(4)); } @@ -296,8 +303,9 @@ && inScopeOfMemberTypes(child)) { if (typeParameter.getNameAsString().equals(firstComponent)) { // `name` names a type parameter, or is nested within one. A type parameter shadows // any type of the same name that is declared outside `ancestor`, and it has no - // TypeElement. - return ResolvedName.of(typeParameter); + // TypeElement. A type variable has no member types, so if `name` has a suffix, as + // in `T.Inner`, then it names nothing at all. + return suffix.isEmpty() ? ResolvedName.of(typeParameter) : ResolvedName.NONE; } } } From 9003fbc5995fb37b47818ab8a0b9bc712d0ff45a Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Fri, 18 Sep 2026 21:13:52 -0700 Subject: [PATCH 55/58] Move typeToTypeMirror and typeVariableUpperBound to JavaParserUtil `WholeProgramInferenceJavaParserStorage` had its own `typeToTypeMirror`, which differed from `JavaParserUtil.typeToTypeMirror` only in that a use of a type variable yields the type variable's effective upper bound. Merge the two: `JavaParserUtil.typeToTypeMirror` now resolves a name with `resolveName` and returns the upper bound for a type variable. Also move the helper method `typeVariableUpperBound`. Co-Authored-By: Claude Opus 5 --- ...holeProgramInferenceJavaParserStorage.java | 80 ++----------------- .../framework/util/JavaParserUtil.java | 54 ++++++++++++- 2 files changed, 57 insertions(+), 77 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java index 294a2d21746f..6c55f2fdff2c 100644 --- a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java +++ b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java @@ -1183,8 +1183,8 @@ boolean annotationIsRelevant(AnnotationExpr anno) { if (parentNode instanceof Type type) { // JavaParser's `TypeParameter` is a `Type`, so an annotation on a type parameter - // declaration, as in `<@Anno T>`, takes this branch. `typeToTypeMirror` returns null for a - // type parameter declaration, so such an annotation is retained. + // declaration, as in `<@Anno T>`, takes this branch. `JavaParserUtil.typeToTypeMirror` + // returns null for a type parameter declaration, so such an annotation is retained. return typeIsRelevant(gatf, type); } if (parentNode instanceof ArrayCreationLevel level) { @@ -1263,12 +1263,13 @@ private boolean typeIsRelevant(GenericAnnotatedTypeFactory gatf, Typ */ private boolean typeIsRelevant( GenericAnnotatedTypeFactory gatf, Type componentType, int arrayLevels) { - TypeMirror tm = typeToTypeMirror(componentType); + Types types = atypeFactory.getProcessingEnv().getTypeUtils(); + TypeMirror tm = + JavaParserUtil.typeToTypeMirror(elements, types, componentType, typeElementCache); if (tm == null) { // The type could not be determined. Be conservative. return true; } - Types types = atypeFactory.getProcessingEnv().getTypeUtils(); for (int i = 0; i < arrayLevels; i++) { tm = types.getArrayType(tm); } @@ -1302,77 +1303,6 @@ private boolean arrayCreationLevelIsRelevant( return true; } - /** - * Returns the TypeMirror for the given JavaParser type, or null if it cannot be determined. - * - *

This differs from {@link JavaParserUtil#typeToTypeMirror} in that a use of a type variable, - * which has no TypeMirror here, yields the type variable's effective upper bound as computed by - * {@link #typeVariableUpperBound}. That is sound for deciding relevance, because {@code - * GenericAnnotatedTypeFactory.isRelevant} erases the type and treats a type variable as relevant - * exactly when its upper bound is. - * - * @param type a JavaParser type - * @return the TypeMirror for {@code type}, or null if it cannot be determined - */ - private @Nullable TypeMirror typeToTypeMirror(Type type) { - Types types = atypeFactory.getProcessingEnv().getTypeUtils(); - // An array type's element type is what might name a type variable, and a non-array type is - // its own element type. - Type elementType = type.getElementType(); - TypeMirror result; - if (elementType instanceof ClassOrInterfaceType classType) { - // Resolving the name once yields both what it might name: a type or a type variable. - // Looking each up separately would walk the enclosing scopes twice. - JavaParserUtil.ResolvedName resolved = - JavaParserUtil.resolveName(elements, classType, typeElementCache); - TypeElement typeElt = resolved.typeElement(); - TypeParameter typeParameter = resolved.typeParameter(); - if (typeElt != null) { - result = typeElt.asType(); - } else if (typeParameter != null) { - // A type variable has no TypeMirror here, so use its upper bound. - result = typeVariableUpperBound(typeParameter); - } else { - return null; - } - } else { - result = JavaParserUtil.typeToTypeMirror(elements, types, elementType, typeElementCache); - } - if (result == null) { - return null; - } - for (int i = type.getArrayLevel(); i > 0; i--) { - result = types.getArrayType(result); - } - return result; - } - - /** - * Returns the TypeMirror for the given type variable's upper bound, or null if the upper bound - * cannot be determined. - * - *

When the upper bound is an intersection type, which {@code Types} cannot create, this - * returns the intersection type's erasure -- that is, its leftmost bound. - * - * @param typeParameter the declaration of a type variable - * @return the TypeMirror for the upper bound of {@code typeParameter}, erased if that bound is an - * intersection type, or null - */ - private @Nullable TypeMirror typeVariableUpperBound(TypeParameter typeParameter) { - NodeList bounds = typeParameter.getTypeBound(); - if (bounds.isEmpty()) { - // The implicit upper bound is `Object`. - TypeElement objectElt = elements.getTypeElement("java.lang.Object"); - return objectElt == null ? null : objectElt.asType(); - } - // If there are multiple bounds, the upper bound is an intersection type, which `Types` cannot - // create. Use its leftmost bound, which is its erasure; that is sufficient because the client, - // `GenericAnnotatedTypeFactory.isRelevant`, erases the type before testing relevance. - // The bound may itself be a type variable, as in ``; the - // recursion terminates because Java forbids a cycle among type variable bounds. - return typeToTypeMirror(bounds.get(0)); - } - /** * Returns the relative directory that corresponds to a package name. For example, if {@code * separatorChar} is {@code '/'}, then the package name {@code "org.example"} yields {@code diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index 5d3a2a6bab91..e096e1477047 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -5,6 +5,7 @@ import com.github.javaparser.ast.CompilationUnit; import com.github.javaparser.ast.ImportDeclaration; import com.github.javaparser.ast.Node; +import com.github.javaparser.ast.NodeList; import com.github.javaparser.ast.body.AnnotationDeclaration; import com.github.javaparser.ast.body.BodyDeclaration; import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; @@ -1099,6 +1100,10 @@ public static TypeKind typeKindForPrimitive(PrimitiveType primitiveType) { * cannot be determined for an intersection type, a union type, {@code var}, a wildcard, a type * parameter declaration, or a type that is not on the classpath. * + *

A use of a type variable has no TypeMirror here, so this returns the type variable's + * effective upper bound, as computed by {@link #typeVariableUpperBound}. A client that + * distinguishes a type variable from its upper bound should call {@link #resolveName} instead. + * * @param elements used for looking up names * @param types used for creating types * @param type a JavaParser type @@ -1121,13 +1126,58 @@ public static TypeKind typeKindForPrimitive(PrimitiveType primitiveType) { return types.getNoType(TypeKind.VOID); } if (type instanceof ClassOrInterfaceType classType) { - TypeElement typeElt = resolveTypeName(elements, classType, cache); - return typeElt == null ? null : typeElt.asType(); + // Resolving the name once yields both what it might name: a type or a type variable. + // Looking each up separately would walk the enclosing scopes twice. + ResolvedName resolved = resolveName(elements, classType, cache); + TypeElement typeElt = resolved.typeElement(); + if (typeElt != null) { + return typeElt.asType(); + } + TypeParameter typeParameter = resolved.typeParameter(); + if (typeParameter != null) { + // A type variable has no TypeMirror here, so use its upper bound. + return typeVariableUpperBound(elements, types, typeParameter, cache); + } + return null; } // An intersection type, a union type, `var`, a wildcard, a type parameter declaration, etc. return null; } + /** + * Returns the TypeMirror for the given type variable's upper bound, or null if the upper bound + * cannot be determined. + * + *

When the upper bound is an intersection type, which {@code Types} cannot create, this + * returns the intersection type's erasure -- that is, its leftmost bound. + * + * @param elements used for looking up names + * @param types used for creating types + * @param typeParameter the declaration of a type variable + * @param cache maps a name to the type it names, or to null if it names no type; this method both + * reads and writes it. See {@link #resolveTypeName(Elements, ClassOrInterfaceType, Map)} for + * restrictions on it. + * @return the TypeMirror for the upper bound of {@code typeParameter}, erased if that bound is an + * intersection type, or null + */ + public static @Nullable TypeMirror typeVariableUpperBound( + Elements elements, + Types types, + TypeParameter typeParameter, + Map cache) { + NodeList bounds = typeParameter.getTypeBound(); + if (bounds.isEmpty()) { + // The implicit upper bound is `Object`. + TypeElement objectElt = elements.getTypeElement("java.lang.Object"); + return objectElt == null ? null : objectElt.asType(); + } + // If there are multiple bounds, the upper bound is an intersection type, which `Types` cannot + // create. Use its leftmost bound, which is its erasure. + // The bound may itself be a type variable, as in ``; the + // recursion terminates because Java forbids a cycle among type variable bounds. + return typeToTypeMirror(elements, types, bounds.get(0), cache); + } + // // Perform side effects // From 993a1c5d95ba33ae9a3ccdc4a6b2c4c86b7e3302 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Mon, 21 Sep 2026 13:38:53 -0700 Subject: [PATCH 56/58] Address code review `typeToTypeMirror` again returns null for a use of a type variable; the new `typeToTypeMirrorOrUpperBound` substitutes the upper bound, which is sound only for a client that erases the result. Reduce the visibility of `ResolvedName`, `resolveName`, and `typeVariableUpperBound`, and delete the unused `resolveTypeVariableName`. --- .../IrrelevantTypeVariable.ajava.goal | 4 +- .../non-annotated/IrrelevantTypeVariable.java | 4 +- ...holeProgramInferenceJavaParserStorage.java | 10 +- .../framework/util/JavaParserUtil.java | 143 ++++++++++-------- 4 files changed, 87 insertions(+), 74 deletions(-) diff --git a/checker/tests/ainfer-relevance/IrrelevantTypeVariable.ajava.goal b/checker/tests/ainfer-relevance/IrrelevantTypeVariable.ajava.goal index 4da20a7a8ca0..a6afd7edb33e 100644 --- a/checker/tests/ainfer-relevance/IrrelevantTypeVariable.ajava.goal +++ b/checker/tests/ainfer-relevance/IrrelevantTypeVariable.ajava.goal @@ -3,8 +3,8 @@ import org.checkerframework.checker.testchecker.ainfer.qual.AinferSibling1; // A use of a type variable is relevant only if the type variable's upper bound is relevant. // `Number` is not listed in the checker's `@RelevantJavaTypes` and is not related to a listed type // by subtyping, so inference must not write, into the .ajava file, an annotation that it infers -// for a use of `T`. The checker rejects the annotation that is written below, for the same -// reason, in both passes of this test. +// for a use of `T`. The checker rejects such an annotation, for the same reason, in both passes +// of this test. @org.checkerframework.framework.qual.AnnotatedFor("org.checkerframework.checker.testchecker.ainfer.AinferRelevanceTestChecker") public class IrrelevantTypeVariable { diff --git a/checker/tests/ainfer-relevance/non-annotated/IrrelevantTypeVariable.java b/checker/tests/ainfer-relevance/non-annotated/IrrelevantTypeVariable.java index 1120b249a023..1fbf583f5404 100644 --- a/checker/tests/ainfer-relevance/non-annotated/IrrelevantTypeVariable.java +++ b/checker/tests/ainfer-relevance/non-annotated/IrrelevantTypeVariable.java @@ -3,8 +3,8 @@ // A use of a type variable is relevant only if the type variable's upper bound is relevant. // `Number` is not listed in the checker's `@RelevantJavaTypes` and is not related to a listed type // by subtyping, so inference must not write, into the .ajava file, an annotation that it infers -// for a use of `T`. The checker rejects the annotation that is written below, for the same -// reason, in both passes of this test. +// for a use of `T`. The checker rejects such an annotation, for the same reason, in both passes +// of this test. public class IrrelevantTypeVariable { T field; diff --git a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java index 6c55f2fdff2c..f06f10a37b0f 100644 --- a/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java +++ b/framework/src/main/java/org/checkerframework/common/wholeprograminference/WholeProgramInferenceJavaParserStorage.java @@ -1183,8 +1183,9 @@ boolean annotationIsRelevant(AnnotationExpr anno) { if (parentNode instanceof Type type) { // JavaParser's `TypeParameter` is a `Type`, so an annotation on a type parameter - // declaration, as in `<@Anno T>`, takes this branch. `JavaParserUtil.typeToTypeMirror` - // returns null for a type parameter declaration, so such an annotation is retained. + // declaration, as in `<@Anno T>`, takes this branch. + // `JavaParserUtil.typeToTypeMirrorOrUpperBound` returns null for a type parameter + // declaration, so such an annotation is retained. return typeIsRelevant(gatf, type); } if (parentNode instanceof ArrayCreationLevel level) { @@ -1264,8 +1265,11 @@ private boolean typeIsRelevant(GenericAnnotatedTypeFactory gatf, Typ private boolean typeIsRelevant( GenericAnnotatedTypeFactory gatf, Type componentType, int arrayLevels) { Types types = atypeFactory.getProcessingEnv().getTypeUtils(); + // Use the upper bound of a type variable. That is sound here because `isRelevant` erases its + // argument, and the erasure of a type variable is the erasure of its upper bound. TypeMirror tm = - JavaParserUtil.typeToTypeMirror(elements, types, componentType, typeElementCache); + JavaParserUtil.typeToTypeMirrorOrUpperBound( + elements, types, componentType, typeElementCache); if (tm == null) { // The type could not be determined. Be conservative. return true; diff --git a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java index e096e1477047..5061144a069f 100644 --- a/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java +++ b/framework/src/main/java/org/checkerframework/framework/util/JavaParserUtil.java @@ -70,8 +70,7 @@ private JavaParserUtil() { * Returns the element for the given JavaParser type, whose name is resolved in the scope of the * type declarations and the compilation unit that contain it. Returns null if the name cannot be * resolved: it names a type variable, a local class, a member of a local or anonymous class, or a - * type that is not on the classpath. To learn whether the name names a type variable, and which - * one, call {@link #resolveTypeVariableName(Elements, ClassOrInterfaceType, Map)}. + * type that is not on the classpath. * *

A client that resolves many names should call {@link #resolveTypeName(Elements, * ClassOrInterfaceType, Map)}, which memoizes the name lookups. @@ -89,8 +88,7 @@ private JavaParserUtil() { * Returns the element for the given JavaParser type, whose name is resolved in the scope of the * type declarations and the compilation unit that contain it. Returns null if the name cannot be * resolved: it names a type variable, a local class, a member of a local or anonymous class, or a - * type that is not on the classpath. To learn whether the name names a type variable, and which - * one, call {@link #resolveTypeVariableName(Elements, ClassOrInterfaceType, Map)}. + * type that is not on the classpath. * *

Resolving one name looks up many candidate names, most of which name no type, so a client * that resolves many names should pass the same cache to each call. A cache should not be reused @@ -109,46 +107,6 @@ private JavaParserUtil() { return resolveName(elements, type, cache).typeElement(); } - /** - * Returns the declaration of the type variable that the given JavaParser type names, or null if - * the name does not name a type variable. The name is resolved in the scope of the type - * declarations and the compilation unit that contain it, so the result is null if a type - * declaration shadows a type variable of the same name. - * - *

A client that resolves many names should call {@link #resolveTypeVariableName(Elements, - * ClassOrInterfaceType, Map)}, which memoizes the name lookups. - * - * @param elements used for looking up names - * @param type a JavaParser class or interface type - * @return the declaration of the type variable that {@code type} names, or null if {@code type} - * does not name a type variable - */ - public static @Nullable TypeParameter resolveTypeVariableName( - Elements elements, ClassOrInterfaceType type) { - return resolveTypeVariableName(elements, type, new HashMap<>(4)); - } - - /** - * Returns the declaration of the type variable that the given JavaParser type names, or null if - * the name does not name a type variable. The name is resolved in the scope of the type - * declarations and the compilation unit that contain it, so the result is null if a type - * declaration shadows a type variable of the same name. - * - *

A name with more than one component, as in {@code T.Inner}, never names a type variable, - * because a type variable has no member types. - * - * @param elements used for looking up names - * @param type a JavaParser class or interface type - * @param cache maps a name to the type it names, or to null if it names no type; this method both - * reads and writes it - * @return the declaration of the type variable that {@code type} names, or null if {@code type} - * does not name a type variable - */ - public static @Nullable TypeParameter resolveTypeVariableName( - Elements elements, ClassOrInterfaceType type, Map cache) { - return resolveName(elements, type, cache).typeParameter(); - } - /** * What a type name refers to: a type, a type variable, or neither. At most one component is * non-null. @@ -157,7 +115,7 @@ private JavaParserUtil() { * @param typeParameter the declaration of the type variable that the name refers to, or null if * it refers to no type variable */ - public record ResolvedName( + private record ResolvedName( @Nullable TypeElement typeElement, @Nullable TypeParameter typeParameter) { /** A name that refers to neither a type nor a type variable. */ @@ -190,10 +148,12 @@ private static ResolvedName of(TypeParameter typeParameter) { * contain it. * *

Resolving a name walks the enclosing scopes and searches supertypes, which is far more work - * than the cache avoids. A client that needs to know both whether the name names a type and - * whether it names a type variable should call this method once, rather than calling both {@link - * #resolveTypeName(Elements, ClassOrInterfaceType, Map)} and {@link - * #resolveTypeVariableName(Elements, ClassOrInterfaceType, Map)}, each of which repeats the walk. + * than the cache avoids. A caller that needs to know both whether the name names a type and + * whether it names a type variable should call this method once, rather than asking the two + * questions separately, because each call repeats the walk. + * + *

A name with more than one component, as in {@code T.Inner}, never names a type variable, + * because a type variable has no member types. * * @param elements used for looking up names * @param type a JavaParser class or interface type @@ -202,14 +162,14 @@ private static ResolvedName of(TypeParameter typeParameter) { * restrictions on it. * @return what the name of {@code type} refers to */ - public static ResolvedName resolveName( + private static ResolvedName resolveName( Elements elements, ClassOrInterfaceType type, Map cache) { return resolveName(elements, type, cache, new IdentityHashMap<>(4)); } /** * Returns what the given JavaParser type's name refers to, as {@link #resolveName(Elements, - * ClassOrInterfaceType, Map)} does. This method also reads and writes a cache, for efficiency. + * ClassOrInterfaceType, Map)} does. This method also memoizes its recursive calls. * * @param elements used for looking up names * @param type a JavaParser class or interface type @@ -1097,12 +1057,8 @@ public static TypeKind typeKindForPrimitive(PrimitiveType primitiveType) { /** * Returns the TypeMirror for the given JavaParser type, or null if it cannot be determined. It - * cannot be determined for an intersection type, a union type, {@code var}, a wildcard, a type - * parameter declaration, or a type that is not on the classpath. - * - *

A use of a type variable has no TypeMirror here, so this returns the type variable's - * effective upper bound, as computed by {@link #typeVariableUpperBound}. A client that - * distinguishes a type variable from its upper bound should call {@link #resolveName} instead. + * cannot be determined for a use of a type variable, an intersection type, a union type, {@code + * var}, a wildcard, a type parameter declaration, or a type that is not on the classpath. * * @param elements used for looking up names * @param types used for creating types @@ -1114,9 +1070,59 @@ public static TypeKind typeKindForPrimitive(PrimitiveType primitiveType) { */ public static @Nullable TypeMirror typeToTypeMirror( Elements elements, Types types, Type type, Map cache) { + return typeToTypeMirror(elements, types, type, cache, false); + } + + /** + * Returns the TypeMirror for the given JavaParser type, in which each use of a type variable is + * replaced by the type variable's upper bound, or by that bound's leftmost bound if the upper + * bound is an intersection type. Returns null if the result cannot be determined, which is the + * case for an intersection type, a union type, {@code var}, a wildcard, a type parameter + * declaration, or a type that is not on the classpath. + * + *

The result is not the type that {@code type} names, so a client that distinguishes a type + * variable from its upper bound should call {@link #typeToTypeMirror} instead. Replacing a type + * variable by its upper bound is sound only for a client that erases the result, such as one that + * determines whether a type qualifier is relevant: the erasure of a type variable is the erasure + * of its leftmost bound. + * + * @param elements used for looking up names + * @param types used for creating types + * @param type a JavaParser type + * @param cache maps a name to the type it names, or to null if it names no type; this method both + * reads and writes it. See {@link #resolveTypeName(Elements, ClassOrInterfaceType, Map)} for + * restrictions on it. + * @return the TypeMirror for {@code type} with each type variable replaced by its upper bound, or + * null if it cannot be determined + */ + public static @Nullable TypeMirror typeToTypeMirrorOrUpperBound( + Elements elements, Types types, Type type, Map cache) { + return typeToTypeMirror(elements, types, type, cache, true); + } + + /** + * Returns the TypeMirror for the given JavaParser type, or null if it cannot be determined. + * + * @param elements used for looking up names + * @param types used for creating types + * @param type a JavaParser type + * @param cache maps a name to the type it names, or to null if it names no type; this method both + * reads and writes it. See {@link #resolveTypeName(Elements, ClassOrInterfaceType, Map)} for + * restrictions on it. + * @param substituteUpperBounds if true, a use of a type variable yields the type variable's upper + * bound; if false, it yields null + * @return the TypeMirror for {@code type}, or null if it cannot be determined + */ + private static @Nullable TypeMirror typeToTypeMirror( + Elements elements, + Types types, + Type type, + Map cache, + boolean substituteUpperBounds) { if (type instanceof ArrayType arrayType) { TypeMirror componentType = - typeToTypeMirror(elements, types, arrayType.getComponentType(), cache); + typeToTypeMirror( + elements, types, arrayType.getComponentType(), cache, substituteUpperBounds); return componentType == null ? null : types.getArrayType(componentType); } if (type instanceof PrimitiveType primitiveType) { @@ -1135,8 +1141,10 @@ public static TypeKind typeKindForPrimitive(PrimitiveType primitiveType) { } TypeParameter typeParameter = resolved.typeParameter(); if (typeParameter != null) { - // A type variable has no TypeMirror here, so use its upper bound. - return typeVariableUpperBound(elements, types, typeParameter, cache); + // A type variable has no TypeMirror here. + return substituteUpperBounds + ? typeVariableUpperBound(elements, types, typeParameter, cache) + : null; } return null; } @@ -1149,7 +1157,8 @@ public static TypeKind typeKindForPrimitive(PrimitiveType primitiveType) { * cannot be determined. * *

When the upper bound is an intersection type, which {@code Types} cannot create, this - * returns the intersection type's erasure -- that is, its leftmost bound. + * returns the intersection type's leftmost bound, which has the same erasure as the intersection + * type. * * @param elements used for looking up names * @param types used for creating types @@ -1157,10 +1166,10 @@ public static TypeKind typeKindForPrimitive(PrimitiveType primitiveType) { * @param cache maps a name to the type it names, or to null if it names no type; this method both * reads and writes it. See {@link #resolveTypeName(Elements, ClassOrInterfaceType, Map)} for * restrictions on it. - * @return the TypeMirror for the upper bound of {@code typeParameter}, erased if that bound is an - * intersection type, or null + * @return the TypeMirror for the upper bound of {@code typeParameter}, or that bound's leftmost + * bound if it is an intersection type, or null */ - public static @Nullable TypeMirror typeVariableUpperBound( + private static @Nullable TypeMirror typeVariableUpperBound( Elements elements, Types types, TypeParameter typeParameter, @@ -1168,14 +1177,14 @@ public static TypeKind typeKindForPrimitive(PrimitiveType primitiveType) { NodeList bounds = typeParameter.getTypeBound(); if (bounds.isEmpty()) { // The implicit upper bound is `Object`. - TypeElement objectElt = elements.getTypeElement("java.lang.Object"); + TypeElement objectElt = getTypeElement(elements, "java.lang.Object", cache); return objectElt == null ? null : objectElt.asType(); } // If there are multiple bounds, the upper bound is an intersection type, which `Types` cannot - // create. Use its leftmost bound, which is its erasure. + // create. Use its leftmost bound, which has the same erasure. // The bound may itself be a type variable, as in ``; the // recursion terminates because Java forbids a cycle among type variable bounds. - return typeToTypeMirror(elements, types, bounds.get(0), cache); + return typeToTypeMirror(elements, types, bounds.get(0), cache, true); } // From 98d2bfb58d1a85c51a68942ecbc4f1b18892605b Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Mon, 21 Sep 2026 17:17:02 -0700 Subject: [PATCH 57/58] Avoid a nullness warning in AnnotationFileParser.sameTypeArguments --- .../framework/stub/AnnotationFileParser.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/framework/stub/AnnotationFileParser.java b/framework/src/main/java/org/checkerframework/framework/stub/AnnotationFileParser.java index 82507eeaf096..244b7bff8608 100644 --- a/framework/src/main/java/org/checkerframework/framework/stub/AnnotationFileParser.java +++ b/framework/src/main/java/org/checkerframework/framework/stub/AnnotationFileParser.java @@ -2242,14 +2242,17 @@ private boolean sameTypeArguments(TypeMirror javacType, ClassOrInterfaceType jav ClassOrInterfaceType javaParserScope = javaParserType.getScope().orElse(null); // If no enclosing type is written with type arguments, then the enclosing types need not be // compared, just as a type with no type arguments matches any type arguments. - boolean compareScope = javaParserScope != null && writesTypeArguments(javaParserScope); - if (javaParserTypeArgs == null && !compareScope) { + if (javaParserScope != null && !writesTypeArguments(javaParserScope)) { + javaParserScope = null; + } + if (javaParserTypeArgs == null && javaParserScope == null) { return true; } if (!(javacType instanceof DeclaredType javacDeclaredType)) { return false; } - if (compareScope && !sameTypeArguments(javacDeclaredType.getEnclosingType(), javaParserScope)) { + if (javaParserScope != null + && !sameTypeArguments(javacDeclaredType.getEnclosingType(), javaParserScope)) { return false; } if (javaParserTypeArgs == null) { From 306a87e98bdfddb5039a1966e28650cee8e53d76 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Mon, 21 Sep 2026 17:58:39 -0700 Subject: [PATCH 58/58] Avoid a nullness warning in AnnotationFileParser.sameTypeArguments Track the enclosing type to compare in a single nullable variable, so that the nullness checker can relate the null check to the use. --- .../framework/stub/AnnotationFileParser.java | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/framework/src/main/java/org/checkerframework/framework/stub/AnnotationFileParser.java b/framework/src/main/java/org/checkerframework/framework/stub/AnnotationFileParser.java index 82507eeaf096..931c19e9d30c 100644 --- a/framework/src/main/java/org/checkerframework/framework/stub/AnnotationFileParser.java +++ b/framework/src/main/java/org/checkerframework/framework/stub/AnnotationFileParser.java @@ -2239,17 +2239,21 @@ && sameType( */ private boolean sameTypeArguments(TypeMirror javacType, ClassOrInterfaceType javaParserType) { NodeList javaParserTypeArgs = javaParserType.getTypeArguments().orElse(null); - ClassOrInterfaceType javaParserScope = javaParserType.getScope().orElse(null); - // If no enclosing type is written with type arguments, then the enclosing types need not be + // The enclosing type to compare, or null if the enclosing types need not be compared. If no + // enclosing type is written with type arguments, then the enclosing types need not be // compared, just as a type with no type arguments matches any type arguments. - boolean compareScope = javaParserScope != null && writesTypeArguments(javaParserScope); - if (javaParserTypeArgs == null && !compareScope) { + ClassOrInterfaceType javaParserScope = javaParserType.getScope().orElse(null); + if (javaParserScope != null && !writesTypeArguments(javaParserScope)) { + javaParserScope = null; + } + if (javaParserTypeArgs == null && javaParserScope == null) { return true; } if (!(javacType instanceof DeclaredType javacDeclaredType)) { return false; } - if (compareScope && !sameTypeArguments(javacDeclaredType.getEnclosingType(), javaParserScope)) { + if (javaParserScope != null + && !sameTypeArguments(javacDeclaredType.getEnclosingType(), javaParserScope)) { return false; } if (javaParserTypeArgs == null) {