diff --git a/graphify/extract.py b/graphify/extract.py index cb01dc530..70c9a739c 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4499,28 +4499,41 @@ def _resolve_typescript_member_calls( if type_name in _LANGUAGE_BUILTIN_GLOBALS: continue type_defs = type_def_nids.get(_key(type_name), []) - if len(type_defs) != 1: - continue - type_nid = type_defs[0] # Origin gate (#2553): the caller's file must actually see the matched # type — same file, a named import of the type, or a module import of # the type's file. Otherwise a third-party type name that happens to # collide with a local class fabricates an edge; emit nothing. + # The same gate also picks among same-named definitions: `Context` can be + # a class in one file and an interface in two others, and the caller's + # import says which one its `c: Context` means. Exactly one visible + # definition is required; none or several still emit nothing. caller_file = file_of_node.get(caller) - type_file = file_of_node.get(type_nid) imported = imported_by_filenode.get(caller_file, set()) - if not ( - (caller_file is not None and caller_file == type_file) - or type_nid in imported - or (type_file is not None and type_file in imported) - ): + + def _visible(nid: str) -> bool: + type_file = file_of_node.get(nid) + return ( + (caller_file is not None and caller_file == type_file) + or nid in imported + or (type_file is not None and type_file in imported) + ) + + visible = [nid for nid in type_defs if _visible(nid)] + if len(visible) != 1: continue + type_nid = visible[0] 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 # (a `references` edge to the type node) was another fabrication # vector; skip instead, matching the C# resolver. continue + # _key() drops the `#`, so `c.newResponse()` also keys onto a private + # `#newResponse()`. A `#name` is only reachable as `#name`; never bind + # across that difference. + method_name = str(node_by_id.get(method_nid, {}).get("label", "")).lstrip(".") + if method_name.startswith("#") != str(callee).startswith("#"): + continue if method_nid == caller or (caller, method_nid) in existing_pairs: continue existing_pairs.add((caller, method_nid)) diff --git a/tests/test_ts_receiver_member_calls.py b/tests/test_ts_receiver_member_calls.py index f02015d1e..9ef780ae4 100644 --- a/tests/test_ts_receiver_member_calls.py +++ b/tests/test_ts_receiver_member_calls.py @@ -214,3 +214,56 @@ def test_class_field_of_an_unimported_type_emits_no_edge(tmp_path): "export class K {\n repo: Svc;\n a(): number { return this.repo.doThing(); }\n}\n"), }) assert not _caller_hits(calls, "a") +# ── Same-named types: `Context` can be a class in one file and an interface or +# type in others. The caller's own import says which one `c: Context` means. + +_CTX_CLASS = "export class Context {\n header(): number { return 1; }\n}\n" +_CTX_IFACE = "export interface Context {\n header(): number;\n}\n" + + +def _hits(calls, caller: str, callee: str = "header") -> bool: + return any(caller in s and callee in t for s, t in calls) + + +def test_same_named_type_is_picked_by_the_callers_import(tmp_path): + calls, r = _calls(tmp_path, { + "context.ts": _CTX_CLASS, + "jsx/context.ts": _CTX_IFACE, + "router/node.ts": "export type Context = { header(): number };\n", + "cookie.ts": ('import type { Context } from "./context";\n' + "export const setCookie = (c: Context): number => c.header();\n"), + }) + nodes = {n["id"]: n for n in r["nodes"]} + targets = [e["target"] for e in r["edges"] + if e["relation"] == "calls" and "setCookie" in nodes[e["source"]]["label"]] + # the class in ./context.ts, not the jsx interface or the router type alias + assert len(targets) == 1, targets + assert targets[0].endswith("context_context_header"), targets + assert "jsx" not in targets[0] and "router" not in targets[0], targets + + +def test_same_named_type_the_import_cannot_decide_emits_no_edge(tmp_path): + calls, _ = _calls(tmp_path, { + "a/context.ts": _CTX_CLASS, + "b/context.ts": _CTX_CLASS, + "both.ts": ('import * as a from "./a/context";\nimport * as b from "./b/context";\n' + "type Context = a.Context;\n" + "export function both(c: Context): number { return c.header(); }\n"), + "neither.ts": "export function neither(c: Context): number { return c.header(); }\n", + }) + assert not _hits(calls, "both") + assert not _hits(calls, "neither") + + +def test_a_public_call_never_binds_to_a_private_hash_method(tmp_path): + # `_key()` folds `#newResponse` and `newResponse` together; a `#name` is only + # reachable as `#name`, so `c.newResponse()` must not land on it. + calls, _ = _calls(tmp_path, { + "ctx.ts": ("export class Ctx {\n" + " #newResponse(): number { return 1; }\n" + " build(): number { return this.#newResponse(); }\n" + "}\n"), + "use.ts": ('import { Ctx } from "./ctx";\n' + "export function make(c: Ctx): number { return c.newResponse(); }\n"), + }) + assert not _hits(calls, "make", "newResponse")