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 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'
+ }
}