From f25cb93a6caee60d4337c793183e63ceef3cca63 Mon Sep 17 00:00:00 2001 From: Rishet Mehra Date: Sun, 19 Jul 2026 04:51:50 +0530 Subject: [PATCH 1/2] feat(extract): bind top-level / script-context calls to the file node as caller (#1972) Calls at a file's top level (or inside a plain DSL block) had no enclosing definition, so they never entered function_bodies and got no caller node or edge. Walk the file root once with the file node as caller after the per-definition walk; walk_calls already returns at every definition boundary, so it cannot double-emit anything the per-body loop covered. During this root walk emit ONLY direct calls: a _toplevel_calls_only flag suppresses indirect_call (already handled by the dedicated module-dispatch pass with correct shadow filtering) and the PHP-family reference emissions. Java is skipped entirely (no top-level statements; field initializers are walked via initializer_nodes with the owning class as caller). Covers Python and JS/TS, which share this extractor. Adds tests for top-level Python/JS calls and an in-context regression guard. --- graphify/extractors/engine.py | 38 ++++++++++++++++++++++++++---- tests/test_extract.py | 44 +++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index e0601cc3f..6c14d64e6 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -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 @@ -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 @@ -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: @@ -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") @@ -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: @@ -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()) @@ -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 removes. + 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. diff --git a/tests/test_extract.py b/tests/test_extract.py index 98b73edb9..19e6aa455 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -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 From d904bdcec2f4a20bd3319f58cff7c11e888ef49b Mon Sep 17 00:00:00 2001 From: Rishet11 <154429365+Rishet11@users.noreply.github.com> Date: Mon, 20 Jul 2026 04:08:06 +0530 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- graphify/extractors/engine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 6c14d64e6..16e9e87b1 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -4345,7 +4345,7 @@ def walk_calls( # 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 removes. + # raw_calls that resurrect the ambiguous phantom stubs #1744 removed. if config.ts_module != "tree_sitter_java": _toplevel_calls_only = True try: