From 6154fed80fc1126f2da5a3b000296d82e4aa062e Mon Sep 17 00:00:00 2001 From: Aayush Yash Date: Fri, 23 Oct 2026 11:38:02 +0530 Subject: [PATCH 1/3] test: add regression test for constrained typevar in protocol --- pyrefly/lib/test/variance_inference.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pyrefly/lib/test/variance_inference.rs b/pyrefly/lib/test/variance_inference.rs index 6a27fb8d2f..4ac4c22f3e 100644 --- a/pyrefly/lib/test/variance_inference.rs +++ b/pyrefly/lib/test/variance_inference.rs @@ -605,6 +605,21 @@ class Foo(Generic[T_co]): "#, ); +testcase!( + test_constrained_typevar_protocol_variance_mismatch, + r#" +from typing import Protocol, TypeVar + +T = TypeVar("T", str, bytes) + +class Foo(Protocol[T]): + def value(self) -> T: ... + +class Bar(Protocol[T]): + def consume(self, x: T) -> None: ... +"#, +); + testcase!( test_shallow_contravariant_in_return, r#" From 3fe3b0447a10576748338829ae7f80c6ccf8151a Mon Sep 17 00:00:00 2001 From: Aayush Yash Date: Fri, 23 Oct 2026 11:38:21 +0530 Subject: [PATCH 2/3] fix: do not emit variance-mismatch for constrained typevars in protocols --- pyrefly/lib/alt/solve.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/pyrefly/lib/alt/solve.rs b/pyrefly/lib/alt/solve.rs index bf15e3566a..43fe6d6d4f 100644 --- a/pyrefly/lib/alt/solve.rs +++ b/pyrefly/lib/alt/solve.rs @@ -3948,16 +3948,21 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { // with a narrower variance. We only check invariant TypeVars here // because wrong variance on covariant/contravariant TypeVars is // already caught by InvalidVariance at the usage site. + // Constrained TypeVars are excluded because variance cannot be declared on them. let metadata = self.get_metadata_for_class(class); if metadata.is_protocol() - && tparams - .as_vec() - .iter() - .any(|p| p.is_type_var() && p.variance() == PreInferenceVariance::Invariant) + && tparams.as_vec().iter().any(|p| { + p.is_type_var() + && p.variance() == PreInferenceVariance::Invariant + && !matches!(p.restriction(), Restriction::Constraints(_)) + }) { let inferred = self.infer_variance_ignoring_declared(class); for tparam in tparams.as_vec() { - if !tparam.is_type_var() || tparam.variance() != PreInferenceVariance::Invariant { + if !tparam.is_type_var() + || tparam.variance() != PreInferenceVariance::Invariant + || matches!(tparam.restriction(), Restriction::Constraints(_)) + { continue; } let inferred_v = inferred.get(tparam.name()); From e579257505d49d3173f16ec733c9e3b38916b641 Mon Sep 17 00:00:00 2001 From: Aayush Yash Date: Tue, 3 Nov 2026 10:47:06 +0530 Subject: [PATCH 3/3] fix: update conformance outputs and refine constrained typevar comment --- conformance/third_party/conformance.exp | 11 ----------- conformance/third_party/conformance.result | 1 + conformance/third_party/results.json | 4 ++-- pyrefly/lib/alt/solve.rs | 3 ++- 4 files changed, 5 insertions(+), 14 deletions(-) diff --git a/conformance/third_party/conformance.exp b/conformance/third_party/conformance.exp index 837cb9cb1d..152be6c778 100644 --- a/conformance/third_party/conformance.exp +++ b/conformance/third_party/conformance.exp @@ -11025,17 +11025,6 @@ "stop_column": 17, "stop_line": 22 }, - { - "code": -2, - "column": 7, - "concise_description": "Type variable `T3` in class `Protocol2` is declared as invariant, but could be contravariant based on its usage", - "description": "Type variable `T3` in class `Protocol2` is declared as invariant, but could be contravariant based on its usage", - "line": 41, - "name": "variance-mismatch", - "severity": "warn", - "stop_column": 16, - "stop_line": 41 - }, { "code": -2, "column": 7, diff --git a/conformance/third_party/conformance.result b/conformance/third_party/conformance.result index ff37e9423a..4e97a0b626 100644 --- a/conformance/third_party/conformance.result +++ b/conformance/third_party/conformance.result @@ -144,6 +144,7 @@ "protocols_self.py": [], "protocols_subtyping.py": [], "protocols_variance.py": [ + "Line 41: Expected 1 errors", "Line 15: Unexpected errors ['Unexpected keyword argument `contravariant` to ParamSpec']" ], "qualifiers_annotated.py": [], diff --git a/conformance/third_party/results.json b/conformance/third_party/results.json index 2533501bc8..d616143d15 100644 --- a/conformance/third_party/results.json +++ b/conformance/third_party/results.json @@ -3,7 +3,7 @@ "pass": 140, "fail": 5, "pass_rate": 0.97, - "differences": 23, + "differences": 24, "passing": [ "aliases_explicit.py", "aliases_implicit.py", @@ -151,7 +151,7 @@ "annotations_future_import.py": 2, "generics_paramspec_variance.py": 9, "generics_typevartuple_variance.py": 9, - "protocols_variance.py": 1 + "protocols_variance.py": 2 }, "comment": "@generated" } \ No newline at end of file diff --git a/pyrefly/lib/alt/solve.rs b/pyrefly/lib/alt/solve.rs index f9e34f3801..9e7ac531ec 100644 --- a/pyrefly/lib/alt/solve.rs +++ b/pyrefly/lib/alt/solve.rs @@ -3977,7 +3977,8 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { // with a narrower variance. We only check invariant TypeVars here // because wrong variance on covariant/contravariant TypeVars is // already caught by InvalidVariance at the usage site. - // Constrained TypeVars are excluded because variance cannot be declared on them. + // Constrained TypeVars are excluded because invariance is a sound default for a fixed + // set of constraints, and the narrower-variance suggestion is usually noise. let metadata = self.get_metadata_for_class(class); if metadata.is_protocol() && tparams.as_vec().iter().any(|p| {