You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2026/08/12 00:48:32 UTC

(groovy) 02/07: GROOVY-12242: add systematic visibility matrix for instanceof pattern variables

This is an automated email from the ASF dual-hosted git repository.

paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git

commit c9992ad79c4a570ec0d6e5ae6ca48c6a65135a4c
Author: Daniel Sun <su...@apache.org>
AuthorDate: Sat Aug 8 20:41:10 2026 +0900

    GROOVY-12242: add systematic visibility matrix for instanceof pattern variables
---
 .../groovy/classgen/InstanceofFlowBindings.java    |  62 ++++-
 .../groovy/InstanceofFlowBindingsTest.groovy       | 195 ++++++++++++++
 src/test/groovy/groovy/InstanceofTest.groovy       | 285 +++++++++++++++++++++
 3 files changed, 532 insertions(+), 10 deletions(-)

diff --git a/src/main/java/org/codehaus/groovy/classgen/InstanceofFlowBindings.java b/src/main/java/org/codehaus/groovy/classgen/InstanceofFlowBindings.java
index 84e1d27a15..5d6394a900 100644
--- a/src/main/java/org/codehaus/groovy/classgen/InstanceofFlowBindings.java
+++ b/src/main/java/org/codehaus/groovy/classgen/InstanceofFlowBindings.java
@@ -18,6 +18,7 @@
  */
 package org.codehaus.groovy.classgen;
 
+import groovy.transform.Internal;
 import org.codehaus.groovy.ast.CodeVisitorSupport;
 import org.codehaus.groovy.ast.expr.BinaryExpression;
 import org.codehaus.groovy.ast.expr.BooleanExpression;
@@ -50,16 +51,25 @@ import java.util.Set;
  * {@code &&} (union of true bindings), {@code ||} (union of false bindings).
  * Other shapes contribute nothing (conservative).
  * <p>
- * Consumers:
- * <ul>
- *   <li>{@link VariableScopeVisitor} — declare names on the live path</li>
- *   <li>{@link org.codehaus.groovy.classgen.asm.InstanceofFlowSlotPublisher} —
- *       publish/hide CompileStack slots from these bindings</li>
- * </ul>
+ * <b>Design note — dual use across compiler phases.</b><br>
+ * This class is consumed by two separate compiler phases:
+ * <ol>
+ *   <li>{@link VariableScopeVisitor} (semantic analysis) — uses the binding
+ *       sets to declare pattern-variable names only on the live path, so that
+ *       subsequent name resolution sees the correct scope.</li>
+ *   <li>{@link org.codehaus.groovy.classgen.asm.InstanceofFlowSlotPublisher}
+ *       (code generation) — uses the same binding sets to publish/hide bytecode
+ *       locals on the matching control-flow arm, keeping CompileStack slot
+ *       visibility consistent with the resolved scopes.</li>
+ * </ol>
+ * Both phases need to ask the same question ("which names are live on which
+ * path?"), so sharing this analysis type avoids two independent implementations
+ * that could diverge.
  *
  * @see org.codehaus.groovy.classgen.asm.InstanceofFlowSlotPublisher
  * @since 6.0.0
  */
+@Internal
 public final class InstanceofFlowBindings {
 
     private static final InstanceofFlowBindings EMPTY =
@@ -111,6 +121,11 @@ public final class InstanceofFlowBindings {
 
     /**
      * All pattern-variable names appearing in either path (stable encounter order).
+     * <p>
+     * Implemented by iterating both lists directly rather than composing
+     * {@link #whenTrueNames()} and {@link #whenFalseNames()}: that would
+     * allocate two intermediate {@link Set} objects only to merge them into a
+     * third, whereas the direct loop allocates only the result set.
      */
     public Set<String> allNames() {
         if (isEmpty()) return Collections.emptySet();
@@ -145,8 +160,17 @@ public final class InstanceofFlowBindings {
     /**
      * Returns {@code true} if {@code expression} contains any JEP&nbsp;394 type
      * pattern ({@code e instanceof T t} or {@code e !instanceof T t}), including
-     * nested subexpressions. Used to decide whether expression-statement
-     * CompileStack isolation is required.
+     * <em>arbitrarily nested</em> subexpressions (e.g. a pattern buried inside a
+     * method-call argument or a ternary).
+     * <p>
+     * <strong>Why a full subtree walk?</strong> This method is used for
+     * expression-statement isolation: before discarding an expression-statement's
+     * value, the code generator needs to know whether <em>any</em> descendant
+     * node may have allocated a CompileStack slot for a pattern variable, even
+     * if {@link #of} does not model that node (e.g. a pattern inside a method
+     * argument). The conservative "does any descendant match?" question requires
+     * visiting the whole tree. Short-circuiting ({@code found[0]} check) stops
+     * the walk as soon as the first match is detected.
      *
      * @param expression any expression; {@code null} yields {@code false}
      */
@@ -175,6 +199,20 @@ public final class InstanceofFlowBindings {
                 && decl.getVariableExpression() != null;
     }
 
+    /**
+     * Core recursive descent that computes binding sets.
+     * <p>
+     * <strong>Why not a full subtree walk?</strong> Unlike {@link #containsPattern},
+     * this method only needs to understand the <em>boolean algebra</em> of the
+     * condition (which variables are <em>definitely</em> bound on each path),
+     * not to locate patterns anywhere in an arbitrary expression tree. The
+     * recursive descent follows exactly the operators that can propagate
+     * definite-assignment ({@code instanceof}, {@code !instanceof}, {@code &&},
+     * {@code ||}, {@code !}) and returns {@link #EMPTY} conservatively for any
+     * other expression shape. This keeps the traversal shallow and
+     * proportional to the boolean structure of the condition, not to the total
+     * AST size.
+     */
     private static InstanceofFlowBindings analyse(final Expression expression) {
         Expression expr = expression;
 
@@ -200,7 +238,9 @@ public final class InstanceofFlowBindings {
             if (op == Types.LOGICAL_AND) {
                 InstanceofFlowBindings left = analyse(binary.getLeftExpression());
                 InstanceofFlowBindings right = analyse(binary.getRightExpression());
-                // True path evaluates both; false path is not definite for either side alone.
+                // True path: the condition succeeds only if both sides are true, so
+                // both sides' true-bindings are definitely assigned. The false path is
+                // not definite: either side alone may have caused failure.
                 return new InstanceofFlowBindings(
                         union(left.whenTrue, right.whenTrue),
                         List.of());
@@ -208,7 +248,9 @@ public final class InstanceofFlowBindings {
             if (op == Types.LOGICAL_OR) {
                 InstanceofFlowBindings left = analyse(binary.getLeftExpression());
                 InstanceofFlowBindings right = analyse(binary.getRightExpression());
-                // False path evaluates both; true path is not definite for either side alone.
+                // False path: the condition fails only if both sides are false, so
+                // both sides' false-bindings are definitely assigned. The true path
+                // is not definite: only the left side may have been evaluated.
                 return new InstanceofFlowBindings(
                         List.of(),
                         union(left.whenFalse, right.whenFalse));
diff --git a/src/test/groovy/groovy/InstanceofFlowBindingsTest.groovy b/src/test/groovy/groovy/InstanceofFlowBindingsTest.groovy
index 72a4d61aff..85b969c491 100644
--- a/src/test/groovy/groovy/InstanceofFlowBindingsTest.groovy
+++ b/src/test/groovy/groovy/InstanceofFlowBindingsTest.groovy
@@ -153,6 +153,175 @@ final class InstanceofFlowBindingsTest {
         assert accesses.count { it instanceof DynamicVariable } >= 1
     }
 
+    // -------------------------------------------------------------------------
+    // GROOVY-12242: systematic binding-analysis unit tests
+    //
+    // Covers every condition shape in the visibility matrix:
+    //   (1) instanceof s
+    //   (2) !instanceof s
+    //   (3) !instanceof s (negated via BooleanExpression)
+    //   (4) instanceof s && cond
+    //   (5) instanceof s || cond
+    //   (6) !instanceof s && cond
+    //   (7) !instanceof s || cond
+    //   (8) !(instanceof s && cond)
+    //   (9) double negation !!(instanceof s)
+    // -------------------------------------------------------------------------
+
+    // (4) o instanceof String s && cond — true: {s}, false: {}
+    @Test
+    void testAndWithPattern_trueBinds_falseEmpty() {
+        def b = InstanceofFlowBindings.of(parseCondition('o instanceof String s && true'))
+        assert b.whenTrue()*.name == ['s']
+        assert b.whenFalse().isEmpty()
+    }
+
+    // (5) o instanceof String s || cond — both paths empty (can't guarantee s on true path,
+    //     and cond alone doesn't bind s on the false path)
+    @Test
+    void testOrWithPattern_bothEmpty() {
+        def b = InstanceofFlowBindings.of(parseCondition('o instanceof String s || true'))
+        assert b.isEmpty()
+    }
+
+    // (6) !(o instanceof String s) && cond
+    //     Left's false path binds s, but && propagates no false-bindings.
+    //     Left's true path is empty. Right contributes nothing.
+    //     → true: {}, false: {}
+    @Test
+    void testNegatedAndCond_bothEmpty() {
+        def b = InstanceofFlowBindings.of(parseCondition('!(o instanceof String s) && true'))
+        assert b.isEmpty()
+    }
+
+    // (7) !(o instanceof String s) || cond
+    //     Left false-path binds s (since !(s) is false → s matched).
+    //     Right's false path is empty. || false-path = union of false-paths = {s}.
+    //     → true: {}, false: {s}
+    @Test
+    void testNegatedOrCond_falseBinds() {
+        def b = InstanceofFlowBindings.of(parseCondition('!(o instanceof String s) || true'))
+        assert b.whenFalse()*.name == ['s']
+        assert b.whenTrue().isEmpty()
+    }
+
+    // (8) !(o instanceof String s && s.length() > 0)
+    //     Inner: true:{s}, false:{}. Negated: true:{}, false:{s}.
+    //     → true: {}, false: {s}
+    @Test
+    void testNegatedAndPattern_falseBindsAfterNegation() {
+        def b = InstanceofFlowBindings.of(parseCondition('!(o instanceof String s && s.length() > 0)'))
+        assert b.whenFalse()*.name == ['s']
+        assert b.whenTrue().isEmpty()
+    }
+
+    // (9) double negation !!(o instanceof String s) ≡ o instanceof String s
+    //     Inner: true:{s}, false:{}. Negated once: true:{}, false:{s}.
+    //     Negated twice: true:{s}, false:{}.
+    @Test
+    void testDoubleNegation_sameAsPositive() {
+        def b = InstanceofFlowBindings.of(parseCondition('!!(o instanceof String s)'))
+        assert b.whenTrue()*.name == ['s']
+        assert b.whenFalse().isEmpty()
+    }
+
+    // (4) o instanceof String s && p instanceof Integer i  (JLS §6.3.1.1)
+    // Rule B: a&&b when-true = a.whenTrue ∪ b.whenTrue = {s} ∪ {i} = {s, i}
+    // Rule A: s (introduced by a when true) is definitely matched at b — legal,
+    // different names, no §6.3.1.1-200-A error.
+    // No rule for when-false (§6.3.1.1 note: cannot determine which side failed).
+    @Test
+    void testAndWithTwoPatterns_bothInTrue() {
+        def b = InstanceofFlowBindings.of(
+                parseCondition2('o instanceof String s && p instanceof Integer i'))
+        assert b.whenTrue()*.name as Set == ['s', 'i'] as Set
+        assert b.whenFalse().isEmpty()
+    }
+
+    // (5) o instanceof String s || p instanceof Integer i  (JLS §6.3.1.2)
+    // Rule B: a||b when-false = a.whenFalse ∪ b.whenFalse
+    //   a.whenFalse for (o instanceof String s) = {} (§6.3.1.5: no when-false rule for instanceof)
+    //   b.whenFalse for (p instanceof Integer i) = {}
+    //   a||b when-false = {} ∪ {} = {}
+    // No rule for when-true (§6.3.1.2 note: cannot determine which side was true).
+    // Different names s/i → no §6.3.1.2-200-A or -200-B error.
+    @Test
+    void testOrWithTwoPatterns_bothEmpty() {
+        def b = InstanceofFlowBindings.of(
+                parseCondition2('o instanceof String s || p instanceof Integer i'))
+        assert b.isEmpty()
+    }
+
+    // !(a instanceof String s) || !(b instanceof Integer i)  (JLS §6.3.1.2 + §6.3.1.3)
+    // !(o instanceof String s): when-false = {s}  (§6.3.1.3: !a when-false = a.whenTrue)
+    // !(p instanceof Integer i): when-false = {i}
+    // Rule A: s (introduced by left when false) is in scope at right (§6.3.1.2-100-A).
+    // Rule B: a||b when-false = {s} ∪ {i} = {s, i}
+    // Different names s/i → no §6.3.1.2-200-B error.
+    @Test
+    void testOrWithTwoNegatedPatterns_falseHasBoth() {
+        def b = InstanceofFlowBindings.of(
+                parseCondition2('!(o instanceof String s) || !(p instanceof Integer i)'))
+        assert b.whenFalse()*.name as Set == ['s', 'i'] as Set
+        assert b.whenTrue().isEmpty()
+    }
+
+    // isEmpty() is false when only whenFalse is non-empty
+    @Test
+    void testIsEmpty_falseWhenOnlyFalseSide() {
+        def b = InstanceofFlowBindings.of(parseCondition('!(o instanceof String s)'))
+        assert !b.isEmpty()
+        assert b.whenTrue().isEmpty()
+        assert !b.whenFalse().isEmpty()
+    }
+
+    // allNames() returns names from both sides (stable order)
+    @Test
+    void testAllNames_mergesBothSides() {
+        // construct a condition that has s on true and t on false:
+        // (o instanceof String s && true) || !(o instanceof Integer t)
+        // → true: {} (|| doesn't guarantee), false: {t} (from right)
+        // Use allNames on something that has both sides:
+        // !(o instanceof String s) alone: whenFalse={s}, whenTrue={}; allNames={s}
+        def b1 = InstanceofFlowBindings.of(parseCondition('!(o instanceof String s)'))
+        assert b1.allNames() == ['s'] as Set
+
+        // positive: whenTrue={s}, whenFalse={}
+        def b2 = InstanceofFlowBindings.of(parseCondition('o instanceof String s'))
+        assert b2.allNames() == ['s'] as Set
+    }
+
+    // whenTrueNames() / whenFalseNames() return Set (not List)
+    // Uses a legal condition; tests that the return type is a Set.
+    @Test
+    void testNameSets_returnSets() {
+        // o instanceof String s && true  →  whenTrue introduces {s}, whenFalse is {}
+        def b = InstanceofFlowBindings.of(parseCondition('o instanceof String s && true'))
+        assert b.whenTrueNames() instanceof Set
+        assert b.whenFalseNames() instanceof Set
+        assert b.whenTrueNames() == ['s'] as Set
+        assert b.whenFalseNames().isEmpty()
+    }
+
+    // JLS §6.3.1.1-200-A: it is a compile-time error when the same pattern variable name
+    // is introduced by BOTH operands of &&.  InstanceofFlowBindings is a pre-error analysis
+    // (it runs before the error is reported) and will conservatively union the sets; the
+    // resulting program is still rejected at compile time by VariableScopeVisitor.
+    // This test documents that behavior without asserting on specific names (the analysis
+    // result for rejected input is unspecified / implementation-defined).
+    @Test
+    void testJLS_6_3_1_1_DuplicateNameIsCompileError_analysisStillRuns() {
+        // 'o instanceof String s && o instanceof String s' — same name s on both sides,
+        // §6.3.1.1-200-A error.  The analysis sees both, union deduplicates to size 1.
+        // We only check it doesn't throw; the compiler will still reject the source.
+        def b = InstanceofFlowBindings.of(parseCondition('o instanceof String s && o instanceof String s'))
+        // whenTrue may contain s (deduped) — at minimum the result is not null / EMPTY
+        assert b != null
+        // The union of {s} and {s} should deduplicate to one entry
+        assert b.whenTrueNames().size() == 1 : 'union should deduplicate same-named vars'
+        assert b.whenFalseNames().isEmpty()  : 'no false-path bindings for &&'
+    }
+
     private static Expression parseCondition(String condition) {
         def src = """
             class C {
@@ -203,4 +372,30 @@ final class InstanceofFlowBindingsTest {
         })
         found
     }
+
+    /**
+     * Variant of {@link #parseCondition} for conditions that reference two variables
+     * ({@code o} and {@code p}), e.g. multi-pattern {@code &&} / {@code ||} combinations.
+     */
+    private static Expression parseCondition2(String condition) {
+        def src = """
+            class C {
+                def m(Object o, Object p) {
+                    if ($condition) return 1
+                    return 0
+                }
+            }
+        """
+        def cu = new CompilationUnit()
+        cu.addSource('C.groovy', src)
+        cu.compile(Phases.CONVERSION)
+        IfStatement ifStmt = null
+        cu.ast.classes[0].getMethods('m')[0].code.visit(new CodeVisitorSupport() {
+            @Override
+            void visitIfElse(IfStatement statement) {
+                ifStmt = statement
+            }
+        })
+        ifStmt.booleanExpression.expression
+    }
 }
diff --git a/src/test/groovy/groovy/InstanceofTest.groovy b/src/test/groovy/groovy/InstanceofTest.groovy
index 4caa670509..ba7b6ec8de 100644
--- a/src/test/groovy/groovy/InstanceofTest.groovy
+++ b/src/test/groovy/groovy/InstanceofTest.groovy
@@ -498,4 +498,289 @@ final class InstanceofTest {
             true
         ''')
     }
+
+    // -------------------------------------------------------------------------
+    // GROOVY-12242: systematic visibility matrix
+    //
+    // For each condition shape the test verifies:
+    //   - if-block visibility
+    //   - else-block visibility
+    //   - after-if-else visibility (with and without abrupt completion)
+    // -------------------------------------------------------------------------
+
+    // --- (1) simple: o instanceof String s ---
+
+    // pattern var in if-block (already covered by testVariable above);
+    // here we also test: NOT in else-block, NOT after if-else (no abrupt branch)
+    @Test
+    void testSimpleInstanceof_notInElse_notAfterIf() {
+        def err = shouldFail MissingPropertyException, '''
+            class C {
+                def m(Object o) {
+                    if (o instanceof String s) { /* ok */ }
+                    else { return s }
+                }
+            }
+            new C().m(1)
+        '''
+        assert err.message =~ /No such property: s/
+
+        err = shouldFail MissingPropertyException, '''
+            class C {
+                def m(Object o) {
+                    if (o instanceof String s) { /* ok */ }
+                    return s
+                }
+            }
+            new C().m('x')
+        '''
+        assert err.message =~ /No such property: s/
+    }
+
+    // pattern var IS visible after if when else cannot complete normally
+    @Test
+    void testSimpleInstanceof_visibleAfterIf_whenElseAbrupt() {
+        // else throws → s is visible after
+        def f = { Object o ->
+            if (o instanceof String s) {
+                // matched
+            } else {
+                throw new IllegalArgumentException('not a string')
+            }
+            s.toUpperCase()
+        }
+        assert f('hello') == 'HELLO'
+        try { f(1); assert false } catch (IllegalArgumentException ignored) {}
+    }
+
+    // --- (2) simple: !(o instanceof String s) ---
+
+    // true-branch (the !instanceof branch) must NOT see s;
+    // false-branch (else) MUST see s
+    @Test
+    void testNegatedInstanceof_truePathHides_falsePathBinds() {
+        // else sees s (already covered by testVariableScopeNegatedElse)
+        // here: if-branch does NOT see s
+        def err = shouldFail MissingPropertyException, '''
+            class C {
+                def m(Object o) {
+                    if (!(o instanceof String s)) {
+                        return s
+                    }
+                    return 'ok'
+                }
+            }
+            new C().m(1)
+        '''
+        assert err.message =~ /No such property: s/
+    }
+
+    // --- (3) !instanceof s, plus return in else block (→ s after if) ---
+    // (already covered by testVariableScopeEarlyReturn / testVariableScopeNegatedElse;
+    //  repeat here as an explicit cell in the matrix)
+    @Test
+    void testNegatedInstanceof_earlyReturnInTrue_visibleAfter() {
+        def f = { Object o ->
+            if (!(o instanceof String s)) return 'nope'
+            s.toUpperCase()
+        }
+        assert f('hi') == 'HI'
+        assert f(42) == 'nope'
+    }
+
+    // --- (4) o instanceof String s && cond ---
+
+    // if-block: s visible; else-block: s NOT visible; after: NOT visible
+    @Test
+    void testAndChain_ifBlockVisible_elseNotVisible_afterNotVisible() {
+        // if-block is visible (tested by testVariableAndChain and testVariableScope)
+        // else-block: NOT visible
+        def err = shouldFail MissingPropertyException, '''
+            class C {
+                def m(Object o) {
+                    if (o instanceof String s && s.length() > 0) {
+                        /* ok */
+                    } else {
+                        return s
+                    }
+                }
+            }
+            new C().m(1)
+        '''
+        assert err.message =~ /No such property: s/
+
+        // after if: NOT visible (even if both branches complete normally)
+        err = shouldFail MissingPropertyException, '''
+            class C {
+                def m(Object o) {
+                    if (o instanceof String s && s.length() > 0) { /* ok */ }
+                    return s
+                }
+            }
+            new C().m('x')
+        '''
+        assert err.message =~ /No such property: s/
+    }
+
+    // --- (5) o instanceof String s || cond ---
+
+    // In dynamic Groovy the evaluateInstanceof always allocates the slot, so s can be
+    // accessed in the if-block at runtime (even though flow scoping says it's not
+    // guaranteed). TypeChecked enforces the stricter Java rule: true-path binding of
+    // left of || is NOT in scope on the right (Java rule) and NOT in the if-block.
+    @Test
+    void testOrChain_noVisibilityAnywhere() {
+        def shell = GroovyShell.withConfig {
+            ast groovy.transform.TypeChecked
+        }
+        // s must not be visible in the if-block when condition is instanceof s || ...
+        def err = shouldFail shell, '''
+            @groovy.transform.TypeChecked
+            class C {
+                static Object m(Object o) {
+                    if (o instanceof String s || true) {
+                        return s
+                    }
+                    return 'ok'
+                }
+            }
+        '''
+        assert err.message =~ /The variable .s. is undeclared|Apparent variable .s./
+    }
+
+    // --- (6) !(o instanceof String s) && cond ---
+    // De Morgan: ≡ (!instanceof s) && cond
+    // In dynamic mode, evaluateInstanceof defines the slot during condition evaluation,
+    // so s resolves dynamically even in the if-body. TypeChecked enforces the strict rule:
+    // && propagates no false-bindings, so if-block (true path) does NOT see s.
+    @Test
+    void testNegatedAndCond_noVisibility() {
+        def shell = GroovyShell.withConfig {
+            ast groovy.transform.TypeChecked
+        }
+        def err = shouldFail shell, '''
+            @groovy.transform.TypeChecked
+            class C {
+                static Object m(Object o) {
+                    if (!(o instanceof String s) && true) {
+                        return s
+                    }
+                    return 'out'
+                }
+            }
+        '''
+        assert err.message =~ /The variable .s. is undeclared|Apparent variable .s./
+    }
+
+    // --- (7) !(o instanceof String s) && cond, plus return in else block ---
+    // After the if: the if-block's true-path has !(s bound) && cond, no s guarantee.
+    // TypeChecked enforces that s is NOT visible after the if statement.
+    @Test
+    void testNegatedAndCond_withElseReturn_noVisibilityAfter() {
+        def shell = GroovyShell.withConfig {
+            ast groovy.transform.TypeChecked
+        }
+        def err = shouldFail shell, '''
+            @groovy.transform.TypeChecked
+            class C {
+                static Object m(Object o) {
+                    if (!(o instanceof String s) && true) {
+                        // true path: s not definitely bound
+                    } else {
+                        return 'out'
+                    }
+                    return s
+                }
+            }
+        '''
+        assert err.message =~ /The variable .s. is undeclared|Apparent variable .s./
+    }
+
+    // --- (8) !(o instanceof String s) || cond ---
+    // Equivalent to !s || cond. False path of || = both sides false → s is bound when
+    // left is false (i.e. o instanceof String s) AND right is false.
+    // So else-block sees s; after-if with abrupt if-block sees s.
+    @Test
+    void testNegatedOr_elseBlockSees_afterAbruptIfBlockSees() {
+        // else-block: !(!(s)) || cond is false → !(o instanceof s) is false → s bound
+        def f = { Object o ->
+            if (!(o instanceof String s) || s.isEmpty()) {
+                return 'branch-true'
+            } else {
+                // here s is definitely bound (the !instanceof was false, so instanceof matched)
+                return 'has-s:' + s
+            }
+        }
+        assert f('hello') == 'has-s:hello'   // !instanceof false, so else
+        assert f('') == 'branch-true'         // !instanceof false but s.isEmpty true → if
+        assert f(42) == 'branch-true'         // !instanceof true → if
+
+        // after if with abrupt else (throw): s visible after
+        def g = { Object o ->
+            if (!(o instanceof String s) || s.isEmpty()) {
+                /* fell through */
+            } else {
+                throw new IllegalStateException('non-empty string')
+            }
+            // s NOT visible here (if-block can complete normally without binding s)
+        }
+        g('') // no exception
+        g(42) // no exception
+    }
+
+    // --- (9) !(o instanceof String s) || cond, plus return in else block ---
+    // true-path completes normally → s NOT visible after if-else when else abruptly returns
+    // (because the if-path does not guarantee s is bound)
+    @Test
+    void testNegatedOr_elseReturn_noVisibilityAfter() {
+        def err = shouldFail MissingPropertyException, '''
+            class C {
+                def m(Object o) {
+                    if (!(o instanceof String s) || s.isEmpty()) {
+                        /* true branch: no guarantee s is bound */
+                    } else {
+                        return 'else'
+                    }
+                    return s
+                }
+            }
+            new C().m('')
+        '''
+        assert err.message =~ /No such property: s/
+    }
+
+    // -------------------------------------------------------------------------
+    // Additional De Morgan / compound cases
+    // -------------------------------------------------------------------------
+
+    // De Morgan: !(a && b) ≡ !a || !b
+    // !(o instanceof String s && cond) — no binding anywhere (conservative)
+    @Test
+    void testDeMorgan_notAndNegation_noBinding() {
+        def err = shouldFail MissingPropertyException, '''
+            class C {
+                def m(Object o) {
+                    if (!(o instanceof String s && s.length() > 0)) {
+                        return s
+                    }
+                    return 'ok'
+                }
+            }
+            new C().m(1)
+        '''
+        assert err.message =~ /No such property: s/
+    }
+
+    // Double negation: !!(o instanceof String s) ≡ o instanceof String s
+    @Test
+    void testDoubleNegation_positiveBinding() {
+        def f = { Object o ->
+            if (!!(o instanceof String s)) {
+                return s.toUpperCase()
+            }
+            return 'no'
+        }
+        assert f('ab') == 'AB'
+        assert f(1)   == 'no'
+    }
 }