mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-13 10:57:13 +00:00
fix(extract): never bind a cross-file call to a definition in another language family
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.
This commit is contained in:
@@ -9860,6 +9860,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)
|
||||
@@ -16780,6 +16826,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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user