From 82e6fe218538f51a7f681e7f64f477edb44f9f08 Mon Sep 17 00:00:00 2001 From: edinaldoof Date: Tue, 7 Jul 2026 12:48:35 -0300 Subject: [PATCH] fix(extract): never bind a cross-file call to a definition in another language family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-file call resolver matches raw-call callees against a repo-wide label index with no language check. In a repo that mixes a web app with a native Android app, a TSX callback passed by name (register(refreshHeading)) resolved to a same-named Kotlin method and shipped as an INFERRED indirect_call edge — a phantom the extraction spec itself forbids ('calls edges MUST stay within one language'). Direct calls from non-JS/TS callers had the same hole with no gate at all: a bare Python call bound to the lone same-named Kotlin fun. Found on a production Next.js + Android codebase, where the phantom edge also inflated the Kotlin node's betweenness enough to surface it as a top suggested question in GRAPH_REPORT.md. Resolution candidates are now filtered by language interop family before the single-candidate/import-evidence logic runs. Families are grouped by REAL interop so legitimate cross-language resolution keeps working: Kotlin/Java/Scala/Groovy share the JVM, C/C++/Objective-C/CUDA share headers (Swift bridges to ObjC), and JS/TS variants plus Vue/Svelte/Astro SFCs compile into one module graph. Candidates whose family is unknown (no source_file, non-code nodes) are never filtered, preserving the previous permissive behavior, and callers with an unmapped extension skip the guard entirely. --- graphify/extract.py | 63 ++++++++++++ tests/test_cross_language_call_resolution.py | 103 +++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 tests/test_cross_language_call_resolution.py diff --git a/graphify/extract.py b/graphify/extract.py index 652261ba6..005217cc6 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -9828,6 +9828,52 @@ def _lang_is_case_insensitive(source_file: object) -> bool: return Path(str(source_file)).suffix.lower() in _CASE_INSENSITIVE_EXTS +# Language interop families for cross-file call resolution. A call in one language +# can never bind by name to a definition in another family — a TSX component does +# not invoke a Kotlin method, and a Python function does not invoke a Java one. +# Families are grouped by REAL interop so legitimate cross-language resolution +# keeps working: Kotlin/Java/Scala/Groovy share the JVM, C/C++/Objective-C/CUDA +# share headers and symbols (Swift bridges to Objective-C), and JS/TS variants +# (plus Vue/Svelte/Astro SFC script blocks) compile into one module graph. +# Extensions absent from this map (docs, configs, unknown languages) resolve to +# no family and are never filtered — same permissive default as before. +_LANG_FAMILY_BY_EXT: dict[str, str] = { + # JS/TS module graph (SFCs embed JS/TS) + ".js": "jsts", ".jsx": "jsts", ".mjs": "jsts", ".cjs": "jsts", + ".ts": "jsts", ".tsx": "jsts", ".mts": "jsts", ".cts": "jsts", + ".vue": "jsts", ".svelte": "jsts", ".astro": "jsts", + # JVM interop + ".java": "jvm", ".kt": "jvm", ".kts": "jvm", + ".scala": "jvm", ".groovy": "jvm", ".gradle": "jvm", + # C-family: shared headers, Objective-C/C++ mix, Swift↔ObjC bridging + ".c": "native", ".h": "native", ".cpp": "native", ".cc": "native", + ".cxx": "native", ".hpp": "native", ".cu": "native", ".cuh": "native", + ".metal": "native", ".m": "native", ".mm": "native", ".swift": "native", + # Single-language families + ".py": "python", + ".go": "go", + ".rs": "rust", + ".rb": "ruby", + ".php": "php", ".phtml": "php", ".php3": "php", ".php4": "php", + ".php5": "php", ".php7": "php", ".phps": "php", + ".cs": "dotnet", ".razor": "dotnet", ".cshtml": "dotnet", ".xaml": "dotnet", + ".lua": "lua", ".luau": "lua", + ".zig": "zig", + ".ex": "elixir", ".exs": "elixir", + ".jl": "julia", + ".dart": "dart", + ".sh": "shell", ".bash": "shell", + ".ps1": "powershell", ".psm1": "powershell", ".psd1": "powershell", +} + + +def _lang_family(source_file: object) -> str | None: + """Interop family of the file's language, or None when unknown/not code.""" + if not source_file: + return None + return _LANG_FAMILY_BY_EXT.get(Path(str(source_file)).suffix.lower()) + + def _node_label_key(node: dict, fold: bool = False) -> str: label = str(node.get("label", "")).strip() key = re.sub(r"[^a-zA-Z0-9]+", "", label) @@ -16748,6 +16794,23 @@ def extract( candidates = global_label_to_nids_ci.get(callee.lower(), []) if not candidates: continue + # Cross-language guard: never bind a call to a definition in a different + # language family. Name-only matching was resolving a TSX callback passed + # by name to a same-named Kotlin method in the Android half of the repo + # (and a Python call to a Kotlin fun) — phantom edges the extraction spec + # explicitly forbids. Candidates whose family is unknown (no source_file, + # non-code nodes) are kept, preserving the previous permissive behavior; + # real interop pairs (Kotlin↔Java, C↔C++↔ObjC, JS↔TS) share a family and + # still resolve. + caller_family = _lang_family(rc.get("source_file")) + if caller_family is not None: + candidates = [ + c for c in candidates + if (candidate_family := _lang_family(nid_to_source_file.get(c))) is None + or candidate_family == caller_family + ] + if not candidates: + continue caller = rc["caller_nid"] # Resolve the caller's file via the raw_call's own source_file string, # which is stable regardless of any caller_nid remap. An indirect diff --git a/tests/test_cross_language_call_resolution.py b/tests/test_cross_language_call_resolution.py new file mode 100644 index 000000000..62fe4c60f --- /dev/null +++ b/tests/test_cross_language_call_resolution.py @@ -0,0 +1,103 @@ +"""Cross-language call resolution — a call in one language must never bind by +name to a definition in another language family. + +The cross-file resolver matched raw-call callees against a repo-wide label +index with no language check, so in a repo that mixes a web app with a native +Android app a TSX callback passed by name (``register(refreshHeading)``) +resolved to a same-named Kotlin method and shipped as an INFERRED +``indirect_call`` edge — a phantom the extraction spec explicitly forbids +("calls edges MUST stay within one language"). Direct calls from non-JS/TS +languages had the same hole: a Python call bound to a Kotlin ``fun``. + +The fix filters resolution candidates by language interop family. Families are +grouped by REAL interop so legitimate cross-language resolution keeps working: +Kotlin/Java share the JVM, C/C++/Objective-C share headers, JS/TS variants +compile into one module graph. Candidates with no known family (non-code +nodes) are never filtered, preserving the previous permissive behavior. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _call_edges(files: list[Path], base: Path) -> set[tuple[str, str, str, str]]: + r = extract(files, cache_root=base, parallel=False) + lbl = {n["id"]: n["label"] for n in r["nodes"]} + return { + (lbl.get(e["source"], ""), lbl.get(e["target"], ""), e["relation"], e.get("confidence")) + for e in r["edges"] if e["relation"] in ("calls", "indirect_call") + } + + +def test_tsx_callback_does_not_bind_to_kotlin_method(tmp_path: Path) -> None: + # The real-world symptom: a repo with a web app and a native Android app. + # A TSX component passes a callback by name; the only same-named definition + # repo-wide is a Kotlin method. No edge must be emitted. + _write(tmp_path / "web/Upcoming.tsx", + "declare function register(cb: () => void): void;\n" + "export function UpcomingPanel() {\n" + " register(refreshHeading);\n" + " return null;\n" + "}\n") + _write(tmp_path / "android/HeadingSensorBridge.kt", + "class HeadingSensorBridge {\n" + " fun refreshHeading() {\n" + " println(\"native sensor\")\n" + " }\n" + "}\n") + edges = _call_edges(sorted(tmp_path.rglob("*.tsx")) + sorted(tmp_path.rglob("*.kt")), tmp_path) + assert not any("refreshHeading" in t for _s, t, _r, _c in edges), edges + + +def test_python_call_does_not_bind_to_kotlin_function(tmp_path: Path) -> None: + # Direct-call path (non-JS/TS callers have no import-evidence gate): a bare + # Python call must not resolve to the lone same-named Kotlin definition. + _write(tmp_path / "py/worker.py", + "def process():\n" + " return refreshHeading()\n") + _write(tmp_path / "android/HeadingSensorBridge.kt", + "class HeadingSensorBridge {\n" + " fun refreshHeading() {\n" + " println(\"native sensor\")\n" + " }\n" + "}\n") + edges = _call_edges(sorted(tmp_path.rglob("*.py")) + sorted(tmp_path.rglob("*.kt")), tmp_path) + assert not any("refreshHeading" in t for _s, t, _r, _c in edges), edges + + +def test_same_language_callback_still_resolves(tmp_path: Path) -> None: + # Positive control: a TS callback passed by name with a same-language + # definition and import evidence keeps resolving as INFERRED indirect_call. + _write(tmp_path / "a.ts", + 'import { refreshHeading } from "./b";\n' + "declare function register(cb: () => void): void;\n" + "export function run() { register(refreshHeading); }\n") + _write(tmp_path / "b.ts", + "export function refreshHeading(): void {}\n") + edges = _call_edges([tmp_path / "a.ts", tmp_path / "b.ts"], tmp_path) + resolved = [e for e in edges if "refreshHeading" in e[1] and e[2] == "indirect_call"] + assert resolved, edges + assert resolved[0][3] == "INFERRED" + + +def test_jvm_interop_kotlin_call_to_java_still_resolves(tmp_path: Path) -> None: + # Kotlin and Java share the JVM — same interop family, so a Kotlin call to a + # Java method must keep resolving exactly as it did before the guard. + _write(tmp_path / "Alarm.java", + "public class Alarm {\n" + " public static void ring() { System.out.println(\"ring\"); }\n" + "}\n") + _write(tmp_path / "Scheduler.kt", + "fun schedule() {\n" + " ring()\n" + "}\n") + edges = _call_edges([tmp_path / "Alarm.java", tmp_path / "Scheduler.kt"], tmp_path) + assert any("ring" in t for _s, t, _r, _c in edges), edges