diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md
index 013c8ca134..34e4227f11 100644
--- a/crates/ty/docs/rules.md
+++ b/crates/ty/docs/rules.md
@@ -39,7 +39,7 @@ def test(): -> "int":
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -63,7 +63,7 @@ Calling a non-callable object will raise a `TypeError` at runtime.
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -95,7 +95,7 @@ f(int) # error
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -126,7 +126,7 @@ a = 1
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -158,7 +158,7 @@ class C(A, B): ...
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -190,7 +190,7 @@ class B(A): ...
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -217,7 +217,7 @@ class B(A, A): ...
Default level: error ·
Added in 0.0.1-alpha.12 ·
Related issues ·
-View source
+View source
@@ -329,7 +329,7 @@ def test(): -> "Literal[5]":
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -359,7 +359,7 @@ class C(A, B): ...
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -385,7 +385,7 @@ t[3] # IndexError: tuple index out of range
Default level: error ·
Added in 0.0.1-alpha.12 ·
Related issues ·
-View source
+View source
@@ -474,7 +474,7 @@ an atypical memory layout.
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -501,7 +501,7 @@ func("foo") # error: [invalid-argument-type]
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -529,7 +529,7 @@ a: int = ''
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -563,7 +563,7 @@ C.instance_var = 3 # error: Cannot assign to instance variable
Default level: error ·
Added in 0.0.1-alpha.19 ·
Related issues ·
-View source
+View source
@@ -599,7 +599,7 @@ asyncio.run(main())
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -623,7 +623,7 @@ class A(42): ... # error: [invalid-base]
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -650,7 +650,7 @@ with 1:
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -679,7 +679,7 @@ a: str
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -723,7 +723,7 @@ except ZeroDivisionError:
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -756,7 +756,7 @@ class C[U](Generic[T]): ...
Default level: error ·
Added in 0.0.1-alpha.17 ·
Related issues ·
-View source
+View source
@@ -795,7 +795,7 @@ carol = Person(name="Carol", age=25) # typo!
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -830,7 +830,7 @@ def f(t: TypeVar("U")): ...
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -864,7 +864,7 @@ class B(metaclass=f): ...
Default level: error ·
Added in 0.0.1-alpha.20 ·
Related issues ·
-View source
+View source
@@ -950,7 +950,7 @@ and `__ne__` methods accept `object` as their second argument.
Default level: error ·
Added in 0.0.1-alpha.19 ·
Related issues ·
-View source
+View source
@@ -982,7 +982,7 @@ TypeError: can only inherit from a NamedTuple type and Generic
Default level: error ·
Preview (since 1.0.0) ·
Related issues ·
-View source
+View source
@@ -1012,7 +1012,7 @@ Baz = NewType("Baz", int | str) # error: invalid base for `typing.NewType`
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1062,7 +1062,7 @@ def foo(x: int) -> int: ...
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1088,7 +1088,7 @@ def f(a: int = ''): ...
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1119,7 +1119,7 @@ P2 = ParamSpec("S2") # error: ParamSpec name must match the variable it's assig
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1153,7 +1153,7 @@ TypeError: Protocols can only inherit from other protocols, got
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1202,7 +1202,7 @@ def g():
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1227,7 +1227,7 @@ def func() -> int:
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1285,7 +1285,7 @@ TODO #14889
Default level: error ·
Added in 0.0.1-alpha.6 ·
Related issues ·
-View source
+View source
@@ -1312,7 +1312,7 @@ NewAlias = TypeAliasType(get_name(), int) # error: TypeAliasType name mus
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1342,7 +1342,7 @@ TYPE_CHECKING = ''
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1372,7 +1372,7 @@ b: Annotated[int] # `Annotated` expects at least two arguments
Default level: error ·
Added in 0.0.1-alpha.11 ·
Related issues ·
-View source
+View source
@@ -1406,7 +1406,7 @@ f(10) # Error
Default level: error ·
Added in 0.0.1-alpha.11 ·
Related issues ·
-View source
+View source
@@ -1440,7 +1440,7 @@ class C:
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1475,7 +1475,7 @@ T = TypeVar('T', bound=str) # valid bound TypeVar
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1500,7 +1500,7 @@ func() # TypeError: func() missing 1 required positional argument: 'x'
Default level: error ·
Added in 0.0.1-alpha.20 ·
Related issues ·
-View source
+View source
@@ -1533,7 +1533,7 @@ alice["age"] # KeyError
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1562,7 +1562,7 @@ func("string") # error: [no-matching-overload]
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1586,7 +1586,7 @@ Subscripting an object that does not support it will raise a `TypeError` at runt
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1612,7 +1612,7 @@ for i in 34: # TypeError: 'int' object is not iterable
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1639,7 +1639,7 @@ f(1, x=2) # Error raised here
Default level: error ·
Added in 0.0.1-alpha.22 ·
Related issues ·
-View source
+View source
@@ -1697,7 +1697,7 @@ def test(): -> "int":
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1727,7 +1727,7 @@ static_assert(int(2.0 * 3.0) == 6) # error: does not have a statically known tr
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1756,7 +1756,7 @@ class B(A): ... # Error raised here
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1783,7 +1783,7 @@ f("foo") # Error raised here
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1811,7 +1811,7 @@ def _(x: int):
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1857,7 +1857,7 @@ class A:
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1884,7 +1884,7 @@ f(x=1, y=2) # Error raised here
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1912,7 +1912,7 @@ A().foo # AttributeError: 'A' object has no attribute 'foo'
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1937,7 +1937,7 @@ import foo # ModuleNotFoundError: No module named 'foo'
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1962,7 +1962,7 @@ print(x) # NameError: name 'x' is not defined
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -1999,7 +1999,7 @@ b1 < b2 < b1 # exception raised here
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2027,7 +2027,7 @@ A() + A() # TypeError: unsupported operand type(s) for +: 'A' and 'A'
Default level: error ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2052,7 +2052,7 @@ l[1:10:0] # ValueError: slice step cannot be zero
Default level: warn ·
Added in 0.0.1-alpha.20 ·
Related issues ·
-View source
+View source
@@ -2093,7 +2093,7 @@ class SubProto(BaseProto, Protocol):
Default level: warn ·
Added in 0.0.1-alpha.16 ·
Related issues ·
-View source
+View source
@@ -2181,7 +2181,7 @@ a = 20 / 0 # type: ignore
Default level: warn ·
Added in 0.0.1-alpha.22 ·
Related issues ·
-View source
+View source
@@ -2209,7 +2209,7 @@ A.c # AttributeError: type object 'A' has no attribute 'c'
Default level: warn ·
Added in 0.0.1-alpha.22 ·
Related issues ·
-View source
+View source
@@ -2241,7 +2241,7 @@ A()[0] # TypeError: 'A' object is not subscriptable
Default level: warn ·
Added in 0.0.1-alpha.22 ·
Related issues ·
-View source
+View source
@@ -2273,7 +2273,7 @@ from module import a # ImportError: cannot import name 'a' from 'module'
Default level: warn ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2300,7 +2300,7 @@ cast(int, f()) # Redundant
Default level: warn ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
@@ -2324,7 +2324,7 @@ reveal_type(1) # NameError: name 'reveal_type' is not defined
Default level: warn ·
Added in 0.0.1-alpha.15 ·
Related issues ·
-View source
+View source
@@ -2382,7 +2382,7 @@ def g():
Default level: warn ·
Added in 0.0.1-alpha.7 ·
Related issues ·
-View source
+View source
@@ -2421,7 +2421,7 @@ class D(C): ... # error: [unsupported-base]
Default level: warn ·
Added in 0.0.1-alpha.22 ·
Related issues ·
-View source
+View source
@@ -2484,7 +2484,7 @@ def foo(x: int | str) -> int | str:
Default level: ignore ·
Preview (since 0.0.1-alpha.1) ·
Related issues ·
-View source
+View source
@@ -2508,7 +2508,7 @@ Dividing by zero raises a `ZeroDivisionError` at runtime.
Default level: ignore ·
Added in 0.0.1-alpha.1 ·
Related issues ·
-View source
+View source
diff --git a/crates/ty_python_semantic/resources/mdtest/liskov.md b/crates/ty_python_semantic/resources/mdtest/liskov.md
index fd74eca365..24a1c3e2c2 100644
--- a/crates/ty_python_semantic/resources/mdtest/liskov.md
+++ b/crates/ty_python_semantic/resources/mdtest/liskov.md
@@ -523,3 +523,63 @@ class Baz(NamedTuple):
class Spam(Baz):
def _asdict(self) -> tuple[int, ...]: ... # error: [invalid-method-override]
```
+
+## Staticmethods and classmethods
+
+Methods decorated with `@staticmethod` or `@classmethod` are checked in much the same way as other
+methods.
+
+
+
+```pyi
+class Parent:
+ def instance_method(self, x: int) -> int: ...
+ @classmethod
+ def class_method(cls, x: int) -> int: ...
+ @staticmethod
+ def static_method(x: int) -> int: ...
+
+class BadChild1(Parent):
+ @staticmethod
+ def instance_method(self, x: int) -> int: ... # error: [invalid-method-override]
+ # TODO: we should emit `invalid-method-override` here.
+ # Although the method has the same signature as `Parent.class_method`
+ # when accessed on instances, it does not have the same signature as
+ # `Parent.class_method` when accessed on the class object itself
+ def class_method(cls, x: int) -> int: ...
+ def static_method(x: int) -> int: ... # error: [invalid-method-override]
+
+class BadChild2(Parent):
+ # TODO: we should emit `invalid-method-override` here.
+ # Although the method has the same signature as `Parent.class_method`
+ # when accessed on instances, it does not have the same signature as
+ # `Parent.class_method` when accessed on the class object itself.
+ #
+ # Note that whereas `BadChild1.class_method` is reported as a Liskov violation by
+ # mypy, pyright and pyrefly, pyright is the only one of those three to report a
+ # Liskov violation on this method as of 2025-11-23.
+ @classmethod
+ def instance_method(self, x: int) -> int: ...
+ @staticmethod
+ def class_method(cls, x: int) -> int: ... # error: [invalid-method-override]
+ @classmethod
+ def static_method(x: int) -> int: ... # error: [invalid-method-override]
+
+class BadChild3(Parent):
+ @classmethod
+ def class_method(cls, x: bool) -> object: ... # error: [invalid-method-override]
+ @staticmethod
+ def static_method(x: bool) -> object: ... # error: [invalid-method-override]
+
+class GoodChild1(Parent):
+ @classmethod
+ def class_method(cls, x: int) -> int: ...
+ @staticmethod
+ def static_method(x: int) -> int: ...
+
+class GoodChild2(Parent):
+ @classmethod
+ def class_method(cls, x: object) -> bool: ...
+ @staticmethod
+ def static_method(x: object) -> bool: ...
+```
diff --git a/crates/ty_python_semantic/resources/mdtest/snapshots/liskov.md_-_The_Liskov_Substitut…_-_Staticmethods_and_cl…_(49e28aae6fdd1291).snap b/crates/ty_python_semantic/resources/mdtest/snapshots/liskov.md_-_The_Liskov_Substitut…_-_Staticmethods_and_cl…_(49e28aae6fdd1291).snap
new file mode 100644
index 0000000000..f7ca2c257f
--- /dev/null
+++ b/crates/ty_python_semantic/resources/mdtest/snapshots/liskov.md_-_The_Liskov_Substitut…_-_Staticmethods_and_cl…_(49e28aae6fdd1291).snap
@@ -0,0 +1,220 @@
+---
+source: crates/ty_test/src/lib.rs
+expression: snapshot
+---
+---
+mdtest name: liskov.md - The Liskov Substitution Principle - Staticmethods and classmethods
+mdtest path: crates/ty_python_semantic/resources/mdtest/liskov.md
+---
+
+# Python source files
+
+## mdtest_snippet.pyi
+
+```
+ 1 | class Parent:
+ 2 | def instance_method(self, x: int) -> int: ...
+ 3 | @classmethod
+ 4 | def class_method(cls, x: int) -> int: ...
+ 5 | @staticmethod
+ 6 | def static_method(x: int) -> int: ...
+ 7 |
+ 8 | class BadChild1(Parent):
+ 9 | @staticmethod
+10 | def instance_method(self, x: int) -> int: ... # error: [invalid-method-override]
+11 | # TODO: we should emit `invalid-method-override` here.
+12 | # Although the method has the same signature as `Parent.class_method`
+13 | # when accessed on instances, it does not have the same signature as
+14 | # `Parent.class_method` when accessed on the class object itself
+15 | def class_method(cls, x: int) -> int: ...
+16 | def static_method(x: int) -> int: ... # error: [invalid-method-override]
+17 |
+18 | class BadChild2(Parent):
+19 | # TODO: we should emit `invalid-method-override` here.
+20 | # Although the method has the same signature as `Parent.class_method`
+21 | # when accessed on instances, it does not have the same signature as
+22 | # `Parent.class_method` when accessed on the class object itself.
+23 | #
+24 | # Note that whereas `BadChild1.class_method` is reported as a Liskov violation by
+25 | # mypy, pyright and pyrefly, pyright is the only one of those three to report a
+26 | # Liskov violation on this method as of 2025-11-23.
+27 | @classmethod
+28 | def instance_method(self, x: int) -> int: ...
+29 | @staticmethod
+30 | def class_method(cls, x: int) -> int: ... # error: [invalid-method-override]
+31 | @classmethod
+32 | def static_method(x: int) -> int: ... # error: [invalid-method-override]
+33 |
+34 | class BadChild3(Parent):
+35 | @classmethod
+36 | def class_method(cls, x: bool) -> object: ... # error: [invalid-method-override]
+37 | @staticmethod
+38 | def static_method(x: bool) -> object: ... # error: [invalid-method-override]
+39 |
+40 | class GoodChild1(Parent):
+41 | @classmethod
+42 | def class_method(cls, x: int) -> int: ...
+43 | @staticmethod
+44 | def static_method(x: int) -> int: ...
+45 |
+46 | class GoodChild2(Parent):
+47 | @classmethod
+48 | def class_method(cls, x: object) -> bool: ...
+49 | @staticmethod
+50 | def static_method(x: object) -> bool: ...
+```
+
+# Diagnostics
+
+```
+error[invalid-method-override]: Invalid override of method `instance_method`
+ --> src/mdtest_snippet.pyi:10:9
+ |
+ 8 | class BadChild1(Parent):
+ 9 | @staticmethod
+10 | def instance_method(self, x: int) -> int: ... # error: [invalid-method-override]
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Parent.instance_method`
+11 | # TODO: we should emit `invalid-method-override` here.
+12 | # Although the method has the same signature as `Parent.class_method`
+ |
+ ::: src/mdtest_snippet.pyi:2:9
+ |
+ 1 | class Parent:
+ 2 | def instance_method(self, x: int) -> int: ...
+ | ------------------------------------ `Parent.instance_method` defined here
+ 3 | @classmethod
+ 4 | def class_method(cls, x: int) -> int: ...
+ |
+info: `BadChild1.instance_method` is a staticmethod but `Parent.instance_method` is an instance method
+info: This violates the Liskov Substitution Principle
+info: rule `invalid-method-override` is enabled by default
+
+```
+
+```
+error[invalid-method-override]: Invalid override of method `static_method`
+ --> src/mdtest_snippet.pyi:16:9
+ |
+14 | # `Parent.class_method` when accessed on the class object itself
+15 | def class_method(cls, x: int) -> int: ...
+16 | def static_method(x: int) -> int: ... # error: [invalid-method-override]
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Parent.static_method`
+17 |
+18 | class BadChild2(Parent):
+ |
+ ::: src/mdtest_snippet.pyi:6:9
+ |
+ 4 | def class_method(cls, x: int) -> int: ...
+ 5 | @staticmethod
+ 6 | def static_method(x: int) -> int: ...
+ | ---------------------------- `Parent.static_method` defined here
+ 7 |
+ 8 | class BadChild1(Parent):
+ |
+info: `BadChild1.static_method` is an instance method but `Parent.static_method` is a staticmethod
+info: This violates the Liskov Substitution Principle
+info: rule `invalid-method-override` is enabled by default
+
+```
+
+```
+error[invalid-method-override]: Invalid override of method `class_method`
+ --> src/mdtest_snippet.pyi:30:9
+ |
+28 | def instance_method(self, x: int) -> int: ...
+29 | @staticmethod
+30 | def class_method(cls, x: int) -> int: ... # error: [invalid-method-override]
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Parent.class_method`
+31 | @classmethod
+32 | def static_method(x: int) -> int: ... # error: [invalid-method-override]
+ |
+ ::: src/mdtest_snippet.pyi:4:9
+ |
+ 2 | def instance_method(self, x: int) -> int: ...
+ 3 | @classmethod
+ 4 | def class_method(cls, x: int) -> int: ...
+ | -------------------------------- `Parent.class_method` defined here
+ 5 | @staticmethod
+ 6 | def static_method(x: int) -> int: ...
+ |
+info: `BadChild2.class_method` is a staticmethod but `Parent.class_method` is a classmethod
+info: This violates the Liskov Substitution Principle
+info: rule `invalid-method-override` is enabled by default
+
+```
+
+```
+error[invalid-method-override]: Invalid override of method `static_method`
+ --> src/mdtest_snippet.pyi:32:9
+ |
+30 | def class_method(cls, x: int) -> int: ... # error: [invalid-method-override]
+31 | @classmethod
+32 | def static_method(x: int) -> int: ... # error: [invalid-method-override]
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Parent.static_method`
+33 |
+34 | class BadChild3(Parent):
+ |
+ ::: src/mdtest_snippet.pyi:6:9
+ |
+ 4 | def class_method(cls, x: int) -> int: ...
+ 5 | @staticmethod
+ 6 | def static_method(x: int) -> int: ...
+ | ---------------------------- `Parent.static_method` defined here
+ 7 |
+ 8 | class BadChild1(Parent):
+ |
+info: `BadChild2.static_method` is a classmethod but `Parent.static_method` is a staticmethod
+info: This violates the Liskov Substitution Principle
+info: rule `invalid-method-override` is enabled by default
+
+```
+
+```
+error[invalid-method-override]: Invalid override of method `class_method`
+ --> src/mdtest_snippet.pyi:36:9
+ |
+34 | class BadChild3(Parent):
+35 | @classmethod
+36 | def class_method(cls, x: bool) -> object: ... # error: [invalid-method-override]
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Parent.class_method`
+37 | @staticmethod
+38 | def static_method(x: bool) -> object: ... # error: [invalid-method-override]
+ |
+ ::: src/mdtest_snippet.pyi:4:9
+ |
+ 2 | def instance_method(self, x: int) -> int: ...
+ 3 | @classmethod
+ 4 | def class_method(cls, x: int) -> int: ...
+ | -------------------------------- `Parent.class_method` defined here
+ 5 | @staticmethod
+ 6 | def static_method(x: int) -> int: ...
+ |
+info: This violates the Liskov Substitution Principle
+info: rule `invalid-method-override` is enabled by default
+
+```
+
+```
+error[invalid-method-override]: Invalid override of method `static_method`
+ --> src/mdtest_snippet.pyi:38:9
+ |
+36 | def class_method(cls, x: bool) -> object: ... # error: [invalid-method-override]
+37 | @staticmethod
+38 | def static_method(x: bool) -> object: ... # error: [invalid-method-override]
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `Parent.static_method`
+39 |
+40 | class GoodChild1(Parent):
+ |
+ ::: src/mdtest_snippet.pyi:6:9
+ |
+ 4 | def class_method(cls, x: int) -> int: ...
+ 5 | @staticmethod
+ 6 | def static_method(x: int) -> int: ...
+ | ---------------------------- `Parent.static_method` defined here
+ 7 |
+ 8 | class BadChild1(Parent):
+ |
+info: This violates the Liskov Substitution Principle
+info: rule `invalid-method-override` is enabled by default
+
+```
diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs
index cbe2f14e0e..1f5dfc71b8 100644
--- a/crates/ty_python_semantic/src/types.rs
+++ b/crates/ty_python_semantic/src/types.rs
@@ -1107,13 +1107,6 @@ impl<'db> Type<'db> {
}
}
- pub(crate) const fn as_bound_method(self) -> Option> {
- match self {
- Type::BoundMethod(bound_method) => Some(bound_method),
- _ => None,
- }
- }
-
#[track_caller]
pub(crate) const fn expect_class_literal(self) -> ClassLiteral<'db> {
self.as_class_literal()
diff --git a/crates/ty_python_semantic/src/types/class.rs b/crates/ty_python_semantic/src/types/class.rs
index 1543dcd88f..151d329d47 100644
--- a/crates/ty_python_semantic/src/types/class.rs
+++ b/crates/ty_python_semantic/src/types/class.rs
@@ -1264,6 +1264,14 @@ impl MethodDecorator {
(false, false) => Ok(Self::None),
}
}
+
+ pub(crate) const fn description(self) -> &'static str {
+ match self {
+ MethodDecorator::None => "an instance method",
+ MethodDecorator::ClassMethod => "a classmethod",
+ MethodDecorator::StaticMethod => "a staticmethod",
+ }
+ }
}
/// Kind-specific metadata for different types of fields
diff --git a/crates/ty_python_semantic/src/types/diagnostic.rs b/crates/ty_python_semantic/src/types/diagnostic.rs
index ceecbce7f4..050a55fb93 100644
--- a/crates/ty_python_semantic/src/types/diagnostic.rs
+++ b/crates/ty_python_semantic/src/types/diagnostic.rs
@@ -8,13 +8,13 @@ use super::{
use crate::diagnostic::did_you_mean;
use crate::diagnostic::format_enumeration;
use crate::lint::{Level, LintRegistryBuilder, LintStatus};
+use crate::place::Place;
use crate::semantic_index::definition::{Definition, DefinitionKind};
use crate::semantic_index::place::{PlaceTable, ScopedPlaceId};
use crate::semantic_index::{global_scope, place_table, use_def_map};
use crate::suppression::FileSuppressionId;
-use crate::types::KnownInstanceType;
use crate::types::call::CallError;
-use crate::types::class::{DisjointBase, DisjointBaseKind, Field};
+use crate::types::class::{DisjointBase, DisjointBaseKind, Field, MethodDecorator};
use crate::types::function::{FunctionType, KnownFunction};
use crate::types::liskov::{MethodKind, SynthesizedMethodKind};
use crate::types::string_annotation::{
@@ -27,6 +27,7 @@ use crate::types::{
ProtocolInstanceType, SpecialFormType, SubclassOfInner, Type, TypeContext, binding_type,
infer_isolated_expression, protocol_class::ProtocolClass,
};
+use crate::types::{KnownInstanceType, MemberLookupPolicy};
use crate::{Db, DisplaySettings, FxIndexMap, Module, ModuleName, Program, declare_lint};
use itertools::Itertools;
use ruff_db::{
@@ -3519,6 +3520,27 @@ pub(super) fn report_invalid_method_override<'db>(
"Definition is incompatible with `{overridden_method}`"
));
+ let class_member = |cls: ClassType<'db>| {
+ cls.class_member(db, member, MemberLookupPolicy::default())
+ .place
+ };
+
+ if let Place::Defined(Type::FunctionLiteral(subclass_function), _, _) = class_member(subclass)
+ && let Place::Defined(Type::FunctionLiteral(superclass_function), _, _) =
+ class_member(superclass)
+ && let Ok(superclass_function_kind) =
+ MethodDecorator::try_from_fn_type(db, superclass_function)
+ && let Ok(subclass_function_kind) = MethodDecorator::try_from_fn_type(db, subclass_function)
+ && superclass_function_kind != subclass_function_kind
+ {
+ diagnostic.info(format_args!(
+ "`{class_name}.{member}` is {subclass_function_kind} \
+ but `{overridden_method}` is {superclass_function_kind}",
+ superclass_function_kind = superclass_function_kind.description(),
+ subclass_function_kind = subclass_function_kind.description(),
+ ));
+ }
+
diagnostic.info("This violates the Liskov Substitution Principle");
if !subclass_definition_kind.is_function_def()
@@ -3545,9 +3567,11 @@ pub(super) fn report_invalid_method_override<'db>(
.full_range(db, &parsed_module(db, superclass_scope.file(db)).load(db)),
);
- let superclass_function_span = superclass_type
- .as_bound_method()
- .and_then(|method| signature_span(method.function(db)));
+ let superclass_function_span = match superclass_type {
+ Type::FunctionLiteral(function) => signature_span(function),
+ Type::BoundMethod(method) => signature_span(method.function(db)),
+ _ => None,
+ };
let superclass_definition_kind = definition.kind(db);
diff --git a/crates/ty_python_semantic/src/types/liskov.rs b/crates/ty_python_semantic/src/types/liskov.rs
index ec0f1765d7..2547e391d3 100644
--- a/crates/ty_python_semantic/src/types/liskov.rs
+++ b/crates/ty_python_semantic/src/types/liskov.rs
@@ -49,11 +49,6 @@ fn check_class_declaration<'db>(
return;
};
- // TODO: classmethods and staticmethods
- if function.is_classmethod(db) || function.is_staticmethod(db) {
- return;
- }
-
// Constructor methods are not checked for Liskov compliance
if matches!(
&*member.name,