GROOVY-11894: Provide a NullChecker for groovy-typecheckers#2426
GROOVY-11894: Provide a NullChecker for groovy-typecheckers#2426paulk-asert wants to merge 1 commit intoapache:masterfrom
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #2426 +/- ##
==================================================
+ Coverage 66.3339% 66.3401% +0.0062%
- Complexity 29941 30074 +133
==================================================
Files 1396 1399 +3
Lines 117085 117267 +182
Branches 20730 20791 +61
==================================================
+ Hits 77667 77795 +128
- Misses 33037 33047 +10
- Partials 6381 6425 +44
🚀 New features to boost your workflow:
|
e57b06a to
43b21ce
Compare
There was a problem hiding this comment.
Pull request overview
Adds new Groovy type-checking extensions to perform compile-time null-safety analysis, plus tests and user documentation.
Changes:
- Introduces
NullChecker(annotation-based) andStrictNullChecker(annotation + flow-sensitive) type checking extensions. - Implements shared analysis logic in
NullCheckingVisitor. - Adds unit/spec tests and Asciidoc documentation with include-tagged examples.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| subprojects/groovy-typecheckers/src/main/groovy/org/apache/groovy/typecheckers/NullCheckingVisitor.groovy | Core visitor implementing nullability checks and (optional) flow-sensitive tracking |
| subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/NullChecker.groovy | Registers the annotation-based null checker as a TypeChecked extension |
| subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/StrictNullChecker.groovy | Registers the flow-sensitive null checker as a TypeChecked extension |
| subprojects/groovy-typecheckers/src/test/groovy/groovy/typecheckers/NullCheckerTest.groovy | JUnit tests for both checkers using custom GroovyShell configurations |
| subprojects/groovy-typecheckers/src/spec/test/NullCheckerTest.groovy | Spec tests with tagged snippets used by the docs |
| subprojects/groovy-typecheckers/src/spec/doc/typecheckers.adoc | Documentation section describing the new null checkers and examples |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
...roovy-typecheckers/src/main/groovy/org/apache/groovy/typecheckers/NullCheckingVisitor.groovy
Show resolved
Hide resolved
...roovy-typecheckers/src/main/groovy/org/apache/groovy/typecheckers/NullCheckingVisitor.groovy
Show resolved
Hide resolved
| if (fieldNonNull && isNullExpr(expression.rightExpression)) { | ||
| reportError("Cannot assign null to @NonNull variable '${expression.leftExpression.name}'", expression) | ||
| } | ||
| // @MonotonicNonNull: once initialized with non-null, cannot assign null again | ||
| if (target instanceof AnnotatedNode && hasMonotonicAnno(target)) { | ||
| if (!isNullExpr(expression.rightExpression)) { | ||
| monotonicInitialized.add(target) | ||
| } else if (monotonicInitialized.contains(target)) { | ||
| reportError("Cannot assign null to @MonotonicNonNull variable '${expression.leftExpression.name}' after non-null assignment", expression) | ||
| } | ||
| } | ||
| if (isNullExpr(expression.rightExpression)) { | ||
| nullableVars.add(target) | ||
| } else { | ||
| nullableVars.remove(target) | ||
| if (flowSensitive) { | ||
| trackNullableReturn(target, expression.rightExpression) |
There was a problem hiding this comment.
In flow-sensitive mode, nullability does not propagate through assignments like y = x when x is known nullable (either annotated @Nullable or tracked in nullableVars). Because the else branch unconditionally removes the target from nullableVars unless RHS is a literal null (or a nullable-returning method via trackNullableReturn), StrictNullChecker can miss y being nullable. Consider treating isKnownNullable(expression.rightExpression) as nullable and keeping/adding the target in nullableVars (and similarly avoid marking @MonotonicNonNull as initialized when the assigned value may be null).
| if (fieldNonNull && isNullExpr(expression.rightExpression)) { | |
| reportError("Cannot assign null to @NonNull variable '${expression.leftExpression.name}'", expression) | |
| } | |
| // @MonotonicNonNull: once initialized with non-null, cannot assign null again | |
| if (target instanceof AnnotatedNode && hasMonotonicAnno(target)) { | |
| if (!isNullExpr(expression.rightExpression)) { | |
| monotonicInitialized.add(target) | |
| } else if (monotonicInitialized.contains(target)) { | |
| reportError("Cannot assign null to @MonotonicNonNull variable '${expression.leftExpression.name}' after non-null assignment", expression) | |
| } | |
| } | |
| if (isNullExpr(expression.rightExpression)) { | |
| nullableVars.add(target) | |
| } else { | |
| nullableVars.remove(target) | |
| if (flowSensitive) { | |
| trackNullableReturn(target, expression.rightExpression) | |
| Expression rhs = expression.rightExpression | |
| boolean rhsIsNull = isNullExpr(rhs) | |
| boolean rhsKnownNullable = isKnownNullable(rhs) | |
| if (fieldNonNull && rhsIsNull) { | |
| reportError("Cannot assign null to @NonNull variable '${expression.leftExpression.name}'", expression) | |
| } | |
| // @MonotonicNonNull: once initialized with non-null, cannot assign null again | |
| if (target instanceof AnnotatedNode && hasMonotonicAnno(target)) { | |
| if (!rhsIsNull && !rhsKnownNullable) { | |
| monotonicInitialized.add(target) | |
| } else if (rhsIsNull && monotonicInitialized.contains(target)) { | |
| reportError("Cannot assign null to @MonotonicNonNull variable '${expression.leftExpression.name}' after non-null assignment", expression) | |
| } | |
| } | |
| if (rhsIsNull || rhsKnownNullable) { | |
| nullableVars.add(target) | |
| } else { | |
| nullableVars.remove(target) | |
| if (flowSensitive) { | |
| trackNullableReturn(target, rhs) |
| void visitBinaryExpression(BinaryExpression expression) { | ||
| super.visitBinaryExpression(expression) | ||
| if (isAssignment(expression.operation.type) && expression.leftExpression instanceof VariableExpression) { | ||
| def target = findTargetVariable(expression.leftExpression) | ||
| boolean fieldNonNull = target instanceof AnnotatedNode && hasNonNullAnno(target) | ||
| if (!fieldNonNull && target instanceof FieldNode && !hasNullableAnno(target)) { | ||
| fieldNonNull = target.declaringClass != null && hasNonNullByDefaultAnno(target.declaringClass) | ||
| } | ||
| if (fieldNonNull && isNullExpr(expression.rightExpression)) { | ||
| reportError("Cannot assign null to @NonNull variable '${expression.leftExpression.name}'", expression) | ||
| } | ||
| // @MonotonicNonNull: once initialized with non-null, cannot assign null again | ||
| if (target instanceof AnnotatedNode && hasMonotonicAnno(target)) { | ||
| if (!isNullExpr(expression.rightExpression)) { | ||
| monotonicInitialized.add(target) | ||
| } else if (monotonicInitialized.contains(target)) { | ||
| reportError("Cannot assign null to @MonotonicNonNull variable '${expression.leftExpression.name}' after non-null assignment", expression) | ||
| } | ||
| } |
There was a problem hiding this comment.
The flow-sensitive assignment logic has several edge cases that aren't currently exercised by tests: (1) casted-null assignments (e.g. def x = (String) null) should be treated as nullable in StrictNullChecker, (2) propagation of nullability through variable-to-variable assignment (def y = x) when x is nullable, and (3) @MonotonicNonNull should not be considered initialized when assigned from a value that may be null. Adding focused tests for these cases would prevent regressions and validate the intended flow-sensitive behavior.
glaforge
left a comment
There was a problem hiding this comment.
Maybe we could add checks (in the tests) for the JSpecify annotations?
Added |
11e6d04 to
cbe00b3
Compare
No description provided.