Skip to content

Commit 38b0d45

Browse files
committed
Add instance-var-assign diagnostic for class-object writes (#3750)
Assigning to an instance variable via the class (`C.instance_var = 3`, where the class body declares `instance_var: int` without `ClassVar`) silently succeeded, even though such fields are meant to be assigned on instances — the mirror image of the existing rule that rejects instance writes to a ClassVar. Add a top-level, addressable ErrorKind::InstanceVarAssign, off by default (Severity::Ignore) and enabled in strict mode (Preset::Strict), so default checking behavior is unchanged (matching ty) while the diagnostic remains independently suppressible via `# pyrefly: ignore[instance-var-assign]`. Detection keys off `annotation.is_some() && !is_classvar`, threaded as a provenance flag on `ClassAttribute::ReadWrite(Type, bool)` from the two ClassAttribute conversion sites to the single attribute-write chokepoint, which alone knows whether the write goes through the class object rather than an instance. The error is emitted before the type-compat check, so an ill-typed value reports both this kind and bad-assignment.
1 parent 4e4f1c1 commit 38b0d45

9 files changed

Lines changed: 210 additions & 21 deletions

File tree

‎crates/pyrefly_config/src/base.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ impl Preset {
179179
let errors = HashMap::from([
180180
(ErrorKind::DirectAbstractBaseInstantiation, Severity::Error),
181181
(ErrorKind::ImplicitAny, Severity::Error),
182+
(ErrorKind::InstanceVarAssign, Severity::Error),
182183
(ErrorKind::MissingOverrideDecorator, Severity::Error),
183184
(ErrorKind::OpenUnpacking, Severity::Error),
184185
(ErrorKind::PotentialBadKeywordArgument, Severity::Error),

‎crates/pyrefly_config/src/error_kind.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,8 @@ pub enum ErrorKind {
228228
/// An inconsistency between a function parameter's type in an overload signature and its
229229
/// default value in the implementation.
230230
InconsistentOverloadDefault,
231+
/// Cannot assign to a class-body-declared instance variable via the class (e.g. `C.instance_var = ...`).
232+
InstanceVarAssign,
231233
/// Internal Pyrefly error.
232234
InternalError,
233235
/// An `@abstractmethod` is defined in a class that is not abstract.
@@ -563,6 +565,7 @@ impl ErrorKind {
563565
ErrorKind::ImplicitReexport => Severity::Ignore,
564566
ErrorKind::ImplicitlyDefinedAttribute => Severity::Ignore,
565567
ErrorKind::IncompatibleComparison => Severity::Ignore,
568+
ErrorKind::InstanceVarAssign => Severity::Ignore,
566569
ErrorKind::InvalidAbstractMethod => Severity::Ignore,
567570
ErrorKind::InvalidCast => Severity::Ignore,
568571
ErrorKind::InvalidDecorator => Severity::Warn,

‎pyrefly/lib/alt/attr.rs‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1350,7 +1350,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
13501350
return None;
13511351
};
13521352
let ty = match class_attr {
1353-
ClassAttribute::ReadWrite(ty) | ClassAttribute::ReadOnly(ty, _) => ty,
1353+
ClassAttribute::ReadWrite(ty, _) | ClassAttribute::ReadOnly(ty, _) => ty,
13541354
_ => return None,
13551355
};
13561356
let Type::BoundMethod(bound_method) = ty else {
@@ -1373,7 +1373,9 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
13731373
new_bound_method.func = BoundMethodType::Overload(filtered_overload);
13741374
let new_method = self.heap.mk_bound_method(new_bound_method);
13751375
Some(Attribute::ClassAttribute(match class_attr {
1376-
ClassAttribute::ReadWrite(_) => ClassAttribute::ReadWrite(new_method),
1376+
ClassAttribute::ReadWrite(_, is_instance_var) => {
1377+
ClassAttribute::ReadWrite(new_method, *is_instance_var)
1378+
}
13771379
ClassAttribute::ReadOnly(_, reason) => {
13781380
ClassAttribute::ReadOnly(new_method, reason.clone())
13791381
}
@@ -3122,7 +3124,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
31223124
.into_iter()
31233125
.filter_map(|(attr, _)| {
31243126
match &attr {
3125-
Attribute::ClassAttribute(ClassAttribute::ReadWrite(ty))
3127+
Attribute::ClassAttribute(ClassAttribute::ReadWrite(ty, _))
31263128
| Attribute::ClassAttribute(ClassAttribute::ReadOnly(ty, _))
31273129
| Attribute::Simple(ty)
31283130
| Attribute::ClassAttribute(ClassAttribute::Property(ty, _, _))

‎pyrefly/lib/alt/class/class_field.rs‎

Lines changed: 67 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,9 @@ use crate::types::types::Union;
118118
#[derive(Debug, Clone)]
119119
pub enum ClassAttribute {
120120
/// A read-write attribute with a closed form type for both get and set actions.
121-
ReadWrite(Type),
121+
/// The flag is `true` when the attribute is a class-body-declared instance
122+
/// variable (annotated, not `ClassVar`), used to flag writes via the class.
123+
ReadWrite(Type, bool),
122124
/// A read-only attribute with a closed form type for get actions.
123125
ReadOnly(Type, ReadOnlyReason),
124126
/// A `NoAccess` attribute indicates that the attribute is well-defined, but does
@@ -143,7 +145,13 @@ pub enum ClassAttribute {
143145

144146
impl ClassAttribute {
145147
pub fn read_write(ty: Type) -> Self {
146-
Self::ReadWrite(ty)
148+
Self::ReadWrite(ty, false)
149+
}
150+
151+
/// Like [`read_write`](Self::read_write) but marks the attribute as a
152+
/// class-body-declared instance variable (annotated, not `ClassVar`).
153+
pub fn read_write_instance_var(ty: Type) -> Self {
154+
Self::ReadWrite(ty, true)
147155
}
148156

149157
pub fn read_only(ty: Type, reason: ReadOnlyReason) -> Self {
@@ -183,7 +191,7 @@ impl ClassAttribute {
183191

184192
pub fn read_only_equivalent(self, reason: ReadOnlyReason) -> Self {
185193
match self {
186-
Self::ReadWrite(ty) => Self::ReadOnly(ty, reason),
194+
Self::ReadWrite(ty, _) => Self::ReadOnly(ty, reason),
187195
Self::Property(getter, _, cls) => Self::Property(getter, None, cls),
188196
Self::Descriptor(descriptor, base) => Self::Descriptor(
189197
Descriptor {
@@ -215,7 +223,7 @@ impl ClassAttribute {
215223
match self {
216224
// TODO(stroxler): ReadWrite attributes are not actually methods but limiting access to
217225
// ReadOnly breaks unit tests; we should investigate callsites to understand this better.
218-
ClassAttribute::ReadWrite(ty) | ClassAttribute::ReadOnly(ty, _) => Some(ty),
226+
ClassAttribute::ReadWrite(ty, _) | ClassAttribute::ReadOnly(ty, _) => Some(ty),
219227
ClassAttribute::NoAccess(..)
220228
| ClassAttribute::Property(..)
221229
| ClassAttribute::Descriptor(..)
@@ -1289,10 +1297,13 @@ fn bind_class_attribute(
12891297
cls: &ClassBase,
12901298
attr: Type,
12911299
read_only_reason: Option<ReadOnlyReason>,
1300+
is_instance_var: bool,
12921301
) -> ClassAttribute {
12931302
let ty = make_bound_classmethod(heap, cls, attr).into_inner();
12941303
if let Some(reason) = read_only_reason {
12951304
ClassAttribute::read_only(ty, reason)
1305+
} else if is_instance_var {
1306+
ClassAttribute::read_write_instance_var(ty)
12961307
} else {
12971308
ClassAttribute::read_write(ty)
12981309
}
@@ -3569,12 +3580,16 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
35693580
}
35703581
ClassFieldInner::ClassAttribute {
35713582
mut ty,
3583+
annotation,
35723584
is_classvar,
35733585
read_only_reason,
35743586
descriptor_range,
35753587
..
35763588
} => {
35773589
ty = self.normalize_attr_ty(ty);
3590+
// A class-body-declared instance variable (annotated, not `ClassVar`);
3591+
// writing it via the class object is flagged at the set chokepoint.
3592+
let is_instance_var = annotation.is_some() && !is_classvar;
35783593
let read_only_reason = if is_classvar {
35793594
Some(ReadOnlyReason::ClassVar)
35803595
} else {
@@ -3599,6 +3614,8 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
35993614
(_, ty) => {
36003615
if let Some(reason) = read_only_reason {
36013616
ClassAttribute::read_only(ty, reason)
3617+
} else if is_instance_var {
3618+
ClassAttribute::read_write_instance_var(ty)
36023619
} else {
36033620
ClassAttribute::read_write(ty)
36043621
}
@@ -3654,7 +3671,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
36543671
ClassFieldInner::Property { mut ty, .. } => {
36553672
// When accessing a property on a class (not instance), you get the property object itself
36563673
ty = self.normalize_attr_ty(ty);
3657-
bind_class_attribute(self.heap, cls, ty, None)
3674+
bind_class_attribute(self.heap, cls, ty, None, false)
36583675
}
36593676
ClassFieldInner::Descriptor { descriptor, .. } => {
36603677
ClassAttribute::descriptor(descriptor, DescriptorBase::ClassDef(cls.clone()))
@@ -3676,7 +3693,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
36763693
if let Some(quantified) = self_quantified {
36773694
ty = self.wrap_with_quantified(ty, quantified);
36783695
}
3679-
bind_class_attribute(self.heap, cls, ty, None)
3696+
bind_class_attribute(self.heap, cls, ty, None, false)
36803697
}
36813698
ClassFieldInner::ProxyMethod { .. } => ClassAttribute::no_access(
36823699
NoAccessReason::ProxyMethodClassAccess(cls.class_object().dupe()),
@@ -3689,6 +3706,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
36893706
cls,
36903707
ty,
36913708
Some(ReadOnlyReason::ClassObjectInitializedOnBody),
3709+
false,
36923710
)
36933711
}
36943712
ClassFieldInner::ClassAttribute {
@@ -3699,11 +3717,16 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
36993717
)),
37003718
ClassFieldInner::ClassAttribute {
37013719
mut ty,
3720+
annotation,
3721+
is_classvar,
37023722
read_only_reason,
37033723
descriptor_range,
37043724
..
37053725
} => {
37063726
ty = self.normalize_attr_ty(ty);
3727+
// A class-body-declared instance variable (annotated, not `ClassVar`);
3728+
// writing it via the class object is flagged at the set chokepoint.
3729+
let is_instance_var = annotation.is_some() && !is_classvar;
37073730
if ambiguous && !matches!(cls, ClassBase::SelfType(_)) {
37083731
ClassAttribute::no_access(NoAccessReason::ClassAttributeIsGeneric(
37093732
cls.class_object().dupe(),
@@ -3718,7 +3741,13 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
37183741
base: DescriptorBase::ClassDef(cls.clone()),
37193742
read_only_reason,
37203743
},
3721-
(_, ty) => bind_class_attribute(self.heap, cls, ty, read_only_reason),
3744+
(_, ty) => bind_class_attribute(
3745+
self.heap,
3746+
cls,
3747+
ty,
3748+
read_only_reason,
3749+
is_instance_var,
3750+
),
37223751
}
37233752
}
37243753
}
@@ -4242,7 +4271,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
42424271
});
42434272
};
42444273
match &mut attr {
4245-
ClassAttribute::ReadOnly(ty, _) | ClassAttribute::ReadWrite(ty)
4274+
ClassAttribute::ReadOnly(ty, _) | ClassAttribute::ReadWrite(ty, _)
42464275
if ty.has_toplevel_func_metadata() =>
42474276
{
42484277
relax_ty(ty);
@@ -5338,7 +5367,26 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
53385367
*should_narrow = false;
53395368
}
53405369
}
5341-
ClassAttribute::ReadWrite(attr_ty) => {
5370+
ClassAttribute::ReadWrite(attr_ty, is_instance_var) => {
5371+
// Writing a class-body-declared instance variable via the class object
5372+
// misuses it (it should be assigned on instances). Emitted before the
5373+
// type-compat check below so this kind and bad-assignment fire on
5374+
// separate paths, and before the narrowing decision so the check is
5375+
// purely additive.
5376+
if is_instance_var && instance_class.is_none() && class_base.is_some() {
5377+
errors
5378+
.error_builder(
5379+
range,
5380+
ErrorKind::InstanceVarAssign,
5381+
format!("Cannot set instance variable `{attr_name}` from the class"),
5382+
)
5383+
.with_detail(
5384+
"instance variables declared in a class body (without ClassVar) should be assigned on instances"
5385+
.to_owned(),
5386+
)
5387+
.emit();
5388+
*should_narrow = false;
5389+
}
53425390
// If the attribute has a converter, then `want` should be the type expected by the converter.
53435391
let attr_ty = match instance_class {
53445392
Some(cls) => match self.get_dataclass_member(cls.class_object(), attr_name) {
@@ -5553,7 +5601,9 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
55535601
}
55545602
};
55555603
match attr {
5556-
ClassAttribute::ReadWrite(ty) => Some(ClassAttribute::ReadWrite(filter_type(ty)?)),
5604+
ClassAttribute::ReadWrite(ty, is_instance_var) => {
5605+
Some(ClassAttribute::ReadWrite(filter_type(ty)?, is_instance_var))
5606+
}
55575607
ClassAttribute::ReadOnly(ty, reason) => {
55585608
Some(ClassAttribute::ReadOnly(filter_type(ty)?, reason))
55595609
}
@@ -5607,13 +5657,13 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
56075657
) => Err(Box::new(AttrSubsetError::Property)),
56085658
(
56095659
ClassAttribute::ReadOnly(..),
5610-
ClassAttribute::Property(_, Some(_), _) | ClassAttribute::ReadWrite(_),
5660+
ClassAttribute::Property(_, Some(_), _) | ClassAttribute::ReadWrite(..),
56115661
) => Err(Box::new(AttrSubsetError::ReadOnly)),
56125662
(
56135663
// TODO(stroxler): Investigate this case more: methods should be ReadOnly, but
56145664
// in some cases for unknown reasons they wind up being ReadWrite.
5615-
ClassAttribute::ReadWrite(got),
5616-
ClassAttribute::ReadWrite(want),
5665+
ClassAttribute::ReadWrite(got, _),
5666+
ClassAttribute::ReadWrite(want, _),
56175667
) if got.has_toplevel_func_metadata() && want.has_toplevel_func_metadata() => {
56185668
is_subset(got, want).map_err(|subset_error| {
56195669
Box::new(AttrSubsetError::Covariant {
@@ -5625,7 +5675,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
56255675
})
56265676
})
56275677
}
5628-
(ClassAttribute::ReadWrite(got), ClassAttribute::ReadWrite(want)) => {
5678+
(ClassAttribute::ReadWrite(got, _), ClassAttribute::ReadWrite(want, _)) => {
56295679
let subset_error = is_subset(got, want)
56305680
.map_or_else(Some, |_| is_subset(want, got).map_or_else(Some, |_| None));
56315681
if let Some(subset_error) = subset_error {
@@ -5639,7 +5689,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
56395689
}
56405690
}
56415691
(
5642-
ClassAttribute::ReadWrite(got) | ClassAttribute::ReadOnly(got, ..),
5692+
ClassAttribute::ReadWrite(got, _) | ClassAttribute::ReadOnly(got, ..),
56435693
ClassAttribute::ReadOnly(want, _),
56445694
) => is_subset(got, want).map_err(|subset_error| {
56455695
Box::new(AttrSubsetError::Covariant {
@@ -5666,7 +5716,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
56665716
})
56675717
})
56685718
}
5669-
(ClassAttribute::ReadWrite(got), ClassAttribute::Property(want, want_setter, _)) => {
5719+
(ClassAttribute::ReadWrite(got, _), ClassAttribute::Property(want, want_setter, _)) => {
56705720
is_subset(
56715721
// Synthesize a getter method
56725722
&self.heap.mk_callable_ellipsis(got.clone()),
@@ -5767,7 +5817,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> {
57675817
) -> Result<Type, NoAccessReason> {
57685818
match class_attr {
57695819
ClassAttribute::NoAccess(reason) => Err(reason),
5770-
ClassAttribute::ReadWrite(ty) | ClassAttribute::ReadOnly(ty, _) => Ok(ty),
5820+
ClassAttribute::ReadWrite(ty, _) | ClassAttribute::ReadOnly(ty, _) => Ok(ty),
57715821
ClassAttribute::Property(getter, ..) => {
57725822
self.record_property_getter(range, &getter);
57735823
Ok(self.call_property_getter(getter, range, errors, context))

0 commit comments

Comments
 (0)