From 60cfd51b7dadfbcd3951ee31fd39c6c2b8ca4af4 Mon Sep 17 00:00:00 2001 From: berce <75228263+bercedev@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:33:55 +0300 Subject: [PATCH] fix(js): shadow a file-scope callback's own params (#4448) A closure passed in a module-level call statement is walked under the file node (#3124), but its params and locals were never registered for that walk. A parameter passed on as a call argument then resolved against the corpus-wide label index and produced an indirect_call to any function with the same name, including module-private ones in other packages. Record the closure's bindings in closure_locals_by_body, the same way the #2568 branch already does for const-assigned closures. --- graphify/extractors/engine.py | 8 +++ ...ndirect_call_file_scope_callback_shadow.py | 72 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 tests/test_indirect_call_file_scope_callback_shadow.py diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 75eb5a378..3c65f651e 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -3809,6 +3809,14 @@ def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, # `_js_scan_member_assignments`, so it lands in # `_tracked_body_ids` and walk_calls' own boundary check # skips re-descending into it from here. + # #4448: the walk runs under the file node, so the + # closure's own params/locals must ride along as this + # body's extra_locals (as in the #2568 branch); otherwise + # `forEach(function (value) { s.on(value) })` resolves the + # parameter `value` to any same-named function in the corpus. + if closure_locals_by_body is not None: + closure_locals_by_body[id(closure_body)] = ( + _js_local_bound_names(closure, source)) function_bodies.append((file_nid, closure_body)) assign = next((c for c in node.children if c.type == "assignment_expression"), None) diff --git a/tests/test_indirect_call_file_scope_callback_shadow.py b/tests/test_indirect_call_file_scope_callback_shadow.py new file mode 100644 index 000000000..dd1127d50 --- /dev/null +++ b/tests/test_indirect_call_file_scope_callback_shadow.py @@ -0,0 +1,72 @@ +"""A parameter of a file-scope anonymous callback must shadow same-named +functions elsewhere in the corpus (#4448). + +#3124 walks the body of a closure passed in a module-level call statement +(`items.forEach(function (value) { ... })`, AngularJS `.controller(...)`, +`it("...", () => { ... })`) under the file node, but did not register the +closure's own params/locals for that walk. A parameter passed on as a call +argument was then resolved against the corpus-wide label index and became an +`indirect_call` from the file to any function named like it. Inside a named +function the same code was already handled (#2241, #2568). +""" +from __future__ import annotations + +from graphify.extract import extract + +_TARGET = {"b.js": "function value(x) { return x; }\n"} + + +def _indirect(tmp_path, a_js: str): + files = {"a.js": a_js, **_TARGET} + for name, body in files.items(): + (tmp_path / name).write_text(body) + r = extract([tmp_path / n for n in files], + cache_root=tmp_path / "graphify-out", parallel=False) + lbl = {n["id"]: n["label"] for n in r["nodes"]} + return {(lbl.get(e["source"]), lbl.get(e["target"])) for e in r["edges"] + if e["relation"] == "indirect_call"} + + +def test_callback_param_passed_to_a_member_call_is_not_a_reference(tmp_path): + assert _indirect(tmp_path, "items.forEach(function (value) { s.on(value); });\n") == set() + + +def test_callback_param_passed_to_a_bare_call_is_not_a_reference(tmp_path): + assert _indirect(tmp_path, "items.forEach(function (value) { emit(value); });\n") == set() + + +def test_param_of_a_nested_file_scope_callback_is_not_a_reference(tmp_path): + src = "m.c(function (s) { items.forEach(function (value) { s.on(value); }); });\n" + assert _indirect(tmp_path, src) == set() + + +def test_arrow_callback_params_are_not_references(tmp_path): + src = "items.forEach((value) => emit(value));\nitems.map(value => emit(value));\n" + assert _indirect(tmp_path, src) == set() + + +def test_callback_var_is_not_a_reference(tmp_path): + src = "items.forEach(function () { var value = 1; emit(value); });\n" + assert _indirect(tmp_path, src) == set() + + +def test_unbound_name_inside_a_file_scope_callback_still_resolves(tmp_path): + assert _indirect(tmp_path, "items.forEach(function (x) { emit(value); });\n") == {("a.js", "value()")} + + +def test_a_sibling_callbacks_param_does_not_shadow(tmp_path): + src = "m.c(function (value) { emit(1); }, function () { emit(value); });\n" + assert _indirect(tmp_path, src) == {("a.js", "value()")} + + +def test_param_of_a_callback_inside_a_test_block_is_not_a_reference(tmp_path): + # The inner arrow is itself an expression statement inside the `it` + # callback, so the #3124 branch tracks it as its own body too. + src = ( + "describe('d', () => {\n" + " it('t', async () => {\n" + " intercept((value) => respond(value, 1));\n" + " });\n" + "});\n" + ) + assert _indirect(tmp_path, src) == set()