From bf13b63ca43e96984a027c5491a837f784a379b4 Mon Sep 17 00:00:00 2001 From: xiongjianxu <6457197+xiongjianxu@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:10:43 +0800 Subject: [PATCH] fix(ts): index only the JS/TS interop family for receiver typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A corpus-wide type index let a same-named class in another language stand in for the receiver's type, which hid from the parking branch that nothing in the JS/TS family declares it. SFC script blocks are in the family, so a class exported from a .vue file still answers — here and at merge time. (cherry picked from commit 078c106e62a13915721d61651ee4b95c9723a96e) --- graphify/cross_repo_calls.py | 6 ++++-- graphify/extract.py | 14 +++++++----- tests/test_ts_receiver_member_calls.py | 30 ++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/graphify/cross_repo_calls.py b/graphify/cross_repo_calls.py index d051b3c42..2e571042d 100644 --- a/graphify/cross_repo_calls.py +++ b/graphify/cross_repo_calls.py @@ -46,8 +46,10 @@ _LANG_SUFFIXES: dict[str, frozenset[str]] = { "swift": frozenset({".swift"}), "php": frozenset({".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps"}), # TS and JS share one key: a TS class legitimately answers a JS call site, so - # splitting them would drop every TS<->JS cross-repo call. - "typescript": frozenset({".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"}), + # splitting them would drop every TS<->JS cross-repo call. An SFC script block is + # TS/JS too, so a class exported from `.vue` answers as well. + "typescript": frozenset({".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", + ".vue", ".svelte", ".astro"}), } # A declaration owns its members through a `method` edge, except in C++, where an diff --git a/graphify/extract.py b/graphify/extract.py index 2c3efeb1c..07efd41f2 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4976,8 +4976,8 @@ def _resolve_python_member_calls( _emit_call(caller, children[0], rc) -# Every suffix the TS/JS extractors claim. The member-call resolver both activates on -# and parks by this set, so a pure-ESM (`.mjs`) repo is not silently left out. +# Every suffix the TS/JS extractors claim, so a pure-ESM (`.mjs`) repo still activates +# the member-call resolver below. _JS_TS_SUFFIXES = (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs") @@ -5022,11 +5022,15 @@ def _resolve_typescript_member_calls( contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"} + # Only the JS/TS interop family declares a type this receiver can hold (SFC script + # blocks included). A same-named class in another language must neither answer nor + # hide from the parking branch that nothing local declares the name. type_def_nids: dict[str, list[str]] = {} node_by_id: dict[str, dict] = {} for n in all_nodes: node_by_id[n.get("id")] = n - if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n): + if (_lang_family(n.get("source_file")) == "jsts" + and n.get("id") in contained and _is_type_like_definition(n)): type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"]) method_index: dict[tuple[str, str], str] = {} @@ -5093,8 +5097,8 @@ def _resolve_typescript_member_calls( if not type_defs: # Table-typed only: an uppercase receiver also matches namespace aliases and # default imports, and node_modules is unscanned, so those are npm names. - # The suffix stands in for the `lang` tag TS raw_calls do not carry. - if not type_qualified and str(rc.get("source_file", "")).endswith(_JS_TS_SUFFIXES): + # The caller's family stands in for the `lang` tag TS raw_calls do not carry. + if not type_qualified and _lang_family(rc.get("source_file")) == "jsts": _park_unresolved_member_call( node_by_id.get(caller), callee, type_name, "typescript", rc, ) diff --git a/tests/test_ts_receiver_member_calls.py b/tests/test_ts_receiver_member_calls.py index 187a29e74..59f7c049e 100644 --- a/tests/test_ts_receiver_member_calls.py +++ b/tests/test_ts_receiver_member_calls.py @@ -350,3 +350,33 @@ def test_a_type_the_origin_gate_rejected_is_not_parked(tmp_path): "}\n"), }) assert _parked(r) == [] + + +def test_a_class_from_another_language_neither_answers_nor_blocks_parking(tmp_path): + # The declaration index is corpus-wide, so a same-named PHP class used to make the + # receiver look locally declared and the call was dropped instead of parked. + _, r = _calls(tmp_path, { + "app.ts": ("import { Greeter } from 'greeter-pkg';\n" + "export class App {\n" + " constructor(private greeter: Greeter) {}\n" + " run(): void { this.greeter.greet(); }\n" + "}\n"), + "Greeter.php": "\nexport class Greeter {\n" + " greet(): void {}\n}\n\n"), + }) + assert any("run" in str(s) and "greet" in str(t) for s, t in calls), calls