[
https://issues.apache.org/jira/browse/GROOVY-12000?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18079372#comment-18079372
]
Paul King commented on GROOVY-12000:
------------------------------------
h2. AI Assessment
Probing around the path GROOVY-12000 example flagged uncovered two pre-existing
soundness
bugs and two precision gaps in the {{instanceof}} flow-typing machinery.
For the specific example in the issue ({{!(number instanceof Cloneable ||
number instanceof Closeable)}})
is currently *sound but lossy*, but nearby shapes are not.
None of these were caused by the GROOVY-11983 patches — they have lived in
{{visitNotExpression}}'s blind tti-flip since the 2022 negative-flow-typing
work (GROOVY-6429 / GROOVY-8412 / GROOVY-9931) — but they sit exactly where
GROOVY-11983 was poking.
h2. Where the framework actually is today
Confirmed via {{@ASTTest}} and runtime probes:
||declared type||narrow shape||inferred type in the narrow branch||status||
|{{Object x}}|{{x instanceof List \|\| x instanceof Map}}
(then)|{{<UnionType:List+Map>}}|(/) sound + precise|
|{{CharSequence c}}|{{c instanceof Comparable \|\| c instanceof Serializable}}
(then)|{{(<UnionType:Comparable+Serializable> & CharSequence)}}|(/) sound +
precise|
|{{Number n}}|{{n instanceof Cloneable \|\| n instanceof Closeable}}
(then)|{{Number}} — union dropped|(!) sound but lossy *(this is Eric's case)*|
|{{Object x}}|{{!(!(x instanceof CharSequence) && !(x instanceof Number))}}
(then)|{{(Number & CharSequence)}} — should be a *union*|(x) *unsound*|
h2. Findings
h3. 1. Soundness bug — THEN of {{!(cond && !(x instanceof T))}}
{code:groovy}
@CompileStatic
String f(Object x, boolean cond) {
if (!(cond && !(x instanceof String))) {
return "len=${x.length()}" // compiles
}
return "else"
}
f(42, false) // → ClassCastException: Integer cannot be cast to String
{code}
When {{cond=false}} the outer expression is true but {{x}} can be anything;
the type checker still smart-casts {{x}} to {{String}}.
Trace: {{visitNotExpression}} flips the inner {{[!instanceof,x]→[T]}} entry
into a *positive* {{x→[T]}} at the if-scope, ignoring the {{&&}} gating.
Site: {{StaticTypeCheckingVisitor.java}} lines 821–826 — the unconditional
{{forEach(this::putNotInstanceOfTypeInfo)}} on the way out of the outer Not.
The existing {{testNotInstanceof9}} doesn't catch this because the THEN
branch returns {{-1}} without touching {{x}}, and the ELSE uses
{{x.hashCode()}} (an {{Object}} method that wouldn't expose the smart-cast).
h3. 2. Soundness bug — De-Morgan'd OR via double-Not
{code:groovy}
@CompileStatic
String f(Object x) {
if (!(!(x instanceof CharSequence) && !(x instanceof Number))) {
return "iv=${x.intValue()}" // compiles
}
return "no"
}
f("hello") // → GroovyCastException: String cannot be cast to Number
{code}
{{!(!A && !B)}} should yield {{A∪B}} in the THEN branch. Instead, both
{{[!instanceof,x]}} entries flip back to *positive* into the same bucket
and {{getInferredTypeFromTempInfo}} collapses {{[CharSequence, Number]}}
through {{newIntersectionTypeClassNode}} into {{(Number & CharSequence)}}.
The type checker then permits methods of *both*, but {{x}} satisfies only one.
Same root cause as (1): the flip carries no record of which logical
operator combined the operands inside.
h3. 3. Precision loss — {{Number & UnionType(...)}} collapses to {{Number}}
*(Eric's original case)*
{code:groovy}
@CompileStatic
def f(Number n) {
if (n instanceof Cloneable || n instanceof Closeable) {
// inferred: java.lang.Number — the union info is silently dropped
// can't call clone() / close() without an inner instanceof
}
}
{code}
Sound, but no narrowing benefit.
Cause: {{newIntersectionTypeClassNode}}
({{StaticTypeCheckingVisitor.java:6800}})
partitions components into {{interface}} vs {{non-interface}} buckets.
{{UnionTypeClassNode.isInterface()}} returns {{false}}, so it lands in
{{supers}} alongside {{Number}}; the function then returns {{supers.get(0)}}
and silently drops the union. The interface-declared case
({{CharSequence}} above) escapes this because the declared interface goes
into the {{interfaces}} bucket and the LUB construction survives.
h3. 4. Precision loss — OR with negative entries (TODO already in code)
{{propagateTemporaryTypeInfo}} at {{StaticTypeCheckingVisitor.java:1064}}
literally has:
{code:java}
// TODO: deal with (x !instanceof T)
lhs.keySet().removeIf(k -> k instanceof Object[]);
rhs.keySet().removeIf(k -> k instanceof Object[]);
{code}
This silently kills smart-cast for shapes like:
* {{cond || !(x instanceof T)}}
* {{!(!(x instanceof A) || !(x instanceof B))}} — would soundly be {{A ∩ B}}
h2. How this relates to GROOVY-11983
The recent patches are correct _for the cases they target_:
{{cond && !(x instanceof Y)}}'s ELSE-branch unsoundness is gone, and the
{{0f692ec17f}} follow-up correctly re-enabled the leading-Not case.
But the area underneath — {{visitNotExpression}}'s flip — was never
reworked, and {{canInvertNarrowingForElseBranch}} only gates the ELSE
branch. Neither addresses the THEN branch when the boolean visit itself
populates an unsound positive entry.
h2. Suggested direction (in priority order)
# *Fix the {{visitNotExpression}} flip to be structure-aware.* Two options:
## (conservative) Walk the operand and, like
{{canInvertNarrowingForElseBranch}}, refuse to flip when the operand is gated
by {{&&}} / {{&}} / {{^}}. Cheap; closes findings (1) and (2) at the cost of
also losing some currently-correct inferences.
## (precise) Push/pop scopes inside {{visitBinaryExpression(&&)}} the way the
{{||}} path already does, so an outer Not has clean per-operand info to flip,
and reshape {{propagateTemporaryTypeInfo}} to do the right
disjunction/conjunction. More invasive, more precise.
# *Teach {{newIntersectionTypeClassNode}} about {{UnionTypeClassNode}}.* Either
special-case it into the {{interfaces}} slot of {{LowestUpperBoundClassNode}}
(gives {{Number & <UnionType:...>}}, mirroring the interface-declared case that
already works), or distribute ({{UnionType(Number&Cloneable,
Number&Closeable)}}). The former is much less work and matches the pattern that
already produces good results for interface-typed declarations.
# *Address the {{propagateTemporaryTypeInfo}} negative-entry TODO*, _after_ (1)
lands — handling {{!instanceof}} in the disjunction lets the De-Morgan'd OR
path recover precision soundly.
# *Add THEN-branch tests to {{testNotInstanceof*}}.* The existing suite hits
ELSE thoroughly but barely exercises THEN with smart-cast-sensitive method
calls. Adding {{length()}} / {{intValue()}}-style probes in THEN would have
caught (1) and (2) immediately and will prevent regression once fixed.
Recommendation: tackle 1(a) as a first conservative pass to close the
soundness floor; pursue 1(b) and 2 once that is solid. The original
{{Number}}-union case is the least urgent — it's a precision miss, not a
correctness failure.
> STC: instanceof or combined with not
> ------------------------------------
>
> Key: GROOVY-12000
> URL: https://issues.apache.org/jira/browse/GROOVY-12000
> Project: Groovy
> Issue Type: Bug
> Components: Static Type Checker
> Affects Versions: 6.0.0-alpha-1
> Reporter: Eric Milles
> Priority: Major
>
> Consider the following:
> {code:groovy}
> @groovy.transform.TypeChecked
> void test(Number number) {
> if (!(number instanceof Cloneable || number instanceof Closeable)) {
> number
> } else {
> number
> }
> }
> {code}
> Currently this passes the check for "can invert" and produces a type like
> (Number & (Cloneable | Closeable))" for the else path. Not sure if we're
> ready for that or not.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)