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
38 changes: 34 additions & 4 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -3609,6 +3609,12 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None:
for body_id, (method_node, class_nid) in java_method_scopes.items()
}

# #1972: during the top-level root walk, emit ONLY direct `calls` edges.
# Module-level indirect dispatch already has a dedicated pass below with
# correct module-scope shadow filtering, so a root-walk emission would be a
# duplicate carrying wrong (empty) shadow context.
_toplevel_calls_only = False

def _emit_indirect_by_name(ident_name: str, loc_node, scope_nid: str,
context: str) -> None:
"""Resolve a name that is referenced AS A VALUE to a real callable def and emit
Expand All @@ -3620,6 +3626,8 @@ def _emit_indirect_by_name(ident_name: str, loc_node, scope_nid: str,
string names an ATTRIBUTE and is never shadowed by a local, so that path passes
the name straight through. ``loc_node`` supplies the source line.
"""
if _toplevel_calls_only:
return
ref_nid = label_to_nid.get(ident_name)
# Defer to the cross-file resolver when the name is not defined in this file
# (`from .h import fn`), or resolves to an import-surfaced FOREIGN symbol whose
Expand Down Expand Up @@ -4132,7 +4140,7 @@ def walk_calls(
_emit_indirect_ref(arg, caller_nid, enclosing_locals, "argument")

# Helper function calls: config('foo.bar') → uses_config edge to "foo"
if (callee_name and callee_name in config.helper_fn_names):
if (not _toplevel_calls_only and callee_name and callee_name in config.helper_fn_names):
args_node = node.child_by_field_name("arguments")
first_key: str | None = None
if args_node:
Expand Down Expand Up @@ -4170,7 +4178,8 @@ def walk_calls(
})

# Service container bindings: $this->app->bind(Foo::class, Bar::class)
if (node.type == "member_call_expression"
if (not _toplevel_calls_only
and node.type == "member_call_expression"
and callee_name
and callee_name in config.container_bind_methods):
args_node = node.child_by_field_name("arguments")
Expand Down Expand Up @@ -4208,7 +4217,7 @@ def walk_calls(
})

# Static property access: Foo::$bar → uses_static_prop edge
if node.type in config.static_prop_types:
if node.type in config.static_prop_types and not _toplevel_calls_only:
scope_node = node.child_by_field_name("scope")
if scope_node is None:
for child in node.children:
Expand All @@ -4235,7 +4244,8 @@ def walk_calls(
})

# PHP class constant access: Foo::BAR → references_constant edge
if config.ts_module == "tree_sitter_php" and node.type == "class_constant_access_expression":
if (config.ts_module == "tree_sitter_php" and not _toplevel_calls_only
and node.type == "class_constant_access_expression"):
class_name = _php_class_const_scope(node)
if class_name:
tgt_nid = label_to_nid_ci.get(class_name.lower())
Expand Down Expand Up @@ -4323,6 +4333,26 @@ def walk_calls(
java_receiver_types.get(id(body_node)),
)

# Top-level / script-context calls have no enclosing definition, so they
# never entered function_bodies and got no caller at all (#1972). walk_calls
# already returns without descending at every config.function_boundary_types
# node, so walking from the file's root with file_nid as caller picks up
# calls outside every tracked def — it cannot double-emit anything already
# walked via the function_bodies loop above. During this walk we emit ONLY
# direct `calls` (via _toplevel_calls_only), because module-level indirect
# dispatch and references have their own dedicated passes.
#
# Java is skipped: it has no top-level executable statements, its field
# initializers are already walked via initializer_nodes with the owning
# class as caller, and a root walk would only defer them as file-attributed
# raw_calls that resurrect the ambiguous phantom stubs #1744 removed.
if config.ts_module != "tree_sitter_java":
_toplevel_calls_only = True
try:
walk_calls(root, file_nid, java_receiver_types.get(id(root)))
finally:
_toplevel_calls_only = False

# #1356: walk property/field initializers (collected above). walk_calls
# self-guards against re-entering function bodies and dedups via
# seen_call_pairs, so a closure inside an initializer is not double-walked.
Expand Down
44 changes: 44 additions & 0 deletions tests/test_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -2204,3 +2204,47 @@ def test_rewire_does_not_bind_supertype_stub_to_function():
"source_file": "store.py", "weight": 1.0}]
_rewire_unique_stub_nodes(nodes, edges)
assert edges[0]["target"] == "BookStore" # inherits stub not bound to function


def _file_node_ids(result):
return {n["id"] for n in result["nodes"] if str(n.get("label", "")).endswith((".py", ".js"))}


def test_python_toplevel_call_gets_file_caller(tmp_path):
"""#1972: a module-level call with no enclosing def must still produce a
calls edge, sourced from the file node instead of being dropped."""
f = tmp_path / "toplevel.py"
f.write_text("def tally():\n return 1\n\ntally()\n")
result = extract([f], cache_root=tmp_path)
calls = [(e["source"], e["target"]) for e in result["edges"] if e["relation"] == "calls"]
file_ids = _file_node_ids(result)
tally_id = next(n["id"] for n in result["nodes"] if n.get("label") == "tally()")
assert any(s in file_ids and t == tally_id for s, t in calls), \
f"top-level tally() not sourced from file node: {calls}"


def test_js_toplevel_call_gets_file_caller(tmp_path):
"""#1972: same fix covers JS/TS via the shared engine.py walk."""
f = tmp_path / "toplevel.js"
f.write_text("function tally(){ return 1; }\ntally();\n")
result = extract([f], cache_root=tmp_path)
calls = [(e["source"], e["target"]) for e in result["edges"] if e["relation"] == "calls"]
file_ids = _file_node_ids(result)
tally_id = next(n["id"] for n in result["nodes"] if n.get("label") == "tally()")
assert any(s in file_ids and t == tally_id for s, t in calls), \
f"top-level tally() not sourced from file node: {calls}"


def test_incontext_call_unaffected_by_toplevel_fix(tmp_path):
"""#1972 regression guard: a call inside a function stays sourced from that
function, and the new file-root walk adds no duplicate edge."""
f = tmp_path / "incontext.py"
f.write_text("def tally():\n return 1\n\ndef run():\n tally()\n")
result = extract([f], cache_root=tmp_path)
calls = [(e["source"], e["target"]) for e in result["edges"] if e["relation"] == "calls"]
file_ids = _file_node_ids(result)
run_id = next(n["id"] for n in result["nodes"] if n.get("label") == "run()")
tally_id = next(n["id"] for n in result["nodes"] if n.get("label") == "tally()")
assert (run_id, tally_id) in calls
assert calls.count((run_id, tally_id)) == 1 # no duplicate from file-root walk
assert not any(s in file_ids and t == tally_id for s, t in calls) # file is not the caller