@@ -197,6 +197,11 @@ def __init__(self, policy: Policy, scan: FileScan, ranges):
197197 # _mark_annotation_subtrees; exempt from the name/container checks in _check_name and
198198 # _check_container_literal because annotations are never invoked (see visit).
199199 self ._annotation_nodes : set [int ] = set ()
200+ # One {local_name: self_attr_name} dict per enclosing class/function/lambda scope (mirrors
201+ # _scope_stack's push/pop), tracking which local variables currently alias a self.<attr> --
202+ # so `tmp = self.fn; tmp(x)` is checked the same way `self.fn(x)` directly is; see
203+ # _track_self_attr_alias and its use in _check_call.
204+ self ._alias_stack : list [dict [str , str ]] = []
200205
201206 def report (self , node : ast .AST , code : ViolationCode , message : str ) -> None :
202207 self .violations .append (
@@ -212,13 +217,15 @@ def visit(self, node: ast.AST) -> None:
212217 is_scope = isinstance (node , (ast .ClassDef , ast .FunctionDef , ast .Lambda ))
213218 if is_scope :
214219 self ._scope_stack .append (node )
220+ self ._alias_stack .append ({})
215221 self ._mark_annotation_subtrees (node )
216222
217223 for child in ast .iter_child_nodes (node ):
218224 self .visit (child )
219225
220226 if is_scope :
221227 self ._scope_stack .pop ()
228+ self ._alias_stack .pop ()
222229
223230 def _enclosing_class (self ) -> ast .ClassDef | None :
224231 """The nearest enclosing ClassDef, skipping any FunctionDef/Lambda frames above it."""
@@ -408,6 +415,18 @@ def _check_call(self, node: ast.Call) -> None:
408415 f"call to { self ._resolve (func .id )!r} is not allow-listed" ,
409416 )
410417 return
418+ aliased_attr = self ._current_aliases ().get (func .id )
419+ if aliased_attr is not None and not self ._self_attr .is_safe (
420+ aliased_attr , self ._enclosing_class ()
421+ ):
422+ self .report (
423+ node ,
424+ "attr-on-value" ,
425+ f"{ func .id !r} was assigned from self.{ aliased_attr !r} , which isn't an "
426+ f"allow-listed constructor or a locally-defined class; calling it is not "
427+ f"allowed" ,
428+ )
429+ return
411430 # Otherwise a bare-name call (local var / hidden or visible def / safe builtin) is allowed.
412431 return
413432 if isinstance (func , ast .Attribute ):
@@ -608,6 +627,49 @@ def _check_assign_targets(self, node) -> None:
608627 "banned-construct" ,
609628 "storing a reference to a banned builtin into a container slot is not allowed" ,
610629 )
630+ self ._track_self_attr_alias (node )
631+
632+ def _track_self_attr_alias (self , node ) -> None :
633+ # Detect `tmp = self.<attr>` / `tmp = self.<attr>[i]` / `tmp = <already-tracked alias>` so a
634+ # later `tmp(...)` call in _check_call is checked the same way calling self.<attr> directly
635+ # would be -- otherwise reading an unsafe self.<attr> into a local variable trivially
636+ # defeats _SelfAttrTrust (self.<attr> reads are always allowed; calling a local variable is
637+ # always allowed). Any other assignment to a previously-tracked name clears its alias -- it
638+ # no longer refers to that self.<attr>. AugAssign always clears (no value to trace here).
639+ if not self ._alias_stack :
640+ return
641+ aliases = self ._alias_stack [- 1 ]
642+ if isinstance (node , ast .Assign ):
643+ targets , value = node .targets , node .value
644+ elif isinstance (node , ast .AnnAssign ):
645+ targets , value = ([node .target ] if node .value is not None else []), node .value
646+ else : # AugAssign
647+ targets , value = [node .target ], None
648+ for t in targets :
649+ if not isinstance (t , ast .Name ):
650+ continue
651+ attr = self ._self_attr_source (value ) if value is not None else None
652+ if attr is not None :
653+ aliases [t .id ] = attr
654+ else :
655+ aliases .pop (t .id , None )
656+
657+ def _self_attr_source (self , value : ast .AST ) -> str | None :
658+ """The self.<attr> a plain expression traces back to: a direct self.<attr> read, a
659+ single-level self.<attr>[i] subscript, or a copy of an already-tracked local alias."""
660+ attr = self_attr_name (value )
661+ if attr is not None :
662+ return attr
663+ if isinstance (value , ast .Subscript ) and isinstance (value .value , ast .Attribute ):
664+ attr = self_attr_name (value .value )
665+ if attr is not None :
666+ return attr
667+ if isinstance (value , ast .Name ):
668+ return self ._current_aliases ().get (value .id )
669+ return None
670+
671+ def _current_aliases (self ) -> dict [str , str ]:
672+ return self ._alias_stack [- 1 ] if self ._alias_stack else {}
611673
612674 def _check_reserved_target (self , target : ast .AST ) -> None :
613675 # Guards against: rebinding self/cls (which would forge the identifier-based trust — see
0 commit comments