Skip to content

Commit fc6d956

Browse files
authored
Improve verification of !null -> !null contracts (#1441)
Fixes #1440 Fixes #1104 It turns out we can relatively easily handle these cases by looking for `BOTTOM` in the `NullnessStore` before the return expression (which means the path is infeasible). I do wonder if there are other places in our analyses we should be checking for `BOTTOM`, but we want to be careful since some users may expect issues to be reported in unreachable code. But for contract verification, it seems essential. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Enhanced contract-driven nullness analysis to inspect all nested return expressions and detect unreachable return paths via contract dataflow. * Added an API to identify unreachable/definitively-null access paths for contract checks. * Improved construction of contract-violation messages. * **Bug Fixes** * Reduces spurious contract violation reports (including void-like returns and unreachable branches). * **Tests** * Added tests covering propagation, multi-level contracts, ternaries, void returns, and nested lambdas/anonymous classes. * **Chores** * Replaced a runtime assertion with a clearer verification error for unexpected AST kinds. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 7f8bbca commit fc6d956

4 files changed

Lines changed: 370 additions & 42 deletions

File tree

nullaway/src/main/java/com/uber/nullaway/dataflow/AccessPathNullnessAnalysis.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,23 @@ public static AccessPathNullnessAnalysis instance(VisitorState state, NullAway a
144144
exprPath, context, castToNonNull(contractNullnessPropagation), false);
145145
}
146146

147+
/**
148+
* Check if any access path in the store before an expression maps to {@link Nullness#BOTTOM} in
149+
* contract dataflow.
150+
*
151+
* @param exprPath tree path of expression
152+
* @param context Javac context
153+
* @return true if any access path has {@link Nullness#BOTTOM} before the expression
154+
*/
155+
public boolean hasBottomAccessPathForContractDataflow(TreePath exprPath, Context context) {
156+
NullnessStore store =
157+
dataFlow.resultBeforeExpr(exprPath, context, castToNonNull(contractNullnessPropagation));
158+
if (store == null) {
159+
return false;
160+
}
161+
return !store.getAccessPathsWithValue(Nullness.BOTTOM).isEmpty();
162+
}
163+
147164
/**
148165
* Get the fields that are guaranteed to be nonnull after a method or initializer block.
149166
*

nullaway/src/main/java/com/uber/nullaway/handlers/contract/ContractCheckHandler.java

Lines changed: 111 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@
2828
import com.google.common.base.Preconditions;
2929
import com.google.errorprone.VisitorState;
3030
import com.google.errorprone.util.ASTHelpers;
31+
import com.sun.source.tree.ClassTree;
32+
import com.sun.source.tree.ConditionalExpressionTree;
33+
import com.sun.source.tree.ExpressionTree;
34+
import com.sun.source.tree.LambdaExpressionTree;
3135
import com.sun.source.tree.MethodTree;
36+
import com.sun.source.tree.ParenthesizedTree;
3237
import com.sun.source.tree.ReturnTree;
3338
import com.sun.source.util.TreePath;
3439
import com.sun.source.util.TreePathScanner;
@@ -37,8 +42,11 @@
3742
import com.uber.nullaway.ErrorMessage;
3843
import com.uber.nullaway.NullAway;
3944
import com.uber.nullaway.Nullness;
45+
import com.uber.nullaway.dataflow.AccessPathNullnessAnalysis;
4046
import com.uber.nullaway.handlers.Handler;
4147
import com.uber.nullaway.handlers.MethodAnalysisContext;
48+
import java.util.ArrayList;
49+
import java.util.List;
4250
import java.util.Set;
4351
import org.jspecify.annotations.Nullable;
4452

@@ -136,68 +144,130 @@ public void onMatchMethod(MethodTree tree, MethodAnalysisContext methodAnalysisC
136144

137145
// we scan the method tree for the return nodes and check the contract
138146
new TreePathScanner<@Nullable Void, @Nullable Void>() {
139-
@Override
140-
public @Nullable Void visitReturn(ReturnTree returnTree, @Nullable Void unused) {
141-
142-
VisitorState returnState = state.withPath(getCurrentPath());
143-
Nullness nullness =
144-
analysis
145-
.getNullnessAnalysis(returnState)
146-
.getNullnessForContractDataflow(
147-
new TreePath(returnState.getPath(), returnTree.getExpression()),
148-
returnState.context);
149147

150-
if (nullness == Nullness.NULLABLE || nullness == Nullness.NULL) {
148+
@Override
149+
public @Nullable Void visitLambdaExpression(
150+
LambdaExpressionTree node, @Nullable Void unused) {
151+
// do not scan into lambdas
152+
return null;
153+
}
151154

152-
String errorMessage;
155+
@Override
156+
public @Nullable Void visitClass(ClassTree node, @Nullable Void unused) {
157+
// do not scan into local/anonymous classes
158+
return null;
159+
}
153160

154-
// used for error message
155-
int nonNullAntecedentCount = 0;
156-
int nonNullAntecedentPosition = -1;
161+
@Override
162+
public @Nullable Void visitReturn(ReturnTree returnTree, @Nullable Void unused) {
157163

158-
for (int i = 0; i < antecedent.length; ++i) {
159-
String valueConstraint = antecedent[i].trim();
164+
ExpressionTree returnExpression = returnTree.getExpression();
165+
if (returnExpression == null) {
166+
// this should only be possible with an invalid @Contract on a void-returning method
167+
return null;
168+
}
169+
TreePath returnExpressionPath = new TreePath(getCurrentPath(), returnExpression);
170+
AccessPathNullnessAnalysis nullnessAnalysis = analysis.getNullnessAnalysis(state);
171+
List<TreePath> allPossiblyReturnedExpressions = new ArrayList<>();
172+
collectNestedReturnedExpressions(returnExpressionPath, allPossiblyReturnedExpressions);
160173

161-
if (valueConstraint.equals("!null")) {
162-
nonNullAntecedentCount += 1;
163-
nonNullAntecedentPosition = i;
174+
boolean contractViolated = false;
175+
for (TreePath expressionPath : allPossiblyReturnedExpressions) {
176+
Nullness nullness =
177+
nullnessAnalysis.getNullnessForContractDataflow(expressionPath, state.context);
178+
if (nullness == Nullness.NULLABLE || nullness == Nullness.NULL) {
179+
if (nullnessAnalysis.hasBottomAccessPathForContractDataflow(
180+
expressionPath, state.context)) {
181+
// if any access path is mapped to bottom, this branch is unreachable
182+
continue;
164183
}
184+
contractViolated = true;
185+
break;
165186
}
187+
}
166188

167-
if (nonNullAntecedentCount == 1) {
168-
169-
errorMessage =
170-
"Method "
171-
+ callee.name
172-
+ " has @Contract("
173-
+ contractString
174-
+ "), but this appears to be violated, as a @Nullable value may be returned when parameter "
175-
+ tree.getParameters().get(nonNullAntecedentPosition).getName()
176-
+ " is non-null.";
177-
} else {
178-
errorMessage =
179-
"Method "
180-
+ callee.name
181-
+ " has @Contract("
182-
+ contractString
183-
+ "), but this appears to be violated, as a @Nullable value may be returned "
184-
+ "when the contract preconditions are true.";
185-
}
189+
if (contractViolated) {
190+
String errorMessage =
191+
getErrorMessageForViolatedContract(antecedent, callee, contractString, tree);
186192

187-
returnState.reportMatch(
193+
state.reportMatch(
188194
analysis
189195
.getErrorBuilder()
190196
.createErrorDescription(
191197
new ErrorMessage(
192198
ErrorMessage.MessageTypes.ANNOTATION_VALUE_INVALID, errorMessage),
193199
returnTree,
194200
analysis.buildDescription(returnTree),
195-
returnState,
201+
state,
196202
null));
197203
}
198204
return super.visitReturn(returnTree, null);
199205
}
200206
}.scan(state.getPath(), null);
201207
}
202208
}
209+
210+
private static String getErrorMessageForViolatedContract(
211+
String[] antecedent, Symbol.MethodSymbol callee, String contractString, MethodTree tree) {
212+
String errorMessage;
213+
214+
// used for error message
215+
int nonNullAntecedentCount = 0;
216+
int nonNullAntecedentPosition = -1;
217+
218+
for (int i = 0; i < antecedent.length; ++i) {
219+
String valueConstraint = antecedent[i].trim();
220+
221+
if (valueConstraint.equals("!null")) {
222+
nonNullAntecedentCount += 1;
223+
nonNullAntecedentPosition = i;
224+
}
225+
}
226+
227+
if (nonNullAntecedentCount == 1) {
228+
errorMessage =
229+
"Method "
230+
+ callee.name
231+
+ " has @Contract("
232+
+ contractString
233+
+ "), but this appears to be violated, as a @Nullable value may be returned when parameter "
234+
+ tree.getParameters().get(nonNullAntecedentPosition).getName()
235+
+ " is non-null.";
236+
} else {
237+
errorMessage =
238+
"Method "
239+
+ callee.name
240+
+ " has @Contract("
241+
+ contractString
242+
+ "), but this appears to be violated, as a @Nullable value may be returned "
243+
+ "when the contract preconditions are true.";
244+
}
245+
return errorMessage;
246+
}
247+
248+
/**
249+
* Collect {@code TreePath}s to all nested expressions that may be returned, recursing through
250+
* parenthesized expressions and conditional expressions.
251+
*
252+
* @param expressionPath the TreePath to an expression being returned
253+
* @param output output parameter list to collect nested returned expression TreePaths
254+
*/
255+
private static void collectNestedReturnedExpressions(
256+
TreePath expressionPath, List<TreePath> output) {
257+
ExpressionTree expression = (ExpressionTree) expressionPath.getLeaf();
258+
while (expression instanceof ParenthesizedTree) {
259+
ExpressionTree nestedExpression = ((ParenthesizedTree) expression).getExpression();
260+
expressionPath = new TreePath(expressionPath, nestedExpression);
261+
expression = nestedExpression;
262+
}
263+
if (expression instanceof ConditionalExpressionTree) {
264+
ConditionalExpressionTree conditionalExpression = (ConditionalExpressionTree) expression;
265+
TreePath truePath = new TreePath(expressionPath, conditionalExpression.getTrueExpression());
266+
TreePath falsePath = new TreePath(expressionPath, conditionalExpression.getFalseExpression());
267+
collectNestedReturnedExpressions(truePath, output);
268+
collectNestedReturnedExpressions(falsePath, output);
269+
return;
270+
}
271+
output.add(expressionPath);
272+
}
203273
}

nullaway/src/main/java/com/uber/nullaway/handlers/contract/ContractNullnessStoreInitializer.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import static com.uber.nullaway.Nullness.NONNULL;
44
import static com.uber.nullaway.Nullness.NULLABLE;
55

6+
import com.google.common.base.Verify;
67
import com.google.errorprone.util.ASTHelpers;
78
import com.sun.source.tree.ClassTree;
89
import com.sun.source.tree.MethodTree;
@@ -34,7 +35,10 @@ public NullnessStore getInitialStore(
3435
Context context,
3536
Types types,
3637
Config config) {
37-
assert underlyingAST.getKind() == UnderlyingAST.Kind.METHOD;
38+
Verify.verify(
39+
underlyingAST.getKind() == UnderlyingAST.Kind.METHOD,
40+
"Expected AST for method but got %s",
41+
underlyingAST.getKind());
3842

3943
MethodTree methodTree = ((UnderlyingAST.CFGMethod) underlyingAST).getMethod();
4044
ClassTree classTree = ((UnderlyingAST.CFGMethod) underlyingAST).getClassTree();

0 commit comments

Comments
 (0)