Skip to content

Harden TypeSubstitutionUtils type copies - #1669

Open
msridhar wants to merge 2 commits into
masterfrom
type-substitution-copies
Open

Harden TypeSubstitutionUtils type copies#1669
msridhar wants to merge 2 commits into
masterfrom
type-substitution-copies

Conversation

@msridhar

@msridhar msridhar commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

This is a preparatory PR for #1668. We extend TypeSubstitutionUtils to better handle unbounded wildcards, by restoring upper bound annotations onto a copy of the underlying bound formal type variable. This leads to two subtleties:

  1. Cyclic bounds due to F-bounded polymorphism. We detect cycles and bail out (see the activeUnboundedWildcardBounds map and its usages).
  2. It's important to copy TypeVar objects (and captured types) when modifying their bounds, as mutating the original objects makes the changes visible to other parts of javac (like other Error Prone checkers). But, the copied objects need to return the same value from the baseType() method as the original objects, so javac still treats it as the "same" type variable in internal assignability checks.

As part of handling the above we also had to add some functionality to TypeMetadataBuilder to make the appropriate copies, and to make certain cloning methods more defensive regarding type variables to avoid future bugs.

We add direct unit tests in TypeSubstitutionUtilsTests; the checker-level tests that require the changes above are in #1668.

Summary by CodeRabbit

  • Bug Fixes

    • Improved handling of generic type substitutions, wildcard bounds, captured types, and type variables.
    • Preserved type metadata, annotations, symbols, bounds, and type relationships during transformations.
    • Prevented issues caused by shared or mutable compiler type state, including recursive generic bounds.
  • Tests

    • Added coverage for wildcard replacement, captured types, type variables, annotations, metadata isolation, and compiler type safety.

@msridhar

msridhar commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.34177% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.70%. Comparing base (b7342a6) to head (e4d8783).

Files with missing lines Patch % Lines
.../uber/nullaway/generics/TypeSubstitutionUtils.java 84.21% 1 Missing and 5 partials ⚠️
...om/uber/nullaway/generics/TypeMetadataBuilder.java 90.24% 4 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master    #1669      +/-   ##
============================================
- Coverage     87.71%   87.70%   -0.02%     
- Complexity     3136     3143       +7     
============================================
  Files           109      109              
  Lines         10614    10686      +72     
  Branches       2144     2156      +12     
============================================
+ Hits           9310     9372      +62     
- Misses          626      631       +5     
- Partials        678      683       +5     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@msridhar
msridhar marked this pull request as ready for review August 1, 2026 03:48
@msridhar
msridhar requested a review from lazaroclapp August 1, 2026 03:48
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

TypeMetadataBuilder now creates detached type variables and captured types for JDK 17-and-earlier and JDK 21+ compiler paths. TypeSubstitutionUtils uses detached types for wildcard-bound replacement and annotation restoration. Compiler-backed tests verify metadata, bounds, symbols, base types, lower bounds, and annotations.

Possibly related PRs

Suggested reviewers: lazaroclapp

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 51.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: hardening type-copy behavior in TypeSubstitutionUtils.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch type-substitution-copies

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@nullaway/src/test/java/com/uber/nullaway/generics/TypeSubstitutionUtilsTests.java`:
- Around line 90-104: Update matchVariable to filter fieldName against the four
supported field-name constants before calling createTestTypeContext, returning
NO_MATCH for unrelated declarations. Build the context only after this
selection, preserving the existing switch dispatch and checks; add ImmutableSet
if needed to represent the allowed names.
- Around line 32-70: Update the three tests in TypeSubstitutionUtilsTests and
TypeCopyIsolationChecker to track when each intended type-copy scenario is
matched. Reset the checker’s match counter before each doTest() call and assert
the expected count afterward, ensuring field-name drift or missed matchVariable
branches cannot produce a false passing test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 7409ef31-0ea3-4524-87cf-9c4b2f531168

📥 Commits

Reviewing files that changed from the base of the PR and between 1eeae57 and 6dd76de.

📒 Files selected for processing (3)
  • nullaway/src/main/java/com/uber/nullaway/generics/TypeMetadataBuilder.java
  • nullaway/src/main/java/com/uber/nullaway/generics/TypeSubstitutionUtils.java
  • nullaway/src/test/java/com/uber/nullaway/generics/TypeSubstitutionUtilsTests.java

Comment on lines +32 to +70
@Test
public void replaceUnboundedWildcardUpperBoundDoesNotMutateFormalTypeVariable() {
CompilationTestHelper.newInstance(TypeCopyIsolationChecker.class, getClass())
.addSourceLines(
"Test.java",
"""
class Test<T> {
Test<?> typeVarField;
}
""")
.doTest();
}

@Test
public void replaceCapturedTypeWildcardReturnsDetachedCapture() {
CompilationTestHelper.newInstance(TypeCopyIsolationChecker.class, getClass())
.addSourceLines(
"Test.java",
"""
class Test<T> {
Test<?> capturedTypeField;
}
""")
.doTest();
}

@Test
public void cloneTypeWithMetadataReturnsDetachedMutableTypes() {
CompilationTestHelper.newInstance(TypeCopyIsolationChecker.class, getClass())
.addSourceLines(
"Test.java",
"""
class Test<T> {
Test<?> typeVarMetadataField;
Test<?> capturedTypeMetadataField;
}
""")
.doTest();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check how Error Prone surfaces an exception thrown by a BugChecker under CompilationTestHelper.
set -euo pipefail

fd -t f 'ErrorProneAnalyzer.java|CompilationTestHelper.java' | xargs -r rg -n -C6 'catch\s*\(\s*(Throwable|RuntimeException|Error)'
rg -n -C4 'CompilationTestHelper' --type=java nullaway/src/test | head -60

Repository: uber/NullAway

Length of output: 6774


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Locate relevant files:\n'
fd -t f 'TypeSubstitutionUtilsTests.java|TypeCopyIsolationChecker.java|NullAwayTestsBase.java' nullaway/src/test | sort

printf '\nRelevant test file outline/sections:\n'
file="$(fd -t f 'TypeSubstitutionUtilsTests.java' nullaway/src/test | head -n1)"
if [ -n "${file:-}" ]; then
  wc -l "$file"
  ast-grep outline "$file" --view expanded || true
  cat -n "$file"
fi

printf '\nChecker source:\n'
checker="$(fd -t f 'TypeCopyIsolationChecker.java' nullaway/src/test | head -n1)"
if [ -n "${checker:-}" ]; then
  wc -l "$checker"
  ast-grep outline "$checker" --view expanded || true
  cat -n "$checker"
fi

printf '\nBase helper sections mentioning doTest/scanSourceFile/doCheck:\n'
base="$(fd -t f 'NullAwayTestsBase.java' nullaway/src/test | head -n1)"
if [ -n "${base:-}" ]; then
  rg -n -C8 'doTest| CompilationTestHelper|checkSourceFile|errorprone|BugChecker' "$base"
fi

printf '\nOther doTest usages with assertions in generics tests:\n'
fd -t f '.*Tests.java' nullaway/src/test/java/com/uber/nullaway/generics | xargs -r rg -n -C5 'CompilationTestHelper.*newInstanceOf|addSourceLines|doTest\(\)|assertThat\(.*SCENARIO|assertThrows' | head -n 200

Repository: uber/NullAway

Length of output: 16342


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect ErrorProne CompilationTestHelper source available to the repo's dependencies.
# Use maven local cache without modifying repository files.
if [ -d "$HOME/.m2/repository/com/google/errorprone" ]; then
  echo "ERROR_PRONE_MVN=\"$HOME/.m2/repository/com/google/errorprone\""
  find "$HOME/.m2/repository/com/google/errorprone" -name 'errorprone-checks*.jar' -o -name 'error-prone-checks*.jar' | sort | tail -10
else
  echo "No local Google Error Prone M2 cache found."
fi

# Search external sources if needed for CompilationTestHelper.throws runtime behavior.
web_search "Google Error Prone CompilationTestHelper doTest compiler run checkers exception thrown from BugChecker"

Repository: uber/NullAway

Length of output: 244


🌐 Web query:

Google Error Prone CompilationTestHelper doTest throws AssertionError compiler diagnostic uncaught exception BugChecker

💡 Result:

When using the Google Error Prone CompilationTestHelper.doTest() method, encountering an AssertionError with a message containing "uncaught exception" or similar indicators typically suggests that the BugChecker under test has triggered an unhandled exception or failed to meet expected diagnostic criteria during compilation [1][2][3]. Key causes and troubleshooting steps include: 1. Internal Crashes: CompilationTestHelper is designed to fail the test if the compiler encounters an internal exception [1][3]. If your BugChecker throws an unchecked exception during analysis, doTest() catches it and reports an AssertionError indicating that the static analysis plugin failed [2]. Examine the stack trace provided in the AssertionError to identify the specific code in your BugChecker that is throwing the exception [2]. 2. Mismatched Expectations: doTest() validates that the compiler output matches your test's expectations [4][5]. An AssertionError will be thrown if: - Expected diagnostics are not found on the specified lines [2][6][3]. - Unexpected diagnostics are emitted [2][6][3]. - The compilation result (success/failure) does not match the expected state [1][3]. Ensure that the // BUG: Diagnostic ... markers in your source code exactly match the expected diagnostic keys and patterns defined in your test [1][5]. 3. Missing Dependencies: If the BugChecker or the code being compiled depends on third-party libraries not present on the test classpath, the compilation may fail or behave unexpectedly [7]. Ensure all necessary dependencies are included in the test classpath [7]. To debug the issue, verify the full stack trace of the AssertionError to determine if the "uncaught exception" originated within your custom BugChecker logic or was a side effect of a failed assertion regarding the compiler's output [2][3]. You can also use expectErrorMessage to more precisely match and diagnose the reported compiler messages [1][8].

Citations:


Assert that the checker actually executed the scenario.

Each test relies on doTest() returning with no diagnostic only. If a field name drifts or matchVariable does not enter the intended branch, the test can pass without exercising the copy behavior. Track the matched scenarios in TypeCopyIsolationChecker, reset the counter before each test, and assert the expected count after doTest().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@nullaway/src/test/java/com/uber/nullaway/generics/TypeSubstitutionUtilsTests.java`
around lines 32 - 70, Update the three tests in TypeSubstitutionUtilsTests and
TypeCopyIsolationChecker to track when each intended type-copy scenario is
matched. Reset the checker’s match counter before each doTest() call and assert
the expected count afterward, ensuring field-name drift or missed matchVariable
branches cannot produce a false passing test.

Comment on lines +90 to +104
public Description matchVariable(VariableTree tree, VisitorState state) {
String fieldName = tree.getName().toString();
TestTypeContext testTypeContext = createTestTypeContext(tree, state);
switch (fieldName) {
case TYPE_VAR_FIELD -> checkUnboundedWildcardReplacement(testTypeContext);
case CAPTURED_TYPE_FIELD -> checkCapturedWildcardReplacement(testTypeContext, tree, state);
case TYPE_VAR_METADATA_FIELD -> checkTypeVariableMetadataCopy(testTypeContext);
case CAPTURED_TYPE_METADATA_FIELD ->
checkCapturedTypeMetadataCopy(testTypeContext, tree, state);
default -> {
return NO_MATCH;
}
}
return NO_MATCH;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Build the type context only for the selected field names.

matchVariable runs for every variable declaration in the compiled source. Line 92 creates the context before the switch, so the casts in createTestTypeContext execute for unrelated declarations. A declaration whose type is not a parameterized type with a wildcard first type argument then throws ClassCastException or dereferences a null head. This contradicts the Javadoc on Line 78, which states that other variable declarations are ignored.

Filter the field name first.

🧪 Proposed fix
+    private static final ImmutableSet<String> SCENARIO_FIELDS =
+        ImmutableSet.of(
+            TYPE_VAR_FIELD,
+            CAPTURED_TYPE_FIELD,
+            TYPE_VAR_METADATA_FIELD,
+            CAPTURED_TYPE_METADATA_FIELD);
+
     `@Override`
     public Description matchVariable(VariableTree tree, VisitorState state) {
       String fieldName = tree.getName().toString();
+      if (!SCENARIO_FIELDS.contains(fieldName)) {
+        return NO_MATCH;
+      }
       TestTypeContext testTypeContext = createTestTypeContext(tree, state);
       switch (fieldName) {
         case TYPE_VAR_FIELD -> checkUnboundedWildcardReplacement(testTypeContext);
         case CAPTURED_TYPE_FIELD -> checkCapturedWildcardReplacement(testTypeContext, tree, state);
         case TYPE_VAR_METADATA_FIELD -> checkTypeVariableMetadataCopy(testTypeContext);
         case CAPTURED_TYPE_METADATA_FIELD ->
             checkCapturedTypeMetadataCopy(testTypeContext, tree, state);
         default -> {
           return NO_MATCH;
         }
       }
       return NO_MATCH;
     }

Add the import:

import com.google.common.collect.ImmutableSet;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public Description matchVariable(VariableTree tree, VisitorState state) {
String fieldName = tree.getName().toString();
TestTypeContext testTypeContext = createTestTypeContext(tree, state);
switch (fieldName) {
case TYPE_VAR_FIELD -> checkUnboundedWildcardReplacement(testTypeContext);
case CAPTURED_TYPE_FIELD -> checkCapturedWildcardReplacement(testTypeContext, tree, state);
case TYPE_VAR_METADATA_FIELD -> checkTypeVariableMetadataCopy(testTypeContext);
case CAPTURED_TYPE_METADATA_FIELD ->
checkCapturedTypeMetadataCopy(testTypeContext, tree, state);
default -> {
return NO_MATCH;
}
}
return NO_MATCH;
}
private static final ImmutableSet<String> SCENARIO_FIELDS =
ImmutableSet.of(
TYPE_VAR_FIELD,
CAPTURED_TYPE_FIELD,
TYPE_VAR_METADATA_FIELD,
CAPTURED_TYPE_METADATA_FIELD);
`@Override`
public Description matchVariable(VariableTree tree, VisitorState state) {
String fieldName = tree.getName().toString();
if (!SCENARIO_FIELDS.contains(fieldName)) {
return NO_MATCH;
}
TestTypeContext testTypeContext = createTestTypeContext(tree, state);
switch (fieldName) {
case TYPE_VAR_FIELD -> checkUnboundedWildcardReplacement(testTypeContext);
case CAPTURED_TYPE_FIELD -> checkCapturedWildcardReplacement(testTypeContext, tree, state);
case TYPE_VAR_METADATA_FIELD -> checkTypeVariableMetadataCopy(testTypeContext);
case CAPTURED_TYPE_METADATA_FIELD ->
checkCapturedTypeMetadataCopy(testTypeContext, tree, state);
default -> {
return NO_MATCH;
}
}
return NO_MATCH;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@nullaway/src/test/java/com/uber/nullaway/generics/TypeSubstitutionUtilsTests.java`
around lines 90 - 104, Update matchVariable to filter fieldName against the four
supported field-name constants before calling createTestTypeContext, returning
NO_MATCH for unrelated declarations. Build the context only after this
selection, preserving the existing switch dispatch and checks; add ImmutableSet
if needed to represent the allowed names.

@msridhar
msridhar force-pushed the follow-up-on-wildcard-library-models branch from 1eeae57 to 78d77c1 Compare August 2, 2026 02:09
@msridhar
msridhar force-pushed the type-substitution-copies branch from 6dd76de to 8299787 Compare August 2, 2026 02:09

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@nullaway/src/main/java/com/uber/nullaway/generics/TypeMetadataBuilder.java`:
- Around line 87-123: Update createDetachedCapturedTypeWithoutMetadata so
CapturedType construction remains compatible across all supported JDK versions,
including JDK 21+, rather than always calling the legacy five-argument
constructor. Add the required version-aware or reflective construction path
while preserving the detached type’s upper/lower bounds, wildcard,
compiler-owned tsym, and overridden baseType behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 9e8c8418-2f18-4565-8ea6-66fe2aa41286

📥 Commits

Reviewing files that changed from the base of the PR and between 6dd76de and 8299787.

📒 Files selected for processing (3)
  • nullaway/src/main/java/com/uber/nullaway/generics/TypeMetadataBuilder.java
  • nullaway/src/main/java/com/uber/nullaway/generics/TypeSubstitutionUtils.java
  • nullaway/src/test/java/com/uber/nullaway/generics/TypeSubstitutionUtilsTests.java

@msridhar
msridhar force-pushed the follow-up-on-wildcard-library-models branch from 78d77c1 to f872e52 Compare August 4, 2026 20:37
@msridhar
msridhar force-pushed the type-substitution-copies branch from 8299787 to 70bc1f7 Compare August 4, 2026 20:37

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
nullaway/src/test/java/com/uber/nullaway/generics/TypeSubstitutionUtilsTests.java (1)

90-104: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Filter the field name before you build the type context.

matchVariable runs for every variable declaration in the compiled source. Line 92 calls createTestTypeContext before the switch, so the casts on Lines 111-113 run for unrelated declarations too. A declaration whose type is not a parameterized type with a wildcard first type argument then throws ClassCastException, or dereferences a null head. The Javadoc on Line 78 states that other variable declarations are ignored, so move the name check first.

🧪 Proposed fix
     `@Override`
     public Description matchVariable(VariableTree tree, VisitorState state) {
       String fieldName = tree.getName().toString();
+      if (!SCENARIO_FIELDS.contains(fieldName)) {
+        return NO_MATCH;
+      }
       TestTypeContext testTypeContext = createTestTypeContext(tree, state);
       switch (fieldName) {

Add the constant and import:

import com.google.common.collect.ImmutableSet;

private static final ImmutableSet<String> SCENARIO_FIELDS =
    ImmutableSet.of(
        TYPE_VAR_FIELD,
        CAPTURED_TYPE_FIELD,
        TYPE_VAR_METADATA_FIELD,
        CAPTURED_TYPE_METADATA_FIELD);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@nullaway/src/test/java/com/uber/nullaway/generics/TypeSubstitutionUtilsTests.java`
around lines 90 - 104, Update matchVariable to filter tree.getName() against a
shared SCENARIO_FIELDS set before calling createTestTypeContext, returning
NO_MATCH for unrelated declarations. Add the ImmutableSet import and define
SCENARIO_FIELDS from the four scenario field constants, then retain the existing
switch-specific checks for matched fields.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In
`@nullaway/src/test/java/com/uber/nullaway/generics/TypeSubstitutionUtilsTests.java`:
- Around line 90-104: Update matchVariable to filter tree.getName() against a
shared SCENARIO_FIELDS set before calling createTestTypeContext, returning
NO_MATCH for unrelated declarations. Add the ImmutableSet import and define
SCENARIO_FIELDS from the four scenario field constants, then retain the existing
switch-specific checks for matched fields.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5a899c9e-6b20-4aae-ae6b-3ee0811cfe11

📥 Commits

Reviewing files that changed from the base of the PR and between 8299787 and 70bc1f7.

📒 Files selected for processing (3)
  • nullaway/src/main/java/com/uber/nullaway/generics/TypeMetadataBuilder.java
  • nullaway/src/main/java/com/uber/nullaway/generics/TypeSubstitutionUtils.java
  • nullaway/src/test/java/com/uber/nullaway/generics/TypeSubstitutionUtilsTests.java

@msridhar
msridhar force-pushed the follow-up-on-wildcard-library-models branch from f872e52 to 4794e3f Compare August 5, 2026 18:42
Base automatically changed from follow-up-on-wildcard-library-models to master August 5, 2026 19:07
@msridhar
msridhar force-pushed the type-substitution-copies branch from 70bc1f7 to bcf7e82 Compare August 5, 2026 19:12
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

♻️ Duplicate comments (2)
nullaway/src/test/java/com/uber/nullaway/generics/TypeSubstitutionUtilsTests.java (2)

89-104: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Build the type context only for the selected field names.

matchVariable runs for every variable declaration in the compiled source. Line 92 creates the context before the switch, so the casts in createTestTypeContext run for unrelated declarations. A declaration whose type is not a parameterized type with a wildcard first type argument then throws ClassCastException, or dereferences a null head. This contradicts the Javadoc on Line 78, which states that other variable declarations are ignored.

Filter the field name first.

This repeats an earlier review comment on the same lines.

🧪 Proposed fix
+    private static final ImmutableSet<String> SCENARIO_FIELDS =
+        ImmutableSet.of(
+            TYPE_VAR_FIELD,
+            CAPTURED_TYPE_FIELD,
+            TYPE_VAR_METADATA_FIELD,
+            CAPTURED_TYPE_METADATA_FIELD);
+
     `@Override`
     public Description matchVariable(VariableTree tree, VisitorState state) {
       String fieldName = tree.getName().toString();
+      if (!SCENARIO_FIELDS.contains(fieldName)) {
+        return NO_MATCH;
+      }
       TestTypeContext testTypeContext = createTestTypeContext(tree, state);

Add the import:

import com.google.common.collect.ImmutableSet;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@nullaway/src/test/java/com/uber/nullaway/generics/TypeSubstitutionUtilsTests.java`
around lines 89 - 104, In matchVariable, filter fieldName against the supported
field-name set before calling createTestTypeContext, so unrelated variable
declarations return NO_MATCH without context construction. Add the ImmutableSet
import if needed and preserve the existing checks for TYPE_VAR_FIELD,
CAPTURED_TYPE_FIELD, TYPE_VAR_METADATA_FIELD, and CAPTURED_TYPE_METADATA_FIELD.

32-70: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert that the checker executed each scenario.

Each test relies only on doTest() completing without a diagnostic. If a field name drifts or matchVariable does not enter the intended branch, the test passes without exercising the detached-copy behavior. Track matched scenarios in TypeCopyIsolationChecker and assert the expected count after doTest().

This repeats an earlier review comment on the same lines.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@nullaway/src/test/java/com/uber/nullaway/generics/TypeSubstitutionUtilsTests.java`
around lines 32 - 70, Update the three tests to verify execution of their
intended scenarios, not merely successful compilation: have
TypeCopyIsolationChecker track matches through matchVariable, assert the
expected match count after doTest(), and ensure each test expects the count
corresponding to its declared fields and branch. Keep the detached-copy
assertions and existing test setup unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In
`@nullaway/src/test/java/com/uber/nullaway/generics/TypeSubstitutionUtilsTests.java`:
- Around line 89-104: In matchVariable, filter fieldName against the supported
field-name set before calling createTestTypeContext, so unrelated variable
declarations return NO_MATCH without context construction. Add the ImmutableSet
import if needed and preserve the existing checks for TYPE_VAR_FIELD,
CAPTURED_TYPE_FIELD, TYPE_VAR_METADATA_FIELD, and CAPTURED_TYPE_METADATA_FIELD.
- Around line 32-70: Update the three tests to verify execution of their
intended scenarios, not merely successful compilation: have
TypeCopyIsolationChecker track matches through matchVariable, assert the
expected match count after doTest(), and ensure each test expects the count
corresponding to its declared fields and branch. Keep the detached-copy
assertions and existing test setup unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 494e8169-7830-4118-80a7-6034425eb25f

📥 Commits

Reviewing files that changed from the base of the PR and between b9809cd and bcf7e82.

📒 Files selected for processing (3)
  • nullaway/src/main/java/com/uber/nullaway/generics/TypeMetadataBuilder.java
  • nullaway/src/main/java/com/uber/nullaway/generics/TypeSubstitutionUtils.java
  • nullaway/src/test/java/com/uber/nullaway/generics/TypeSubstitutionUtilsTests.java

@msridhar
msridhar force-pushed the type-substitution-copies branch from bcf7e82 to 010e3c9 Compare August 6, 2026 01:23
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@nullaway/src/main/java/com/uber/nullaway/generics/TypeSubstitutionUtils.java`:
- Around line 458-466: In the unbounded-wildcard branch of the visitor, remove
the silent fallback from t.wildcard.bound to t and enforce the captured-wildcard
backing-bound invariant explicitly. Assert that t.wildcard.bound is non-null
before using it as the formal type variable, or route the null case through a
clearly unimplemented path, while preserving the existing upper-bound
substitution flow for valid captured wildcards.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: cee8dd74-c3b5-4840-8394-cdaf94ff1551

📥 Commits

Reviewing files that changed from the base of the PR and between 2da3fad and 010e3c9.

📒 Files selected for processing (3)
  • nullaway/src/main/java/com/uber/nullaway/generics/TypeMetadataBuilder.java
  • nullaway/src/main/java/com/uber/nullaway/generics/TypeSubstitutionUtils.java
  • nullaway/src/test/java/com/uber/nullaway/generics/TypeSubstitutionUtilsTests.java

Comment on lines +458 to +466
} else if (t.wildcard.kind == BoundKind.UNBOUND) {
Type.TypeVar formalTypeVariable = t.wildcard.bound != null ? t.wildcard.bound : t;
Type upperBound = formalTypeVariable.getUpperBound();
Type updatedUpperBound = upperBound.accept(this, other);
if (updatedUpperBound == upperBound) {
return updated;
}
updatedWildcard =
replaceUnboundedWildcardUpperBound(t.wildcard, formalTypeVariable, updatedUpperBound);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect readers/writers of WildcardType.bound and the unbounded-capture handling.
set -euo pipefail

echo "== writers/readers of WildcardType bound in NullAway =="
rg -nP --type=java -C4 '\.bound\b' nullaway/src/main/java/com/uber/nullaway/generics

echo "== visitCapturedType implementation =="
ast-grep run --pattern $'public Type visitCapturedType($_, $_) { $$$ }' --lang java nullaway/src/main/java/com/uber/nullaway/generics/TypeSubstitutionUtils.java

echo "== tests covering unbounded captured wildcards =="
rg -nP --type=java -C4 'UNBOUND|CapturedType' nullaway/src/test/java/com/uber/nullaway/generics

Repository: uber/NullAway

Length of output: 6571


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate files =="
git ls-files 'nullaway/src/main/java/com/uber/nullaway/generics/*.java' 'nullaway/src/test/java/com/uber/nullaway/generics/*.java'

echo "== TypeSubstitutionUtils relevant sections =="
sed -n '1,200p' nullaway/src/main/java/com/uber/nullaway/generics/TypeSubstitutionUtils.java
echo "== "
sed -n '360,490p' nullaway/src/main/java/com/uber/nullaway/generics/TypeSubstitutionUtils.java

echo "== search for createCapturedType / capture conversions / detacher capture handling =="
rg -nP --type=java -C3 'createCapturedType|CapturedType|replaceCapturedType|visitCapturedType|replaceUnboundedWildcardUpperBound' nullaway/src/main/java nullaway/src/test/java

Repository: uber/NullAway

Length of output: 50369


🌐 Web query:

Java Compiler javac Type.CapturedType TypeVar constructor bound upper bound field

💡 Result:

In the OpenJDK Java compiler (javac), com.sun.tools.javac.code.Type.CapturedType is a static inner class that extends TypeVar [1][2]. It is used to represent type variables created during capture conversion—a process that happens when wildcard type arguments (such as? extends T or? super T) are "captured" into specific type variables [1][3]. Constructor and Fields: The CapturedType constructor typically takes the following parameters [2][4]: - Name name: The name of the captured type variable. - Symbol owner: The owner of the captured type variable. - Type upper: The upper bound. - Type lower: The lower bound. - WildcardType wildcard: The original wildcard from which this type was captured. Internally, CapturedType manages bounds as follows: - Upper Bound: The upper bound is stored in the bound field (inherited from TypeVar) [2][4]. - Lower Bound: It maintains an explicit lower field, which distinguishes it from standard type variables that often have a default lower bound of null or a bottom type [2][4][3]. Because CapturedType extends TypeVar, it participates in the compiler's type system as a type variable, but it specifically carries the metadata of the wildcard it originated from [2][4]. In the compiler's utility class Types.java, operations like cvarUpperBound and cvarLowerBound are used to resolve these bounds during type checking and inference [5][6]. Unlike standard type variables, CapturedType objects are frequently checked by the compiler to enforce rules related to denotability or specific capture-related constraints, such as those arising in anonymous class instantiation [7][8].

Citations:


🌐 Web query:

OpenJDK Types wildUpperBound WildcardType bound javac source

💡 Result:

In the OpenJDK compiler (javac) source code, wildUpperBound is a method defined within the com.sun.tools.javac.code.Types class [1][2]. It is a utility method used to resolve the upper bound of a wildcard type [1][3]. When called with a type (Type t), the wildUpperBound method operates as follows: 1. It checks if the provided type is a wildcard (t.hasTag(WILDCARD)) [1][2]. 2. If it is a wildcard (WildcardType w): - If the wildcard is a super-bounded wildcard (w.isSuperBound, e.g.,? super T), the method returns the wildcard's bound if it exists, or defaults to java.lang.Object (syms.objectType) if the bound is null [1][2]. - If it is not a super-bounded wildcard (e.g.,? extends T or?), it recursively calls wildUpperBound on the underlying type (w.type) [1][2]. 3. If the input is not a wildcard, it returns the type itself [1][2]. This internal javac method should not be confused with the public Java Reflection API interface java.lang.reflect.WildcardType [4][5]. While both deal with wildcard bounds, java.lang.reflect.WildcardType provides the getUpperBounds method as part of the standard Java API for reflection [4][6]. The javac Types.wildUpperBound method is specifically designed for the compiler's internal type system, which handles more complex type representations such as capture variables and internal wildcard structures [1][2]. Internal refactoring (e.g., JDK-8042338) has historically separated this logic into distinct methods like wildUpperBound and cvarUpperBound to clarify whether the target is a wildcard or a captured type variable [7].

Citations:


🌐 Web query:

OpenJDK Types.capture WildcardType CapturedType capture conversion bound field javac source

💡 Result:

Capture conversion is a formal type-checking process defined in the Java Language Specification (JLS 5.1.10) [1][2] used by the Java compiler (javac) to replace wildcard type arguments with fresh, unique type variables (often called "capture variables") [3][4][5]. This allows the compiler to perform type-safe analysis on expressions involving wildcards that would otherwise be ambiguous or impossible to verify [3][4][5]. In the OpenJDK javac implementation, this logic is primarily centralized in the com.sun.tools.javac.code.Types class [6][7]. Key technical components and mechanisms include: 1. Types.capture(Type t): This is the primary method in the javac codebase used to initiate the capture conversion process [8][9][10]. It processes a type by identifying wildcard components and generating corresponding CapturedType representations [6]. 2. CapturedType: This is a specialized internal representation within javac (typically extending or wrapping a TypeVar) used to model the fresh type variables created during capture [6][7]. A CapturedType explicitly tracks the wildcard from which it was "captured," including its upper and lower bounds as derived from the original wildcard [1][2]. 3. Bounds Handling: When javac performs capture, it adheres to the JLS rules: - For a wildcard?, the fresh variable has an upper bound derived from the original type parameter's declaration and a lower bound of the null type [1][2]. - For? extends B, the upper bound is the intersection (glb - greatest lower bound) of the wildcard's bound B and the type parameter's declared bound [1][2]. - For? super B, the lower bound is B [1][2]. 4. Implementation Context: The compiler uses these capture variables during various phases of type checking, including subtyping checks (isSubtype) and type inference [6][11][12]. Because capture conversion is sensitive to the surrounding expression, javac carefully applies it in specific contexts—such as method invocations—to maintain consistency with the JLS and avoid unsound inference results [11][12]. Historically, javac has undergone several refinements to how it handles capture variables, particularly regarding how they are bounded and whether they should be used in certain inference scenarios (e.g., preventing the mapping of capture variables to their upper bounds in incorrect ways) [13][11]. The compiler also provides utility methods like skipTypeVars and CaptureScanner to traverse types and locate or manipulate captured variables throughout the compilation process [6][7].

Citations:


🌐 Web query:

OpenJDK Type.WildcardType constructor bound null javac source

💡 Result:

In the OpenJDK javac source code, the class com.sun.tools.javac.code.Type.WildcardType includes constructors that accept a TypeVar bound parameter [1][2]. It is common for this bound to be null when a wildcard is not captured or does not have an associated specific type variable binding [1][2]. In the internal javac representation: The WildcardType constructor is defined to accept a type, a BoundKind, a TypeSymbol, and optionally a TypeVar bound [2]. When constructing a WildcardType, passing null for the bound is a valid operation, representing that there is no specific bound variable associated with that wildcard instance at that time [1][2]. This is distinct from the public reflection API (java.lang.reflect.WildcardType), which uses getUpperBounds and getLowerBounds returning arrays [3][4], or the language model API (javax.lang.model.type.WildcardType), which uses getExtendsBound and getSuperBound returning null if no bound is declared [5][6]. The internal javac WildcardType class uses the bound field specifically to track capture variables [1][2][7]. If a wildcard has not undergone capture conversion, or if the capture has not resulted in a new type variable, this field is often null [1][7]. You may see this reflected in debugging or compiler errors where expected bounds appear as null due to the absence of a capture variable [8].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

p = Path("nullaway/src/main/java/com/uber/nullaway/generics/TypeSubstitutionUtils.java")
text = p.read_text()

# Locate the capture visitor branch and extract the fallback expression.
m = re.search(r"else if \(t\.wildcard\.kind == BoundKind\.UNBOUND\)\s*\{\s*Type\.TypeVar formalTypeVariable = (.*?);", text, re.S)
if not m:
    raise SystemExit("could not locate UNBOUND fallback")
fallback_expr = m.group(1).strip()
print("fallback_expr:", fallback_expr)
print("has_capture_fallback:", fallback_expr == "t.wildcard.bound != null ? t.wildcard.bound : t")

# Extract the private helper body and check whether it rejects null TypeVar input without using it as bound.
pub = re.search(
    r"public static Type\.WildcardType replaceUnboundedWildcardUpperBound\(\s*Type\.WildcardType wildcard,\s*Type upperBound\)\s*\{(?P<body>.*?)\n  \}", text, re.S
)
priv = re.search(
    r"private static Type\.WildcardType replaceUnboundedWildcardUpperBound\(\s*Type\.WildcardType wildcard,\s*Type\.TypeVar typeVariable,\s*Type upperBound\)\s*\{\s*(?P<body>.*?)\n  \}", text, re.S
)
if pub:
    print("public_uses_verify_not_null:", "Verify.verifyNotNull(" in pub.group("body"))
    print("public_uses_private_helper:", "replaceUnboundedWildcardUpperBound(wildcard, formalTypeVariable, upperBound)" in pub.group("body"))
if priv:
    body = priv.group("body")
    print("private_assigns_param_to_bound:", "updatedWildcard.bound = updatedFormalTypeVariable" in body)
    print("private_rejects_null_param:", "wildcard.bound" in body or "Verify.verifyNotNull" in body)
    
all_replacements = set(re.findall(r"replaceUnboundedWildcardUpperBound\(([^)]+)\)", text))
print("replacement_calls:", sorted(all_replacements))

# Count all NullAway writers to .bound mentioned in the inspected generics package.
writer_count = text.count("updatedWildcard.bound = ")
print("nullaway_generics_updatedWildcard_bound_assignments:", writer_count)
PY

# Check javac source availability in the sandbox toolchain.
echo "== javac availability =="
java -version 2>&1 | head -n 2 || true

echo "== javac Type.WildcardType constructors in installed JDK =="
javap -cp . com.sun.tools.javac.code.Type | rg "class WildcardType|WildcardType|constructor" -A 4 -B 2 || true

Repository: uber/NullAway

Length of output: 952


🌐 Web query:

OpenJDK 17 Type.Variant WildcardType bound null Type.Var ctor

💡 Result:

In Java 17 (and other versions), the java.lang.reflect.WildcardType interface provides methods to retrieve the upper and lower bounds of a wildcard [1]. Regarding the behavior of getLowerBounds() when no lower bound is explicitly declared: The Java documentation specifies that if no lower bound is explicitly declared, the effective lower bound is the type of null [1][2][3]. In this scenario, the method returns a zero-length array rather than an array containing a null element [1][4]. It is important to distinguish between the two different "WildcardType" interfaces in the JDK: 1. java.lang.reflect.WildcardType: This is the reflection-based interface for runtime type information [1]. As noted above, it returns a zero-length array for unspecified lower bounds [1][4]. 2. javax.lang.model.type.WildcardType: This is the model-based interface used by the Java Compiler API (javax.lang.model) [5][6]. In this API, the getSuperBound() method returns null if no lower bound is explicitly declared [5][6]. There is no public constructor for WildcardType in the java.lang.reflect package because it is an interface implemented by the runtime. If you are working with the Compiler API, you would typically use the Types.getWildcardType(TypeMirror extendsBound, TypeMirror superBound) utility method to create a new wildcard type [7]. In this utility, passing null for either extendsBound or superBound indicates that the respective bound is not explicitly declared [7].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read only: locate whether JDK-installed annotations can be inspected without compiling repository code.
echo "== JAVAC_SOURCE_HOME availability =="
if [ -n "$JAVAC_SOURCE_HOME" ] && [ -d "$JAVAC_SOURCE_HOME" ]; then
  grep -n -C 3 "class WildcardType\|WildcardType.*bound\|CaptureScanner\.capture\|CaptureScanner" "$JAVAC_SOURCE_HOME/jdk/src/jdk.compiler/share/classes/com/sun/tools/javac/code/Type.java" || true
  grep -n -C 4 "CaptureScanner\.capture\|captureVar\|class CaptureScanner\n" "$JAVAC_SOURCE_HOME/jdk/src/jdk.compiler/share/classes/com/sun/tools/javac/code/Types.java" || true
else
  echo "JAVAC_SOURCE_HOME not available"
fi

echo "== repository capture creation tests/tests surrounding unbounded captured wildcards =="
rg -nP --type=java -C5 'unbounded.*capture|captured.*UNBOUND|UNBOUND.*capture|TypeSubstitutionUtils\.restore|restoreExplicitNullabilityAnnotations.*capture' nullaway/src/test/java

Repository: uber/NullAway

Length of output: 238


Clarify the t fallback when replacing a captured unbounded wildcard.

Line 459 treats the captured Type.CapturedType as the formal type variable when t.wildcard.bound is null, then writes that copy into WildcardType.bound. Since public static replaceUnboundedWildcardUpperBound(WildcardType, Type) throws when bound is null, use a small invariant check here: either assert that captured unbound wildcards never have a null backing bound, or replace the fallback with a separate unimplemented path so the invariant cannot be silently violated.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nullaway/src/main/java/com/uber/nullaway/generics/TypeSubstitutionUtils.java`
around lines 458 - 466, In the unbounded-wildcard branch of the visitor, remove
the silent fallback from t.wildcard.bound to t and enforce the captured-wildcard
backing-bound invariant explicitly. Assert that t.wildcard.bound is non-null
before using it as the formal type variable, or route the null case through a
clearly unimplemented path, while preserving the existing upper-bound
substitution flow for valid captured wildcards.

@msridhar
msridhar force-pushed the type-substitution-copies branch from 010e3c9 to e4d8783 Compare August 7, 2026 16:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant