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