From a1ce057757565ed7ceeb068e1ed2de0c0e5d5449 Mon Sep 17 00:00:00 2001 From: Mpasha17 Date: Thu, 15 Oct 2026 21:41:27 +0530 Subject: [PATCH] fix(ts): don't guess between members the normalized key can't tell apart Review on #4479: after the exact-name lookup, the `_key` fallback in _resolve_typescript_member_calls still matched loosely. With #4475 splitting close/#close, updateHash/_updateHash and set/$set into separate nodes, one normalized key can now map to two members, and method_index kept whichever was registered last. So c._close() bound to #close, c.UpdateHash() to _updateHash, and c.a() to $a, none of which is the called name. Track keys that more than one member normalizes to and skip the fallback for them (no edge rather than a guess). Exact hits, and loose hits with a single candidate, are unchanged. Co-Authored-By: Grok Bot --- graphify/extract.py | 9 +++++++-- tests/test_js_member_collisions.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 2c715ac36..d45b71d70 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -5048,6 +5048,8 @@ def _resolve_typescript_member_calls( # `updateHash` / `_updateHash` are two nodes of one class, so the exact # name is tried first and `_key` stays the fallback. method_exact: dict[tuple[str, str], str] = {} + # Keys more than one member normalizes to: the fallback can't tell them apart. + method_key_ambiguous: set[tuple[str, str]] = set() class_of_method: dict[str, str] = {} for e in all_edges: if e.get("relation") != "method": @@ -5055,7 +5057,10 @@ def _resolve_typescript_member_calls( src, tgt = e.get("source"), e.get("target") tnode = node_by_id.get(tgt) if tnode is not None: - method_index[(src, _key(tnode.get("label", "")))] = tgt + key = (src, _key(tnode.get("label", ""))) + if method_index.get(key, tgt) != tgt: + method_key_ambiguous.add(key) + method_index[key] = tgt method_exact[(src, str(tnode.get("label", "")).removesuffix("()").removeprefix("."))] = tgt if isinstance(src, str) and isinstance(tgt, str): class_of_method[tgt] = src @@ -5188,7 +5193,7 @@ def _resolve_typescript_member_calls( type_nid = visible[0] method_nid = method_exact.get((type_nid, str(callee))) exact = method_nid is not None - if not exact: + if not exact and (type_nid, _key(callee)) not in method_key_ambiguous: method_nid = method_index.get((type_nid, _key(callee))) if not method_nid: # Receiver typed, but the type has no such method. The old fallback diff --git a/tests/test_js_member_collisions.py b/tests/test_js_member_collisions.py index c302b2604..b4acc9d03 100644 --- a/tests/test_js_member_collisions.py +++ b/tests/test_js_member_collisions.py @@ -99,6 +99,34 @@ def test_cross_file_ts_typed_receiver_hits_each_member(tmp_path): assert ("b()", ".$set()") in calls and ("b()", ".set()") not in calls + +@pytest.mark.parametrize("members,call", [ + (" close(): void {}\n #close(): void {}\n", "c._close()"), + (" updateHash(): void {}\n _updateHash(): void {}\n", "c.$updateHash()"), + (" updateHash(): void {}\n _updateHash(): void {}\n", "c.UpdateHash()"), + (" set(v: number): void {}\n $set(v: number): void {}\n", "c._set(1)"), + (" _a(): void {}\n $a(): void {}\n", "c.a()"), +]) +def test_cross_file_call_matching_no_member_exactly_is_not_guessed(tmp_path, members, call): + # Review on #4479: neither member has the called name, and the loose key fits + # both, so picking one would be a guess (it was whichever registered last). + r = _extract(tmp_path, { + "ctx.ts": "export class Ctx {\n" + members + "}\n", + "use.ts": ('import { Ctx } from "./ctx";\n' + "export function go(c: Ctx) { " + call + "; }\n"), + }) + assert not [t for s, t in _calls(r) if s == "go()"], _calls(r) + + +def test_cross_file_loose_match_with_a_single_candidate_is_unchanged(tmp_path): + # One member per key still resolves loosely, as on v8 (not touched here). + r = _extract(tmp_path, { + "ctx.ts": "export class Ctx {\n _close(): void {}\n}\n", + "use.ts": ('import { Ctx } from "./ctx";\n' + "export function go(c: Ctx) { c.close(); }\n"), + }) + assert ("go()", "._close()") in _calls(r) + def test_dedup_build_and_merge_keep_both(tmp_path): from graphify.build import build, build_merge from graphify.cluster import cluster