Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions tests/test_cross_language_call_resolution.py
Original file line number Diff line number Diff line change
@@ -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