Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -5658,7 +5658,14 @@ def walk_calls(
and not nid_to_sf.get(tgt_nid)
):
tgt_nid = None
if tgt_nid and tgt_nid != caller_nid:
if tgt_nid:
# A direct recursive call (tgt_nid == caller_nid) is a real
# self-edge, not noise: name resolution binds it the same
# way as any other call, and build_from_json already
# preserves a supplied recursive calls self-loop rather
# than stripping it, so extraction dropping it here was
# the only place the call structure actually got lost
# (#3350).
pair = (caller_nid, tgt_nid)
if pair not in seen_call_pairs:
seen_call_pairs.add(pair)
Expand Down
46 changes: 46 additions & 0 deletions tests/test_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,9 @@ def test_python_call_edges_have_call_context():


def test_calls_no_self_loops():
"""sample_calls.py has no recursive functions, so no call resolves to its own
caller here -- a genuinely recursive function DOES produce a self-loop (#3350),
see test_recursive_call_produces_self_loop below."""
result = extract_python(FIXTURES / "sample_calls.py")
for edge in result["edges"]:
if edge["relation"] == "calls":
Expand Down Expand Up @@ -604,6 +607,49 @@ def test_calls_deduplication():
assert len(call_pairs) == len(set(call_pairs)), "Duplicate calls edges found"


def test_recursive_call_produces_self_loop(tmp_path):
"""#3350: a function calling itself must extract as a calls self-loop, not be
silently dropped. build_from_json already preserves a supplied recursive
self-loop (test_recursive_call_self_loop_is_preserved); the gap was that
extraction never produced one to preserve in the first place."""
p = tmp_path / "repro.py"
p.write_text(
"def factorial(n):\n"
" return 1 if n < 2 else n * factorial(n - 1)\n"
"\n"
"def entry(n):\n"
" return factorial(n)\n",
encoding="utf-8",
)
result = extract_python(p)
calls = {(e["source"], e["target"]) for e in result["edges"] if e["relation"] == "calls"}
node_by_label = {n["label"]: n["id"] for n in result["nodes"]}
factorial = node_by_label.get("factorial()")
entry = node_by_label.get("entry()")
assert factorial and entry
assert (entry, factorial) in calls, "ordinary call missing"
assert (factorial, factorial) in calls, "recursive self-loop missing (#3350)"


def test_recursive_method_call_produces_self_loop(tmp_path):
"""#3350 follow-up: recursion through self.<method>() must self-loop too."""
p = tmp_path / "repro.py"
p.write_text(
"class Tree:\n"
" def depth(self, node):\n"
" if not node:\n"
" return 0\n"
" return 1 + self.depth(node.child)\n",
encoding="utf-8",
)
result = extract_python(p)
calls = {(e["source"], e["target"]) for e in result["edges"] if e["relation"] == "calls"}
node_by_label = {n["label"]: n["id"] for n in result["nodes"]}
depth = node_by_label.get(".depth()")
assert depth
assert (depth, depth) in calls, "recursive method self-loop missing (#3350)"


def test_cross_file_calls_skip_ambiguous_duplicate_labels(tmp_path):
"""Unqualified cross-file calls must not guess between duplicate helper names."""
caller = tmp_path / "caller.py"
Expand Down
Loading