Skip to content

Commit f9bcacc

Browse files
committed
fix(python): resolve self./cls. calls into local Calls edges
Two independent bugs meant a call from inside one method to another via self./cls. — the majority of calls inside any Python class — never became a Calls edge in the graph: - ir_to_graph registered functions in node_map only under their bare name, but extract_class extracts calls with a qualified caller name ("ClassName.method_name"). Looking up the caller always missed, so the call fell through to cross-file unresolved-call resolution instead of a same-file edge. - extract_callee_name returned the full attribute text ("self.helper") for a `self.helper()` call instead of the bare method name, which couldn't match anything in node_map either. Register methods under both the bare and qualified name, and strip self./cls. from the callee name before matching. Also fixes Contains (class -> method) edges incorrectly linking same-named methods across two different classes in one file, which used the same bare-name lookup. get_callers/get_callees/get_call_graph/analyze_impact on methods were systematically undercounting call relationships unless the callee happened to also be found by the cross-file fallback.
1 parent 55096f0 commit f9bcacc

2 files changed

Lines changed: 86 additions & 5 deletions

File tree

crates/codegraph-python/src/extractor.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -604,12 +604,32 @@ fn extract_calls_recursive(
604604
}
605605
}
606606

607-
/// Extract the callee name from a call's function node
607+
/// Extract the callee name from a call's function node.
608+
///
609+
/// For `self.method()`/`cls.method()` — by far the most common call shape
610+
/// inside any method — returns the bare `method` name, not `self.method`.
611+
/// `self`/`cls` are Python's instance/class-reference convention, not a
612+
/// real qualifier; graph nodes are keyed by bare name (or `Class.method`
613+
/// for the *caller* side, see `parser_impl::ir_to_graph`), so a literal
614+
/// `"self.method"` string never matched anything and every such call was
615+
/// silently dropped instead of becoming a same-file Calls edge. Other
616+
/// `obj.method()` shapes keep the full dotted text — resolving those
617+
/// would need type inference this extractor doesn't do, so falling back
618+
/// to unresolved (as before) is still correct there.
608619
fn extract_callee_name(source: &[u8], node: Node) -> String {
609620
match node.kind() {
610621
"identifier" => node.utf8_text(source).unwrap_or("").to_string(),
611622
"attribute" => {
612-
// Handle obj.method() or self.method()
623+
let object_is_self_or_cls = node
624+
.child_by_field_name("object")
625+
.and_then(|o| o.utf8_text(source).ok())
626+
.is_some_and(|t| t == "self" || t == "cls");
627+
if object_is_self_or_cls {
628+
if let Some(attr) = node.child_by_field_name("attribute") {
629+
return attr.utf8_text(source).unwrap_or("").to_string();
630+
}
631+
}
632+
// Handle obj.method() — kept as the full dotted text (unresolved).
613633
node.utf8_text(source).unwrap_or("").to_string()
614634
}
615635
_ => String::new(),

crates/codegraph-python/src/parser_impl.rs

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,16 @@ impl PythonParser {
155155
.map_err(|e| ParserError::GraphError(e.to_string()))?;
156156

157157
node_map.insert(func.name.clone(), func_id);
158+
// Methods are extracted with a qualified caller name
159+
// ("ClassName.method_name", see extractor.rs) for their own call
160+
// sites, but were only ever registered here under the bare name
161+
// — so `node_map.get(&call.caller)` below always missed for
162+
// calls originating inside a method, and every such call was
163+
// silently dropped into `unresolved_calls` instead of becoming
164+
// a same-file Calls edge. Register the qualified name too.
165+
if let Some(parent) = &func.parent_class {
166+
node_map.insert(format!("{parent}.{}", func.name), func_id);
167+
}
158168
function_ids.push(func_id);
159169

160170
// Link function to file
@@ -193,10 +203,16 @@ impl PythonParser {
193203
.map_err(|e| ParserError::GraphError(e.to_string()))?;
194204

195205
// Methods are already added via ir.functions with parent_class set
196-
// Just create edges from class to its methods
206+
// Just create edges from class to its methods. Prefer the
207+
// qualified key — two classes with a same-named method (e.g.
208+
// `__init__`) would otherwise both resolve to whichever one
209+
// `node_map`'s bare-name entry happened to be last written by.
197210
for method in &class.methods {
198-
let method_name = method.name.clone();
199-
if let Some(&method_id) = node_map.get(&method_name) {
211+
let qualified = format!("{}.{}", class.name, method.name);
212+
let method_id = node_map
213+
.get(&qualified)
214+
.or_else(|| node_map.get(&method.name));
215+
if let Some(&method_id) = method_id {
200216
// Link method to class
201217
graph
202218
.add_edge(class_id, method_id, EdgeType::Contains, PropertyMap::new())
@@ -588,6 +604,51 @@ mod tests {
588604
assert_eq!(metrics.files_failed, 0);
589605
}
590606

607+
#[test]
608+
fn test_call_from_inside_method_creates_local_calls_edge() {
609+
use codegraph::EdgeType;
610+
611+
// Regression test: calls made from inside a class method were never
612+
// wired as Calls edges because the extractor emits a qualified
613+
// caller name ("Foo.caller_method") for method-body calls, but
614+
// `node_map` only ever registered functions under their bare name.
615+
// `get_callers`/`get_callees` on any method silently returned
616+
// nothing for same-file calls.
617+
let source = "\
618+
class Foo:
619+
def helper(self):
620+
return 1
621+
622+
def caller_method(self):
623+
return self.helper()
624+
";
625+
let parser = PythonParser::new();
626+
let mut graph = codegraph::CodeGraph::in_memory().unwrap();
627+
let file_info = parser
628+
.parse_source(source, Path::new("test.py"), &mut graph)
629+
.unwrap();
630+
631+
let node_named = |name: &str| {
632+
file_info
633+
.functions
634+
.iter()
635+
.copied()
636+
.find(|&id| graph.get_node(id).unwrap().properties.get_string("name") == Some(name))
637+
.unwrap_or_else(|| panic!("no function node named {name}"))
638+
};
639+
640+
let helper_id = node_named("helper");
641+
let caller_id = node_named("caller_method");
642+
643+
let edges = graph.get_edges_between(caller_id, helper_id).unwrap();
644+
assert!(
645+
edges
646+
.iter()
647+
.any(|&eid| graph.get_edge(eid).unwrap().edge_type == EdgeType::Calls),
648+
"expected a Calls edge from caller_method to helper"
649+
);
650+
}
651+
591652
#[test]
592653
fn test_implements_edge_creation() {
593654
use codegraph::{CodeGraph, EdgeType};

0 commit comments

Comments
 (0)