Repository: quora/pyanalyze
Pull request #784 (2024-07-02 19:47:37)

Unsafe comparison overlap
This isn't ready and has some false positives even on pyanalyze's own code but I want to try it out.


Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index aec5478..fa105f3 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -2,6 +2,8 @@
 
 ## Unreleased
 
+- Add new error code `unsafe_comparison`, which gets triggered
+  when two values are compared that can never be equal. (#784)
 - Improve representation of known module, function, and type objects
   in error messages (#788)
 - Add a mechanism to allow overriding the global variables in an
diff --git a/pyanalyze/error_code.py b/pyanalyze/error_code.py
index 83d92cb..f64ef5f 100644
--- a/pyanalyze/error_code.py
+++ b/pyanalyze/error_code.py
@@ -168,6 +168,7 @@ ErrorCode = ErrorRegistry(
         Error("invalid_typeguard", "Invalid use of TypeGuard or TypeIs"),
         Error("readonly_typeddict", "TypedDict is read-only"),
         Error("generator_return", "Generator must return an iterable"),
+        Error("unsafe_comparison", "Non-overlapping equality checks"),
     ]
 )
 
diff --git a/pyanalyze/name_check_visitor.py b/pyanalyze/name_check_visitor.py
index 0a9e02d..205829d 100644
--- a/pyanalyze/name_check_visitor.py
+++ b/pyanalyze/name_check_visitor.py
@@ -50,6 +50,7 @@ from typing import (
     TypeVar,
     Union,
 )
+from unittest.mock import ANY
 
 import asynq
 import qcore
@@ -195,10 +196,13 @@ from .value import (
     KVPair,
     MultiValuedValue,
     NoReturnConstraintExtension,
+    OverlapMode,
     ReferencingValue,
     SequenceValue,
     SkipDeprecatedExtension,
     SubclassValue,
+    SysPlatformExtension,
+    SysVersionInfoExtension,
     TypeAlias,
     TypeAliasValue,
     TypedValue,
@@ -629,7 +633,7 @@ def should_check_for_duplicate_values(cls: object, options: Options) -> bool:
 
 
 def _anything_to_any(obj: object) -> Optional[Value]:
-    if obj is Anything:
+    if obj is Anything or obj is ANY:
         return AnyValue(AnySource.explicit)
     return None
 
@@ -3504,15 +3508,50 @@ class NameCheckVisitor(node_visitor.ReplacingNodeVisitor):
             unite_values(*results), AndConstraint.make(constraints)
         )
 
+    def check_for_unsafe_comparison(
+        self, op: ast.cmpop, lhs: Value, rhs: Value, parent_node: ast.AST
+    ) -> None:
+        if lhs == KnownNone or rhs == KnownNone:
+            return
+        if isinstance(op, (ast.Eq, ast.NotEq)):
+            mode = OverlapMode.EQ
+        elif isinstance(op, (ast.Is, ast.IsNot)):
+            mode = OverlapMode.IS
+        else:
+            return
+        if KnownNone.is_assignable(lhs, self) or KnownNone.is_assignable(rhs, self):
+            return
+        error = lhs.can_overlap(rhs, self, mode)
+        if error is None:
+            return
+        lhs_shown = lhs
+        rhs_shown = rhs
+        for ignored_extension in (
+            SysPlatformExtension,
+            SysVersionInfoExtension,
+            ConstraintExtension,
+            DefiniteValueExtension,
+        ):
+            lhs_shown, _ = unannotate_value(lhs_shown, ignored_extension)
+            rhs_shown, _ = unannotate_value(rhs_shown, ignored_extension)
+        self._show_error_if_checking(
+            msg=f"Comparison between objects that do not overlap: {lhs_shown} and {rhs_shown}",
+            error_code=ErrorCode.unsafe_comparison,
+            detail=str(error),
+            node=parent_node,
+        )
+
     def _visit_single_compare(
         self,
         lhs_node: ast.AST,
         lhs: Value,
-        op: ast.AST,
+        op: ast.cmpop,
         rhs_node: ast.AST,
         rhs: Value,
         parent_node: ast.AST,
     ) -> Value:
+        self.check_for_unsafe_comparison(op, lhs, rhs, parent_node)
+
         lhs_constraint = extract_constraints(lhs)
         rhs_constraint = extract_constraints(rhs)
         if isinstance(rhs, AnnotatedValue):
diff --git a/pyanalyze/patma.py b/pyanalyze/patma.py
index b8d19b5..9d4a547 100644
--- a/pyanalyze/patma.py
+++ b/pyanalyze/patma.py
@@ -53,12 +53,12 @@ from .value import (
     DictIncompleteValue,
     KnownValue,
     KVPair,
+    OverlapMode,
     SequenceValue,
     SubclassValue,
     TypedValue,
     Value,
     flatten_values,
-    is_overlapping,
     kv_pairs_from_mapping,
     replace_known_sequence_value,
     unannotate,
@@ -423,12 +423,16 @@ class PatmaVisitor(ast.NodeVisitor):
         return Constraint(varname, typ, True, value)
 
     def check_impossible_pattern(self, node: ast.AST, value: Value) -> None:
-        if not is_overlapping(self.visitor.match_subject.value, value, self.visitor):
+        error = self.visitor.match_subject.value.can_overlap(
+            value, self.visitor, OverlapMode.MATCH
+        )
+        if error is not None:
             self.visitor.show_error(
                 node,
                 f"Impossible pattern: {self.visitor.match_subject.value} can never"
                 f" be {value}",
                 ErrorCode.impossible_pattern,
+                detail=str(error),
             )
 
 
@@ -475,7 +479,8 @@ def get_value_from_kv_pairs(
                     continue
                 elif is_required:
                     covered_keys.add(my_key)
-        if is_overlapping(key, pair.key, ctx):
+        maybe_error = key.can_overlap(pair.key, ctx, OverlapMode.MATCH)
+        if maybe_error is None:
             possible_values.append(pair.value)
             new_optional_pairs.add(pair)
     if not possible_values:
diff --git a/pyanalyze/signature.py b/pyanalyze/signature.py
index d41c866..d9f7da2 100644
--- a/pyanalyze/signature.py
+++ b/pyanalyze/signature.py
@@ -458,7 +458,7 @@ class SigParameter:
 
     def __post_init__(self) -> None:
         # backward compatibility
-        if self.default is EMPTY:
+        if self.default is EMPTY:  # static analysis: ignore[unsafe_comparison]
             object.__setattr__(self, "default", None)
 
     def substitute_typevars(self, typevars: TypeVarMap) -> "SigParameter":
diff --git a/pyanalyze/type_object.py b/pyanalyze/type_object.py
index 4c06247..919a8d9 100644
--- a/pyanalyze/type_object.py
+++ b/pyanalyze/type_object.py
@@ -4,11 +4,19 @@ An object that represents a type.
 
 """
 
+import collections.abc
 import inspect
 from dataclasses import dataclass, field
-from typing import Container, Dict, Sequence, Set, Union
+from typing import Callable, Container, Dict, Sequence, Set, Union, cast
 from unittest import mock
 
+from pyanalyze.signature import (
+    BoundMethodSignature,
+    OverloadedSignature,
+    ParameterKind,
+    Signature,
+)
+
 from .safe import safe_in, safe_isinstance, safe_issubclass
 from .value import (
     UNINITIALIZED_VALUE,
@@ -193,6 +201,26 @@ class TypeObject:
             bounds_maps.append(can_assign)
         return unify_bounds_maps(bounds_maps)
 
+    def overrides_eq(self, self_val: Value, ctx: CanAssignContext) -> bool:
+        if self.typ is type(None):
+            return False
+        member = ctx.get_attribute_from_value(self_val, "__eq__")
+        sig = ctx.signature_from_value(member)
+        if isinstance(sig, BoundMethodSignature):
+            sig = sig.signature
+        if isinstance(sig, OverloadedSignature):
+            return True
+        elif isinstance(sig, Signature):
+            if len(sig.parameters) != 2:
+                return True
+            param = list(sig.parameters.values())[1]
+            if param.kind in (
+                ParameterKind.POSITIONAL_ONLY,
+                ParameterKind.POSITIONAL_OR_KEYWORD,
+            ) and param.annotation == TypedValue(object):
+                return False
+        return True
+
     def is_instance(self, obj: object) -> bool:
         """Whether obj is an instance of this type."""
         return safe_isinstance(obj, self.typ)
@@ -200,6 +228,9 @@ class TypeObject:
     def is_exactly(self, types: Container[type]) -> bool:
         return self.typ in types
 
+    def can_be_unbound_method(self) -> bool:
+        return self.is_exactly({cast(type, Callable), collections.abc.Callable, object})
+
     def is_metatype_of(self, other: "TypeObject") -> bool:
         if isinstance(self.typ, type) and isinstance(other.typ, type):
             return issubclass(self.typ, type) and safe_isinstance(other.typ, self.typ)
diff --git a/pyanalyze/typeshed.py b/pyanalyze/typeshed.py
index 23804a7..b5cd1e4 100644
--- a/pyanalyze/typeshed.py
+++ b/pyanalyze/typeshed.py
@@ -19,7 +19,6 @@ from enum import EnumMeta
 from types import GeneratorType, MethodDescriptorType, ModuleType
 from typing import (
     Any,
-    Callable,
     Dict,
     Generic,
     Iterable,
@@ -34,7 +33,7 @@ from typing import (
 
 import qcore
 import typeshed_client
-from typing_extensions import Protocol, TypedDict
+from typing_extensions import Protocol
 
 from pyanalyze import node_visitor
 from pyanalyze.functions import translate_vararg_type
@@ -306,14 +305,8 @@ class TypeshedFinder:
                 # too confusing, just hardcode it.
                 if typ is AbstractSet:
                     return [GenericValue(Collection, (TypeVarValue(T_co),))]
-                if typ is Callable or typ is collections.abc.Callable:
+                if typ is collections.abc.Callable:
                     return None
-                if typ is TypedDict:
-                    return [
-                        GenericValue(
-                            MutableMapping, [TypedValue(str), TypedValue(object)]
-                        )
-                    ]
                 # In 3.11 it's named EnumType and EnumMeta is an alias, but the
                 # stubs have it the other way around. We can't deal with that for now.
                 if typ is EnumMeta:
diff --git a/pyanalyze/value.py b/pyanalyze/value.py
index 46e457f..985726a 100644
--- a/pyanalyze/value.py
+++ b/pyanalyze/value.py
@@ -43,17 +43,15 @@ from typing import (
     Type,
     TypeVar,
     Union,
-    cast,
 )
 
 import qcore
-from typing_extensions import ParamSpec, Protocol
+from typing_extensions import ParamSpec, Protocol, assert_never
 
 import pyanalyze
 from pyanalyze.error_code import Error
 from pyanalyze.extensions import CustomCheck, ExternalType
-
-from .safe import all_of_type, safe_equals, safe_isinstance, safe_issubclass
+from pyanalyze.safe import all_of_type, safe_equals, safe_isinstance, safe_issubclass
 
 T = TypeVar("T")
 # __builtin__ in Python 2 and builtins in Python 3
@@ -91,6 +89,12 @@ BoundsMap = Mapping[TypeVarLike, Sequence[ExternalType["pyanalyze.value.Bound"]]
 GenericBases = Mapping[Union[type, str], TypeVarMap]
 
 
+class OverlapMode(enum.Enum):
+    IS = 1
+    MATCH = 2
+    EQ = 3
+
+
 class Value:
     """Base class for all values."""
 
@@ -137,6 +141,36 @@ class Value:
             return {}
         return CanAssignError(f"Cannot assign {other} to {self}")
 
+    def can_overlap(
+        self, other: "Value", ctx: "CanAssignContext", mode: OverlapMode
+    ) -> Optional["CanAssignError"]:
+        """Returns whether self and other can overlap.
+
+        Return None if they can overlap, otherwise a CanAssignError explaining
+        why they cannot.
+
+        """
+        if isinstance(other, (AnyValue, VariableNameValue)):
+            return None
+        if isinstance(other, MultiValuedValue):
+            # allow overlap with Never
+            if other is NO_RETURN_VALUE:
+                return None
+            errors: List[CanAssignError] = []
+            for val in other.vals:
+                maybe_error = self.can_overlap(val, ctx, mode)
+                if maybe_error is None:
+                    return None
+                errors.append(maybe_error)
+            return CanAssignError("cannot overlap with union", errors)
+        if isinstance(other, AnnotatedValue):
+            return self.can_overlap(other.value, ctx, mode)
+        if isinstance(other, TypeVarValue):
+            return self.can_overlap(other.get_fallback_value(), ctx, mode)
+        if isinstance(other, TypeAliasValue):
+            return self.can_overlap(other.get_value(), ctx, mode)
+        return CanAssignError(f"{self} and {other} cannot overlap")
+
     def is_assignable(self, other: "Value", ctx: "CanAssignContext") -> bool:
         """Similar to :meth:`can_assign` but returns a bool for simplicity."""
         return isinstance(self.can_assign(other, ctx), dict)
@@ -406,6 +440,11 @@ class AnyValue(Value):
             return super().can_assign(other, ctx)
         return {}  # Always allowed
 
+    def can_overlap(
+        self, other: Value, ctx: CanAssignContext, mode: OverlapMode
+    ) -> Optional[CanAssignError]:
+        return None  # always overlaps
+
 
 UNRESOLVED_VALUE = AnyValue(AnySource.default)
 """The default instance of :class:`AnyValue`.
@@ -502,6 +541,13 @@ class TypeAliasValue(Value):
             return {}
         return other.can_assign(self.get_value(), ctx)
 
+    def can_overlap(
+        self, other: Value, ctx: CanAssignContext, mode: OverlapMode
+    ) -> Optional[CanAssignError]:
+        if isinstance(other, TypeAliasValue) and self.alias is other.alias:
+            return None
+        return self.get_value().can_overlap(other, ctx, mode)
+
 
 @dataclass(frozen=True)
 class UninitializedValue(Value):
@@ -560,6 +606,33 @@ class KnownValue(Value):
                 return {}
         return super().can_assign(other, ctx)
 
+    def can_overlap(
+        self, other: Value, ctx: CanAssignContext, mode: OverlapMode
+    ) -> Optional[CanAssignError]:
+        if isinstance(other, (SubclassValue, TypedValue)):
+            return other.can_overlap(self, ctx, mode)
+        elif isinstance(other, KnownValue):
+            if self.val is other.val:
+                return None
+            if mode is OverlapMode.IS:
+                # Allow different literals of the same type, otherwise
+                # we get lots of false positives.
+                if type(self.val) is type(other.val):
+                    return None
+                return CanAssignError(f"{self} and {other} cannot overlap")
+            elif mode is OverlapMode.MATCH:
+                if safe_equals(self.val, other.val):
+                    return None
+                return CanAssignError(f"{self} and {other} cannot overlap")
+            elif mode is OverlapMode.EQ:
+                # For EQ mode we're more permissive and allow overlapping types
+                return TypedValue(type(self.val)).can_overlap(
+                    TypedValue(type(other.val)), ctx, mode
+                )
+            else:
+                assert_never(mode)
+        return super().can_overlap(other, ctx, mode)
+
     def __eq__(self, other: Value) -> bool:
         return (
             isinstance(other, KnownValue)
@@ -695,6 +768,14 @@ class UnboundMethodValue(Value):
             return {}
         return CallableValue(signature).can_assign(other, ctx)
 
+    def can_overlap(
+        self, other: Value, ctx: CanAssignContext, mode: OverlapMode
+    ) -> Optional[CanAssignError]:
+        signature = self.get_signature(ctx)
+        if signature is None:
+            return None
+        return CallableValue(signature).can_overlap(other, ctx, mode)
+
     def get_signature(
         self, ctx: CanAssignContext
     ) -> Optional["pyanalyze.signature.ConcreteSignature"]:
@@ -771,9 +852,7 @@ class TypedValue(Value):
             elif isinstance(other.typ, (TypeVarValue, AnyValue)):
                 return {}
         elif isinstance(other, UnboundMethodValue):
-            if self_tobj.is_exactly(
-                {cast(type, Callable), collections.abc.Callable, object}
-            ):
+            if self_tobj.can_be_unbound_method():
                 return {}
         return super().can_assign(other, ctx)
 
@@ -809,6 +888,51 @@ class TypedValue(Value):
             return self.can_assign_thrift_enum(other.value, ctx)
         return CanAssignError(f"Cannot assign {other} to Thrift enum {self}")
 
+    def can_overlap(
+        self, other: Value, ctx: CanAssignContext, mode: OverlapMode
+    ) -> Optional[CanAssignError]:
+        self_tobj = self.get_type_object(ctx)
+        if self_tobj.is_thrift_enum:
+            if isinstance(other, (KnownValue, TypedValue)):
+                can_assign = self.can_assign_thrift_enum(other, ctx)
+                if isinstance(can_assign, CanAssignError):
+                    return can_assign
+                return None
+            else:
+                return super().can_overlap(other, ctx, mode)
+        elif isinstance(other, KnownValue):
+            if mode is OverlapMode.IS:
+                if self_tobj.is_instance(other.val):
+                    return None
+                return CanAssignError(f"{self} and {other} cannot overlap")
+            return self.can_overlap(TypedValue(type(other.val)), ctx, mode)
+        elif isinstance(other, TypedValue):
+            left_errors = self_tobj.can_assign(self, other, ctx)
+            if not isinstance(left_errors, CanAssignError):
+                return None
+            other_tobj = other.get_type_object(ctx)
+            right_errors = other_tobj.can_assign(other, self, ctx)
+            if not isinstance(right_errors, CanAssignError):
+                return None
+            if mode in (OverlapMode.EQ, OverlapMode.MATCH):
+                if self_tobj.overrides_eq(self, ctx) or other_tobj.overrides_eq(
+                    other, ctx
+                ):
+                    return None
+            return CanAssignError(
+                f"{self} and {other} cannot overlap", [left_errors, right_errors]
+            )
+        elif isinstance(other, SubclassValue):
+            if isinstance(other.typ, TypedValue):
+                error = self_tobj.can_assign(self, other, ctx)
+                if isinstance(error, CanAssignError):
+                    return error
+                return None
+            else:
+                return None
+        else:
+            return super().can_overlap(other, ctx, mode)
+
     def get_generic_args_for_type(
         self, typ: Union[type, super, str], ctx: CanAssignContext
     ) -> Optional[List[Value]]:
@@ -893,6 +1017,15 @@ class NewTypeValue(TypedValue):
                 return CanAssignError(f"Cannot assign {other} to {self}")
         return super().can_assign(other, ctx)
 
+    def can_overlap(
+        self, other: Value, ctx: CanAssignContext, mode: OverlapMode
+    ) -> Optional[CanAssignError]:
+        if isinstance(other, NewTypeValue):
+            if self.newtype is other.newtype:
+                return None
+            return CanAssignError(f"NewTypes {self} and {other} cannot overlap")
+        return super().can_overlap(other, ctx, mode)
+
     def __str__(self) -> str:
         return f"NewType({self.name!r}, {stringify_object(self.typ)})"
 
@@ -943,6 +1076,21 @@ class GenericValue(TypedValue):
 
         return super().can_assign(original_other, ctx)
 
+    def can_overlap(
+        self, other: Value, ctx: CanAssignContext, mode: OverlapMode
+    ) -> Optional[CanAssignError]:
+        if isinstance(other, GenericValue) and self.typ is other.typ:
+            if len(self.args) != len(other.args):
+                return CanAssignError(f"Cannot overlap {self} and {other}")
+            for i, (my_arg, their_arg) in enumerate(zip(self.args, other.args)):
+                maybe_error = my_arg.can_overlap(their_arg, ctx, mode)
+                if maybe_error is not None:
+                    return CanAssignError(
+                        f"In generic argument {i} to {self}", [maybe_error]
+                    )
+            return None
+        return super().can_overlap(other, ctx, mode)
+
     def maybe_specify_error(
         self, i: int, other: Value, error: CanAssignError, ctx: CanAssignContext
     ) -> CanAssignError:
@@ -1491,6 +1639,48 @@ class TypedDictValue(GenericValue):
             return unify_bounds_maps(bounds_maps)
         return super().can_assign(other, ctx)
 
+    def can_overlap(
+        self, other: Value, ctx: CanAssignContext, mode: OverlapMode
+    ) -> Optional[CanAssignError]:
+        other = replace_known_sequence_value(other)
+        if isinstance(other, TypedDictValue):
+            for key, entry in self.items.items():
+                if key not in other.items:
+                    if entry.required:
+                        if other.extra_keys is None:
+                            return CanAssignError(f"Key {key} is missing in {other}")
+                        else:
+                            maybe_error = entry.typ.can_overlap(
+                                other.extra_keys, ctx, mode
+                            )
+                            if maybe_error is not None:
+                                return CanAssignError(
+                                    f"Type for key {key} is incompatible with extra keys"
+                                    f" type {other.extra_keys}",
+                                    children=[maybe_error],
+                                )
+                else:
+                    their_entry = other.items[key]
+                    maybe_error = entry.typ.can_overlap(their_entry.typ, ctx, mode)
+                    if maybe_error is not None:
+                        return CanAssignError(
+                            f"Types for key {key} cannot overlap",
+                            children=[maybe_error],
+                        )
+            for key, entry in other.items.items():
+                if key not in self.items and entry.required:
+                    if self.extra_keys is None:
+                        return CanAssignError(f"Key {key} is missing in {self}")
+                    else:
+                        maybe_error = entry.typ.can_overlap(self.extra_keys, ctx, mode)
+                        if maybe_error is not None:
+                            return CanAssignError(
+                                f"Type for key {key} is incompatible with extra keys"
+                                f" type {self.extra_keys}",
+                                children=[maybe_error],
+                            )
+        return super().can_overlap(other, ctx, mode)
+
     def substitute_typevars(self, typevars: TypeVarMap) -> "TypedDictValue":
         return TypedDictValue(
             {
@@ -1606,10 +1796,45 @@ class CallableValue(TypedValue):
 
         return super().can_assign(other, ctx)
 
+    def can_overlap(
+        self, other: Value, ctx: CanAssignContext, mode: OverlapMode
+    ) -> Optional[CanAssignError]:
+        if not isinstance(other, (MultiValuedValue, AnyValue, AnnotatedValue)):
+            signature = ctx.signature_from_value(other)
+            return _signatures_overlap(self.signature, signature, ctx)
+        return super().can_overlap(other, ctx, mode)
+
     def __str__(self) -> str:
         return str(self.signature)
 
 
+def _signatures_overlap(
+    left: "pyanalyze.signature.MaybeSignature",
+    right: "pyanalyze.signature.MaybeSignature",
+    ctx: CanAssignContext,
+) -> Optional[CanAssignError]:
+    if left is None or right is None:
+        return CanAssignError("Not a callable type")
+    if isinstance(left, pyanalyze.signature.BoundMethodSignature):
+        left = left.get_signature(ctx=ctx)
+    if isinstance(right, pyanalyze.signature.BoundMethodSignature):
+        right = right.get_signature(ctx=ctx)
+    if isinstance(left, pyanalyze.signature.Signature) and isinstance(
+        right, pyanalyze.signature.Signature
+    ):
+        left_errors = left.can_assign(right, ctx)
+        if not isinstance(left_errors, CanAssignError):
+            return None
+        right_errors = right.can_assign(left, ctx)
+        if not isinstance(right_errors, CanAssignError):
+            return None
+        return CanAssignError(
+            f"Signatures {left} and {right} cannot overlap",
+            children=[left_errors, right_errors],
+        )
+    return None
+
+
 @dataclass(frozen=True)
 class SubclassValue(Value):
     """Equivalent of ``Type[]``.
@@ -1677,6 +1902,27 @@ class SubclassValue(Value):
                 return {}
         return super().can_assign(other, ctx)
 
+    def can_overlap(
+        self, other: Value, ctx: CanAssignContext, mode: OverlapMode
+    ) -> Optional[CanAssignError]:
+        if isinstance(other, (KnownValue, TypedValue)):
+            can_assign = self.can_assign(other, ctx)
+            if not isinstance(can_assign, CanAssignError):
+                return None
+            return can_assign
+        elif isinstance(other, SubclassValue):
+            left_errors = self.can_assign(other, ctx)
+            if not isinstance(left_errors, CanAssignError):
+                return None
+            right_errors = other.can_assign(self, ctx)
+            if not isinstance(right_errors, CanAssignError):
+                return None
+            return CanAssignError(
+                f"Types {self} and {other} cannot overlap",
+                children=[left_errors, right_errors],
+            )
+        return super().can_overlap(other, ctx, mode)
+
     def get_type(self) -> Optional[type]:
         if isinstance(self.typ, TypedValue):
             return type(self.typ.typ)
@@ -1801,6 +2047,19 @@ class MultiValuedValue(Value):
                 return CanAssignError("Cannot assign to Union", errors)
             return intersect_bounds_maps(bounds_maps)
 
+    def can_overlap(
+        self, other: Value, ctx: CanAssignContext, mode: OverlapMode
+    ) -> Optional[CanAssignError]:
+        if not self.vals:
+            return None
+        errors: List[CanAssignError] = []
+        for val in self.vals:
+            error = val.can_overlap(other, ctx, mode)
+            if error is None:
+                return None
+            errors.append(error)
+        return CanAssignError("Cannot overlap with Union", errors)
+
     def get_type_value(self) -> Value:
         if not self.vals:
             return self
@@ -1952,6 +2211,11 @@ class TypeVarValue(Value):
             bounds = [LowerBound(self.typevar, other), *self.get_inherent_bounds()]
         return self.make_bounds_map(bounds, other, ctx)
 
+    def can_overlap(
+        self, other: Value, ctx: CanAssignContext, mode: OverlapMode
+    ) -> Optional[CanAssignError]:
+        return self.get_fallback_value().can_overlap(other, ctx, mode)
+
     def can_be_assigned(self, left: Value, ctx: CanAssignContext) -> CanAssign:
         if left == self:
             return {}
@@ -2369,6 +2633,11 @@ class AnnotatedValue(Value):
             bounds_maps.append(custom_can_assign)
         return unify_bounds_maps(bounds_maps)
 
+    def can_overlap(
+        self, other: Value, ctx: CanAssignContext, mode: OverlapMode
+    ) -> Optional[CanAssignError]:
+        return self.value.can_overlap(other, ctx, mode)
+
     def walk_values(self) -> Iterable[Value]:
         yield self
         yield from self.value.walk_values()
@@ -2444,6 +2713,11 @@ class VariableNameValue(AnyValue):
             return {}
         return CanAssignError(f"Types {self} and {other} are different")
 
+    def can_overlap(
+        self, other: Value, ctx: CanAssignContext, mode: OverlapMode
+    ) -> Optional[CanAssignError]:
+        return None
+
     def __str__(self) -> str:
         return "<variable name: %s>" % ", ".join(self.varnames)
 
@@ -3108,7 +3382,22 @@ def can_assign_and_used_any(
     return tv_map, used_any
 
 
+def _deliteral(value: Value) -> Value:
+    value = unannotate(value)
+    if isinstance(value, KnownValue):
+        value = TypedValue(type(value.val))
+    if isinstance(value, SequenceValue):
+        value = TypedValue(value.typ)
+    return value
+
+
 def is_overlapping(left: Value, right: Value, ctx: CanAssignContext) -> bool:
+    # Fairly permissive checks for now; possibly this can be tightened up later.
+    left = _deliteral(left)
+    right = _deliteral(right)
+    if isinstance(left, MultiValuedValue) and left.vals:
+        # Swap the operands so we decompose and de-Literal the other union too
+        return any(is_overlapping(right, val, ctx) for val in left.vals)
     return left.is_assignable(right, ctx) or right.is_assignable(left, ctx)
 
 


Test patch:
diff --git a/pyanalyze/test_operations.py b/pyanalyze/test_operations.py
index b7cffb6..1f720e5 100644
--- a/pyanalyze/test_operations.py
+++ b/pyanalyze/test_operations.py
@@ -255,7 +255,7 @@ class TestCompare(TestNameCheckVisitorBase):
     @assert_passes()
     def test_failing_eq(self):
         class FlakyCapybara:
-            def __eq__(self, other: object) -> bool:
+            def __eq__(self, other: int) -> bool:
                 raise IndentationError
 
         fc = FlakyCapybara()
diff --git a/pyanalyze/test_unsafe_comparison.py b/pyanalyze/test_unsafe_comparison.py
new file mode 100644
index 0000000..819569b
--- /dev/null
+++ b/pyanalyze/test_unsafe_comparison.py
@@ -0,0 +1,137 @@
+# static analysis: ignore
+from .test_name_check_visitor import TestNameCheckVisitorBase
+from .test_node_visitor import assert_passes
+
+
+class TestUnsafeOverlap(TestNameCheckVisitorBase):
+    @assert_passes()
+    def test_simple(self):
+        from typing_extensions import Never
+
+        def capybara(x: Never, y: str, z: int):
+            assert x == 1
+            assert 1 == 2
+            assert __name__ == "__main__"
+            assert y == z  # E: unsafe_comparison
+
+    @assert_passes()
+    def test_none(self):
+        def capybara(x: str):
+            assert x is not None
+
+    @assert_passes()
+    def test_fancy_none(self):
+        class X:
+            def __init__(self) -> None:
+                self.y = None
+
+        def capybara(x: X):
+            assert x.y == 42  # OK
+
+    @assert_passes()
+    def test_union(self):
+        from typing import Union
+
+        def capybara(x: Union[int, str], z: Union[str, bytes]):
+            assert x == z  # ok
+            assert x == b"y"  # E: unsafe_comparison
+            assert 1 == z  # E: unsafe_comparison
+
+    @assert_passes()
+    def test_subclass_value(self):
+        from typing import Type
+
+        def capybara(x: type, y: Type[int], marker: int):
+            if marker == 0:
+                assert x == int
+            elif marker == 1:
+                assert int == x
+            elif marker == 2:
+                assert x == y
+            elif marker == 3:
+                assert y == x
+            elif marker == 4:
+                assert str == y  # E: unsafe_comparison
+            elif marker == 5:
+                assert y == str  # E: unsafe_comparison
+
+    @assert_passes()
+    def test_mock_any(self):
+        from unittest.mock import ANY
+
+        def capybara(x: int):
+            assert x == ANY
+
+    @assert_passes()
+    def test_asynq(self):
+        from asynq import AsyncTask, asynq
+
+        from pyanalyze.value import (
+            AsyncTaskIncompleteValue,
+            TypedValue,
+            assert_is_value,
+        )
+
+        @asynq()
+        def f() -> int:
+            return 42
+
+        @asynq()
+        def caller():
+            x = f.asynq()
+            assert_is_value(x, AsyncTaskIncompleteValue(AsyncTask, TypedValue(int)))
+            if x == 42:  # E: unsafe_comparison
+                print("yay")
+
+    @assert_passes()
+    def test_inner_none(self):
+        from typing import List
+
+        def f(x: List[int], y: List[None]):
+            assert x == y  # E: unsafe_comparison
+
+    @assert_passes()
+    def test_allow_is_with_literals(self):
+        from typing_extensions import Literal
+
+        def f(x: Literal[True], y: object):
+            assert True is False
+            if y == 0:
+                assert x is True
+            else:
+                assert x is False
+
+
+class TestOverrideEq(TestNameCheckVisitorBase):
+    @assert_passes()
+    def test_simple_eq(self):
+        from typing_extensions import Literal, overload
+
+        class HasSimpleEq:
+            def __eq__(self, other: object) -> bool:
+                return self is other
+
+        class FancyEq1:
+            @overload
+            def __eq__(self, x: int) -> Literal[False]: ...
+            @overload
+            def __eq__(self, x: str) -> Literal[False]: ...
+            def __eq__(self, x: object) -> bool:
+                return False
+
+        class FancyEq2:
+            def __eq__(self, x: object, extra_arg: bool = False) -> bool:
+                return False
+
+        class FancyEq3:
+            def __eq__(self, *args: object) -> bool:
+                return False
+
+        def capybara(
+            x: HasSimpleEq, y: int, fe1: FancyEq1, fe2: FancyEq2, fe3: FancyEq3
+        ):
+            assert x == y  # E: unsafe_comparison
+            assert y == x  # E: unsafe_comparison
+            assert fe1 == y  # OK
+            assert fe2 == y  # OK
+            assert fe3 == y  # OK
diff --git a/pyanalyze/test_value.py b/pyanalyze/test_value.py
index f70c8c4..42aa4a7 100644
--- a/pyanalyze/test_value.py
+++ b/pyanalyze/test_value.py
@@ -25,6 +25,7 @@ from .value import (
     KnownValue,
     KVPair,
     MultiValuedValue,
+    OverlapMode,
     SequenceValue,
     SubclassValue,
     TypedValue,
@@ -687,3 +688,21 @@ def test_unpack_values() -> None:
         TypedValue(int) | TypedValue(str),
         TypedValue(float),
     ]
+
+
+def test_can_overlap() -> None:
+    assert isinstance(
+        TypedValue(str).can_overlap(TypedValue(int), CTX, OverlapMode.EQ),
+        CanAssignError,
+    )
+    assert isinstance(
+        TypedValue(str).can_overlap(KnownValue(1), CTX, OverlapMode.EQ), CanAssignError
+    )
+    assert isinstance(
+        TypedValue(str).can_overlap(TypedValue(type(None)), CTX, OverlapMode.EQ),
+        CanAssignError,
+    )
+    assert isinstance(
+        TypedValue(str).can_overlap(KnownValue(None), CTX, OverlapMode.EQ),
+        CanAssignError,
+    )