Skip to content

Commit 368a410

Browse files
committed
fix(js): bind this calls to the caller's own class
this.save() in one JS/TS class was resolved through the file-wide name map, which keeps only the last definition of each name, so a class that calls this.save() was wired to the save() of whichever class the file declared last, as EXTRACTED. In zod's v3 types.ts every ZodString, ZodNumber and ZodBigInt check method landed on ZodDate._addCheck. Route this.m() and super.m() through the self-call lookup the Python fix added, renamed _self_call_target. It binds to the caller's own class when that class defines m(). JS/TS extends edges are only emitted by the later symbol pass, so the base chain is unknown at this point; anything the own class does not define keeps the plain lookup, which leaves inherited this.m() edges exactly as they were.
1 parent 02e1ed4 commit 368a410

2 files changed

Lines changed: 77 additions & 10 deletions

File tree

‎graphify/extractors/engine.py‎

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1356,7 +1356,12 @@ def _python_collect_assignment_targets(node, source: bytes, out: set[str]) -> No
13561356
for c in node.children:
13571357
_python_collect_assignment_targets(c, source, out)
13581358

1359-
def _python_self_call_target(
1359+
# Languages whose `self`/`this` member calls bind through _self_call_target.
1360+
_SELF_CALL_LANGUAGES = frozenset({
1361+
"tree_sitter_python", "tree_sitter_javascript", "tree_sitter_typescript",
1362+
})
1363+
1364+
def _self_call_target(
13601365
caller_nid: str,
13611366
callee: str,
13621367
receiver: str,
@@ -1365,8 +1370,14 @@ def _python_self_call_target(
13651370
method_owner: dict[str, str],
13661371
methods_by_owner: dict[tuple[str, str], str],
13671372
class_bases: dict[str, list[str]],
1373+
walk_bases: bool = True,
13681374
) -> str | None:
1369-
"""In-file target of `self.m()`, `cls.m()` or `super().m()`, else None.
1375+
"""In-file target of `self.m()` / `cls.m()` / `super().m()` in Python and
1376+
`this.m()` / `super.m()` in JS/TS, else None.
1377+
1378+
``walk_bases=False`` stops after the caller's own class and otherwise keeps
1379+
the plain lookup: JS/TS `extends` edges come from the later symbol pass, so
1380+
the chain is unknown here and an inherited `this.m()` must not be refused.
13701381
13711382
The receiver is the caller's own instance, so the lookup starts at the
13721383
enclosing class (skipped for `super`) and walks its in-file bases one level
@@ -1393,6 +1404,8 @@ class to whichever class declared save() last. A file-wide hit that is not
13931404
hits = {methods_by_owner[(c, callee)] for c in level if (c, callee) in methods_by_owner}
13941405
if hits:
13951406
return hits.pop() if len(hits) == 1 else None
1407+
if not walk_bases:
1408+
return fallback
13961409
skip_own = False
13971410
level = list(dict.fromkeys(
13981411
base for c in level for base in class_bases.get(c, ()) if base not in seen
@@ -6215,10 +6228,11 @@ def scala_base_name(type_node) -> str | None:
62156228
if _e.get("relation") == "inherits":
62166229
_local_bases.setdefault(_e["source"], []).append(_e["target"])
62176230

6218-
# Class membership for Python self-calls (see _python_self_call_target).
6231+
# Class membership for self-calls (see _self_call_target): Python
6232+
# self/cls/super and JS/TS this/super.
62196233
method_owner: dict[str, str] = {}
62206234
methods_by_owner: dict[tuple[str, str], str] = {}
6221-
if config.ts_module == "tree_sitter_python":
6235+
if config.ts_module in _SELF_CALL_LANGUAGES:
62226236
label_by_nid = {n["id"]: n["label"] for n in nodes}
62236237
for e in edges:
62246238
if e["relation"] == "method":
@@ -6507,6 +6521,9 @@ def walk_calls(
65076521
callee_name: str | None = None
65086522
is_member_call: bool = False
65096523
is_this_field_call: bool = False
6524+
# JS/TS `this.m()` / `super.m()`: kept apart from member_receiver,
6525+
# which feeds the receiver-typed resolvers and raw_calls.
6526+
self_receiver: str | None = None
65106527
swift_receiver: str | None = None
65116528
member_receiver: str | None = None
65126529
kotlin_qualified_prefix: str | None = None
@@ -6892,6 +6909,12 @@ def walk_calls(
68926909
obj = func_node.child_by_field_name(config.call_accessor_object_field)
68936910
if obj is not None and obj.type == "identifier":
68946911
member_receiver = _read_text(obj, source)
6912+
elif (
6913+
obj is not None
6914+
and obj.type in ("this", "super")
6915+
and config.ts_module in _SELF_CALL_LANGUAGES
6916+
):
6917+
self_receiver = obj.type
68956918
elif (
68966919
config.ts_module == "tree_sitter_python"
68976920
and obj is not None
@@ -7017,14 +7040,14 @@ def walk_calls(
70177040
curr_scope = scope_parents.get(curr_scope)
70187041
if not tgt_nid:
70197042
tgt_nid = label_to_nid.get(callee_name)
7020-
elif (
7043+
elif is_member_call and (self_receiver or (
70217044
config.ts_module == "tree_sitter_python"
7022-
and is_member_call
70237045
and member_receiver in ("self", "cls", "super")
7024-
):
7025-
tgt_nid = _python_self_call_target(
7026-
caller_nid, callee_name, member_receiver, label_to_nid,
7027-
scope_parents, method_owner, methods_by_owner, _local_bases,
7046+
)):
7047+
tgt_nid = _self_call_target(
7048+
caller_nid, callee_name, self_receiver or member_receiver or "",
7049+
label_to_nid, scope_parents, method_owner, methods_by_owner,
7050+
_local_bases, walk_bases=self_receiver is None,
70287051
)
70297052
else:
70307053
tgt_nid = label_to_nid.get(callee_name)

‎tests/test_extract.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2234,6 +2234,50 @@ def test_python_self_call_to_stored_module_function_still_binds(tmp_path):
22342234
assert ("svc_job_run", "svc_handler") in calls
22352235

22362236

2237+
def _js_call_pairs(tmp_path, source, ext):
2238+
"""Extract one JS/TS file and return {(caller_id, callee_id)} for calls edges."""
2239+
f = tmp_path / f"svc.{ext}"
2240+
f.write_text(source, encoding="utf-8")
2241+
result = extract([f], cache_root=tmp_path)
2242+
return {(e["source"], e["target"]) for e in result["edges"] if e["relation"] == "calls"}
2243+
2244+
2245+
@pytest.mark.parametrize("ext", ["ts", "js"])
2246+
def test_js_this_call_binds_to_own_class_not_last_declared(tmp_path, ext):
2247+
"""`this.save()` in Server must reach Server.save, not the save() of a class
2248+
declared later in the file. An arrow function keeps the method's `this`."""
2249+
calls = _js_call_pairs(tmp_path, (
2250+
"class Server {\n"
2251+
" save() { return 1; }\n"
2252+
" flush() { return this.save(); }\n"
2253+
" later() { return [1].map(() => this.save()); }\n"
2254+
"}\n"
2255+
"class Cache {\n"
2256+
" save() { return 2; }\n"
2257+
" flush() { return this.save(); }\n"
2258+
"}\n"
2259+
), ext)
2260+
assert ("svc_server_flush", "svc_server_save") in calls
2261+
assert ("svc_server_flush", "svc_cache_save") not in calls
2262+
assert ("svc_server_later", "svc_server_save") in calls
2263+
assert ("svc_cache_flush", "svc_cache_save") in calls
2264+
2265+
2266+
@pytest.mark.parametrize("ext", ["ts", "js"])
2267+
def test_js_this_call_to_inherited_method_keeps_its_edge(tmp_path, ext):
2268+
"""`extends` is only known after the symbol pass, so a method the class does
2269+
not define itself keeps the plain lookup instead of being refused."""
2270+
calls = _js_call_pairs(tmp_path, (
2271+
"class Base {\n"
2272+
" ping() { return 0; }\n"
2273+
"}\n"
2274+
"class Server extends Base {\n"
2275+
" run() { return this.ping(); }\n"
2276+
"}\n"
2277+
), ext)
2278+
assert ("svc_server_run", "svc_base_ping") in calls
2279+
2280+
22372281
def test_python_qualified_call_ambiguous_class_bails(tmp_path):
22382282
"""When the class name is defined in 2+ files, the qualified call must not
22392283
resolve — single-definition god-node guard (#1446)."""

0 commit comments

Comments
 (0)