···457457458458 return False
459459460460+ def _get_non_none_type(self, t: type) -> Optional[type]:
461461+ """Extract the non-None type from an Optional[T] type.
462462+463463+ Returns T if t is Optional[T], otherwise returns None.
464464+ """
465465+ if not self._is_optional_type(t):
466466+ return None
467467+468468+ origin = get_origin(t)
469469+ if origin is EntityT:
470470+ # EntityT is treated as optional but we don't narrow it
471471+ return None
472472+473473+ # For Union types (which includes Optional), get the non-None type
474474+ if hasattr(t, '__args__'):
475475+ non_none_args = [arg for arg in t.__args__ if arg is not type(None)]
476476+ if len(non_none_args) == 1:
477477+ return non_none_args[0]
478478+ elif len(non_none_args) > 1:
479479+ # Multiple non-None types, return a Union of them
480480+ return cast(type, Union[tuple(non_none_args)])
481481+482482+ return None
483483+484484+ def _get_type_narrowing_from_expression(
485485+ self, expr: grammar.Expression, operand: grammar.BooleanOperand
486486+ ) -> Dict[str, type]:
487487+ """Detect type narrowing from null-check patterns.
488488+489489+ For 'and' operations: X != None narrows X from Optional[T] to T
490490+ For 'or' operations: X == None narrows X from Optional[T] to T (for subsequent expressions)
491491+492492+ Returns a dict of identifier_key -> narrowed type
493493+ """
494494+ narrowed_types: Dict[str, type] = {}
495495+496496+ if not isinstance(expr, grammar.BinaryComparison):
497497+ return narrowed_types
498498+499499+ is_and_operation = isinstance(operand, grammar.And)
500500+ is_not_equals = isinstance(expr.comparator, grammar.NotEquals)
501501+ is_equals = isinstance(expr.comparator, grammar.Equals)
502502+503503+ # For 'and': X != None narrows X
504504+ # For 'or': X == None narrows X (because if X == None is false, X is not None)
505505+ should_narrow = (is_and_operation and is_not_equals) or (not is_and_operation and is_equals)
506506+507507+ if not should_narrow:
508508+ return narrowed_types
509509+510510+ # Check if one side is None and the other is a Name
511511+ left_is_none = isinstance(expr.left, grammar.None_)
512512+ right_is_none = isinstance(expr.right, grammar.None_)
513513+514514+ if left_is_none and isinstance(expr.right, grammar.Name):
515515+ name = expr.right
516516+ elif right_is_none and isinstance(expr.left, grammar.Name):
517517+ name = expr.left
518518+ else:
519519+ return narrowed_types
520520+521521+ # Get the current type of the name
522522+ if name.identifier_key not in self._name_type_and_span_cache:
523523+ return narrowed_types
524524+525525+ current_type = self._name_type_and_span_cache[name.identifier_key].type
526526+ non_none_type = self._get_non_none_type(current_type)
527527+528528+ if non_none_type is not None:
529529+ narrowed_types[name.identifier_key] = non_none_type
530530+531531+ return narrowed_types
532532+460533 def _validate_binary_comparison(self, binary_comparison: grammar.BinaryComparison) -> type:
461534 def valid_transition_hook(left_type: type, right_type: type) -> None:
462535 # Some extra warnings for certain cases
···585658 def _validate_boolean_operation(self, boolean_operation: grammar.BooleanOperation) -> type:
586659 # Type check left and right sides, but return bool regardless because the underlying `any` and `all` can
587660 # handle arbitrary types and will always return bools.
661661+ #
662662+ # Apply type narrowing: for 'and' operations, X != None narrows X from Optional[T] to T
663663+ # for subsequent expressions. For 'or' operations, X == None narrows X.
664664+ narrowed_types: Dict[str, type] = {}
665665+588666 for value in boolean_operation.values:
589589- value_type = self._validate_expression(value)
590590- value_type_str = to_display_str(value_type)
591591- self._check_compatible_type(
592592- type_t=value_type,
593593- accepted_by_t=bool,
594594- message=f'unsupported operand type for `{boolean_operation.operand.original_operand}`',
595595- node=value,
596596- hint=f'has type {value_type_str}, expected `bool`',
597597- additional_spans=self._maybe_get_additional_span_for_identifier_definition(value, value_type_str),
598598- )
667667+ # Temporarily apply any accumulated type narrowings for this expression
668668+ original_types: Dict[str, _TypeAndSpan] = {}
669669+ for identifier_key, narrowed_type in narrowed_types.items():
670670+ if identifier_key in self._name_type_and_span_cache:
671671+ original_types[identifier_key] = self._name_type_and_span_cache[identifier_key]
672672+ self._name_type_and_span_cache[identifier_key] = original_types[identifier_key].copy(
673673+ type=narrowed_type
674674+ )
675675+676676+ try:
677677+ value_type = self._validate_expression(value)
678678+ value_type_str = to_display_str(value_type)
679679+ self._check_compatible_type(
680680+ type_t=value_type,
681681+ accepted_by_t=bool,
682682+ message=f'unsupported operand type for `{boolean_operation.operand.original_operand}`',
683683+ node=value,
684684+ hint=f'has type {value_type_str}, expected `bool`',
685685+ additional_spans=self._maybe_get_additional_span_for_identifier_definition(value, value_type_str),
686686+ )
687687+688688+ # Detect any new type narrowings from this expression
689689+ new_narrowings = self._get_type_narrowing_from_expression(value, boolean_operation.operand)
690690+ narrowed_types.update(new_narrowings)
691691+ finally:
692692+ # Restore original types
693693+ for identifier_key, original_type_and_span in original_types.items():
694694+ self._name_type_and_span_cache[identifier_key] = original_type_and_span
599695600696 return bool
601697···752848 number_to_bool_transition = _ValidTwoArgTypeTransition(
753849 valid_left_type=_INT_OR_FLOAT_T, valid_right_type=_INT_OR_FLOAT_T, resulting_type=bool
754850 )
851851+ # Note: Optional types are not directly supported for comparison operators.
852852+ # Use type narrowing with a null check first: X != None and X >= 90
853853+ number_comparison_transitions = [number_to_bool_transition]
755854 # For "in"/"not in"
756855 in_transitions = [
757856 _ValidTwoArgTypeTransition(valid_left_type=str, valid_right_type=str, resulting_type=bool),
···763862 comparators_to_transitions = {
764863 grammar.Equals: [any_to_bool_transition],
765864 grammar.NotEquals: [any_to_bool_transition],
766766- grammar.LessThan: [number_to_bool_transition],
767767- grammar.LessThanEquals: [number_to_bool_transition],
768768- grammar.GreaterThan: [number_to_bool_transition],
769769- grammar.GreaterThanEquals: [number_to_bool_transition],
865865+ grammar.LessThan: number_comparison_transitions,
866866+ grammar.LessThanEquals: number_comparison_transitions,
867867+ grammar.GreaterThan: number_comparison_transitions,
868868+ grammar.GreaterThanEquals: number_comparison_transitions,
770869 grammar.In: in_transitions,
771870 grammar.NotIn: in_transitions,
772871 }
···4141 _COMPARATORS[Equals],
4242 _COMPARATORS[NotEquals],
4343 )
4444+ # For numerical comparisons (<, <=, >, >=), we need to handle None at runtime
4545+ # because the executor resolves all boolean operation dependencies before
4646+ # short-circuiting. The static validator enforces null-check patterns, but
4747+ # at runtime the comparison may still be evaluated with None values.
4848+ self.handles_none_comparison = self.comparator in (
4949+ _COMPARATORS[LessThan],
5050+ _COMPARATORS[LessThanEquals],
5151+ _COMPARATORS[GreaterThan],
5252+ _COMPARATORS[GreaterThanEquals],
5353+ )
44544555 def execute(self, execution_context: 'ExecutionContext') -> bool:
4656 left = execution_context.resolved(self._node.left, return_none_for_failed_values=self.left_can_be_none)
4757 right = execution_context.resolved(self._node.right, return_none_for_failed_values=self.right_can_be_none)
5858+5959+ # Handle None values for numerical comparisons at runtime.
6060+ # Even with null-check patterns like "X != None and X >= 90", the executor
6161+ # resolves all dependencies before the boolean operation short-circuits.
6262+ if self.handles_none_comparison and (left is None or right is None):
6363+ return False
6464+4865 return bool(self.comparator(left, right))
49665067 def get_dependent_nodes(self) -> List[ASTNode]:
···167167 )
168168169169 assert data == {'Ret': expected}
170170+171171+172172+@pytest.mark.parametrize(
173173+ 'opt_val, expected',
174174+ [
175175+ (90, True),
176176+ (80, False),
177177+ ('None', False), # None value - condition short-circuits
178178+ ],
179179+)
180180+def test_optional_null_check_before_comparison(execute: ExecuteFunction, opt_val: object, expected: bool) -> None:
181181+ """Test that type narrowing works for X != None and X >= 90 pattern."""
182182+ data = execute(
183183+ f"""
184184+ OptVal: Optional[int] = {opt_val}
185185+ # Type narrowing: after OptVal != None, OptVal is narrowed from Optional[int] to int
186186+ Ret = OptVal != None and OptVal >= 90
187187+ """
188188+ )
189189+190190+ assert data == {'Ret': expected}
191191+192192+193193+def test_optional_null_check_chained_narrowing(execute: ExecuteFunction) -> None:
194194+ """Test chained type narrowing with multiple optional values."""
195195+ data = execute(
196196+ """
197197+ A: Optional[int] = 100
198198+ B: Optional[int] = 50
199199+ # Both A and B get narrowed after their respective null checks
200200+ Ret = A != None and B != None and A >= 90 and B >= 40
201201+ """
202202+ )
203203+204204+ assert data == {'Ret': True}
205205+206206+207207+@pytest.mark.parametrize(
208208+ 'opt_val, expected',
209209+ [
210210+ (90, True),
211211+ (80, False),
212212+ ('None', True), # None value - first condition is True, so result is True
213213+ ],
214214+)
215215+def test_optional_or_pattern_null_check(execute: ExecuteFunction, opt_val: object, expected: bool) -> None:
216216+ """Test that type narrowing works for X == None or X >= 90 pattern."""
217217+ data = execute(
218218+ f"""
219219+ OptVal: Optional[int] = {opt_val}
220220+ # Type narrowing: after OptVal == None is false, OptVal is narrowed from Optional[int] to int
221221+ Ret = OptVal == None or OptVal >= 90
222222+ """
223223+ )
224224+225225+ assert data == {'Ret': expected}