@@ -203,23 +203,76 @@ def _build_alias_map(self, tree: ast.AST, caller_file: str) -> Dict[str, str]:
203203 (same-file or via the global single-name index) to a user function. This
204204 lets `fn = helper; fn()` recover the `helper` edge. Class/method targets
205205 are out of scope (only ast.Name = ast.Name single-target assigns).
206+
207+ SINGLE-UNCONDITIONAL-ASSIGNMENT GUARD: an alias is kept ONLY when the
208+ name is bound exactly once AND at the function/module top level (a direct
209+ statement of the body, not nested inside an If/For/While/Try/With or any
210+ other block). A name bound 2+ times (last-write-wins) or bound inside a
211+ conditional/loop/try/with is a "maybe" binding; resolving it would assert
212+ a maybe as definite, so we drop it and let `x()` fall through to normal
213+ resolution (no edge). Precision over recall.
206214 """
207- aliases : Dict [str , str ] = {}
215+ # Count EVERY assignment to each name anywhere in the body (any nesting,
216+ # both Assign targets and AnnAssign), so a reassignment or a conditional
217+ # rebinding disqualifies the name even if one binding is top-level.
218+ assign_counts : Dict [str , int ] = {}
208219 for node in ast .walk (tree ):
209- if not isinstance (node , ast .Assign ):
220+ if isinstance (node , ast .Assign ):
221+ for target in node .targets :
222+ for name in self ._assigned_names (target ):
223+ assign_counts [name ] = assign_counts .get (name , 0 ) + 1
224+ elif isinstance (node , ast .AnnAssign ) and node .value is not None :
225+ for name in self ._assigned_names (node .target ):
226+ assign_counts [name ] = assign_counts .get (name , 0 ) + 1
227+
228+ # Only consider candidate aliases declared at the body's TOP LEVEL.
229+ top_level = self ._function_body_statements (tree )
230+ aliases : Dict [str , str ] = {}
231+ for stmt in top_level :
232+ if not isinstance (stmt , ast .Assign ):
210233 continue
211- if not isinstance (node .value , ast .Name ):
234+ if not isinstance (stmt .value , ast .Name ):
212235 continue
213- for target in node .targets :
214- if not isinstance (target , ast .Name ):
215- continue
216- if target .id == node .value .id :
217- continue
218- resolved = self ._resolve_simple_call (node .value .id , caller_file )
219- if resolved :
220- aliases [target .id ] = resolved
236+ if len (stmt .targets ) != 1 :
237+ continue
238+ target = stmt .targets [0 ]
239+ if not isinstance (target , ast .Name ):
240+ continue
241+ if target .id == stmt .value .id :
242+ continue
243+ # GUARD: bound exactly once across the whole body (single +
244+ # unconditional, because a conditional binding would push the
245+ # top-level count higher OR not appear at top level at all).
246+ if assign_counts .get (target .id , 0 ) != 1 :
247+ continue
248+ resolved = self ._resolve_simple_call (stmt .value .id , caller_file )
249+ if resolved :
250+ aliases [target .id ] = resolved
221251 return aliases
222252
253+ @staticmethod
254+ def _assigned_names (target : ast .AST ):
255+ """Yield the simple Name ids bound by an assignment target (recursively
256+ through tuple/list unpacking)."""
257+ if isinstance (target , ast .Name ):
258+ yield target .id
259+ elif isinstance (target , (ast .Tuple , ast .List )):
260+ for elt in target .elts :
261+ yield from CallGraphBuilder ._assigned_names (elt )
262+
263+ @staticmethod
264+ def _function_body_statements (tree : ast .AST ) -> List [ast .stmt ]:
265+ """Return the top-level statements of the analysed function body.
266+
267+ The extractor's `code` includes the `def` line, so a single function
268+ parses to Module(body=[FunctionDef]); the real body is that def's body.
269+ Fall back to the module body for bare module-level code.
270+ """
271+ body = getattr (tree , 'body' , [])
272+ if len (body ) == 1 and isinstance (body [0 ], (ast .FunctionDef , ast .AsyncFunctionDef )):
273+ return list (body [0 ].body )
274+ return list (body )
275+
223276 def _resolve_local_function (self , func_name : str , caller_file : str ) -> Optional [str ]:
224277 """Resolve a bare name to a same-file, non-method user function.
225278
0 commit comments