@@ -1093,8 +1093,15 @@ def _check_model_method(
10931093 """
10941094 Check a model method (with self tracking and @requires_relations parsing).
10951095
1096- self is marked as caller_provided; pre-commit access skips RLC003.
1097- But self.save() followed by relationship access still triggers RLC002.
1096+ All method parameters (``self`` plus other model-typed arguments) are marked
1097+ as caller_provided — these objects are passed in by the caller, preloading
1098+ is the caller's contract (typically declared via a docstring "preload before
1099+ calling: ..." note), so the method body must not be flagged with RLC003 for
1100+ "missing load=". This matches ``_check_coroutine``'s policy of marking all
1101+ params as caller_provided.
1102+
1103+ Post-commit relation access still triggers RLC002 / RLC007 / RLC008
1104+ (caller_provided does not exempt those checks).
10981105 """
10991106 # Parse AST to extract @requires_relations
11001107 source_file , tree , line_offset = self ._parse_function_source (func )
@@ -1114,17 +1121,19 @@ def _check_model_method(
11141121 # Detect instance method or classmethod
11151122 sig = python_inspect .signature (func )
11161123 first_param = next (iter (sig .parameters ), None )
1117- caller_provided_params : set [str ] = set ()
11181124
11191125 # cls -> class alias (classmethod's cls parameter doesn't enter tracked_vars,
11201126 # only used for resolving class-level calls)
11211127 class_aliases : dict [str , str ] = {}
11221128 if first_param == 'self' :
11231129 param_models ['self' ] = cls_name
1124- caller_provided_params .add ('self' )
11251130 elif first_param == 'cls' :
11261131 class_aliases ['cls' ] = cls_name
11271132
1133+ # All method params are caller-provided (self + other model-typed args
1134+ # are the caller's preload responsibility).
1135+ caller_provided_params : set [str ] = set (param_models .keys ())
1136+
11281137 # @requires_relations declared rels as self's dep_loads
11291138 dep_loads : dict [str , set [str ]] = {}
11301139 if 'self' in param_models and decorator_loads :
@@ -1287,20 +1296,32 @@ def _resolve_param_models(self, func: Any) -> dict[str, str]:
12871296 (e.g. TYPE_CHECKING forward references), falls back to per-parameter
12881297 ``__annotations__`` parsing to ensure resolvable params aren't missed.
12891298
1299+ TYPE_CHECKING forward reference handling: passes ``self.model_classes``
1300+ (all mapped ORM classes) as ``localns`` to ``get_type_hints()`` so that
1301+ string annotations like ``llm: 'LLM'`` (where ``LLM`` is imported only
1302+ under ``if TYPE_CHECKING:``) resolve to actual classes instead of being
1303+ silently dropped — silent drops cause RLC007/RLC013 false negatives.
1304+
12901305 :returns: param_name -> model_class_name
12911306 """
12921307 param_models : dict [str , str ] = {}
12931308
1309+ # Provide model_classes as localns so TYPE_CHECKING forward references resolve.
1310+ localns : dict [str , Any ] = dict (self .model_classes )
12941311 try :
1295- hints = typing .get_type_hints (func , include_extras = True )
1312+ hints = typing .get_type_hints (func , include_extras = True , localns = localns )
12961313 except Exception :
1297- # get_type_hints may fail due to TYPE_CHECKING forward references.
1298- # Fall back to per-parameter __annotations__ (skip unresolvable string annotations)
1314+ # Even with model_classes in scope, get_type_hints may still fail on
1315+ # nested third-party ForwardRefs. Fall back to per-parameter __annotations__:
1316+ # try to eval string annotations against globals + model_classes.
12991317 hints = {}
13001318 annotations : dict [str , Any ] = getattr (func , '__annotations__' , {})
13011319 for param_name , annotation in annotations .items ():
13021320 if isinstance (annotation , str ):
1303- continue # Skip unresolvable string annotations
1321+ resolved = self ._eval_string_annotation (annotation , func )
1322+ if resolved is not None :
1323+ hints [param_name ] = resolved
1324+ continue
13041325 hints [param_name ] = annotation
13051326
13061327 for param_name , hint in hints .items ():
@@ -1333,6 +1354,24 @@ def _resolve_param_models(self, func: Any) -> dict[str, str]:
13331354
13341355 return param_models
13351356
1357+ def _eval_string_annotation (self , annotation : str , func : Any ) -> Any :
1358+ """
1359+ Evaluate a string annotation that ``typing.get_type_hints`` couldn't resolve.
1360+
1361+ Typical case: a TYPE_CHECKING-only import is referenced as a forward reference
1362+ (e.g. ``llm: 'LLM'`` where ``LLM`` is imported only under ``if TYPE_CHECKING:``).
1363+ Evaluates against ``func.__globals__`` merged with the known ORM model classes.
1364+
1365+ Supported forms: ``'LLM'``, ``'LLM | None'``, ``'list[LLM]'`` etc.
1366+ Returns ``None`` on failure (silent skip, matches the original fallback).
1367+ """
1368+ globalns : dict [str , Any ] = getattr (func , '__globals__' , {}) or {}
1369+ localns : dict [str , Any ] = dict (self .model_classes )
1370+ try :
1371+ return eval (annotation , globalns , localns ) # noqa: S307 — source-code literal, scoped namespace
1372+ except Exception :
1373+ return None
1374+
13361375 def _extract_model_from_hint (self , hint : Any ) -> str | None :
13371376 """
13381377 Extract a model class name from a type annotation.
0 commit comments