fix(elixir): resolve qualified and aliased remote calls (#4206)

Co-Authored-By: Grok Bot <noreply@x.ai>
(cherry picked from commit b834ec821f)
This commit is contained in:
Mahaboob pasha authored and safishamsi committed 2026-10-08 14:28:27 +01:00
1 parent ab3c3553cc
commit e63386fc0c
3 files changed
+440 -8

No files matched your search

+74
View File
@@ -5820,6 +5820,73 @@ def _resolve_elixir_import_targets(
e["target"] = target_nid
def _resolve_elixir_qualified_calls(
per_file: list[dict],
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Resolve Elixir remote calls that name their module (#4206).
extract_elixir expands the receiver of `App.Accounts.get_user(id)` /
`Accounts.get_user(id)` (through the caller's `alias` table and
`__MODULE__`) and stamps it on the raw_call as ``elixir_module``; calls
into a module of the same file are linked there already. The shared pass
skips member calls, so this pass is strictly additive, like
`_resolve_csharp_qualified_calls`.
The module must match exactly one top-level module in the corpus and the
function must be one of its own defs. A module outside the corpus (Ecto's
`Repo`, `Enum`, `Map`) gets no edge rather than a same-named guess.
"""
raw = [
rc
for result in per_file
for rc in result.get("raw_calls", [])
if rc.get("elixir_module") and rc.get("callee") and rc.get("caller_nid")
]
if not raw:
return
module_nids: dict[str, list[str]] = {}
labels: dict[str, str] = {}
for n in all_nodes:
labels[n["id"]] = str(n.get("label") or "")
if n.get("_elixir_module") and n.get("source_file"):
module_nids.setdefault(labels[n["id"]], []).append(n["id"])
defs: dict[tuple[str, str], str] = {}
for e in all_edges:
if e.get("relation") == "method":
name = labels.get(e.get("target", ""), "")
if name.endswith("()"):
defs.setdefault((e["source"], name[:-2]), e["target"])
existing_pairs = {
(e.get("source"), e.get("target"))
for e in all_edges
if e.get("relation") == "calls"
}
for rc in raw:
candidates = module_nids.get(rc["elixir_module"], [])
if len(candidates) != 1:
continue
caller = rc["caller_nid"]
tgt = defs.get((candidates[0], rc["callee"]))
if tgt is None or tgt == caller or (caller, tgt) in existing_pairs:
continue
existing_pairs.add((caller, tgt))
all_edges.append({
"source": caller,
"target": tgt,
"relation": "calls",
"context": "call",
"confidence": "EXTRACTED", # the module is written in source
"confidence_score": 1.0,
"source_file": rc.get("source_file", ""),
"source_location": rc.get("source_location"),
"weight": 1.0,
})
def _resolve_kotlin_member_calls(
per_file: list[dict],
all_nodes: list[dict],
@@ -5990,6 +6057,13 @@ register_language_resolver(
_resolve_elixir_import_targets,
)
)
register_language_resolver(
LanguageResolver(
"elixir_qualified_calls",
frozenset({".ex", ".exs"}),
_resolve_elixir_qualified_calls,
)
)
# Pascal/Delphi cross-file inherited-method-call resolution: a call from a
# manual descendant class to a method it inherits from an ancestor declared
# in a DIFFERENT file (the common generated-base/manual-descendant split,
+87 -8
View File
@@ -1,6 +1,7 @@
"""Elixir extractor. Moved verbatim from graphify/extract.py."""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
@@ -61,6 +62,11 @@ def extract_elixir(path: Path) -> dict:
# is the file's top level), along with each module's and function's parent.
call_scope: dict[str | None, list[str]] = {}
enclosing: dict[str, str | None] = {}
# For qualified calls (`Mod.fun()`, #4206): each module's full name, each
# scope's `alias` table (same scoping as call_scope) and each module's defs.
module_names: dict[str, str] = {}
aliases: dict[str | None, dict[str, str]] = {}
module_defs: dict[tuple[str, str], str] = {}
_IMPORT_KEYWORDS = frozenset({"alias", "import", "require", "use"})
@@ -99,10 +105,11 @@ def extract_elixir(path: Path) -> dict:
return [_text(child)]
return []
def _get_defimpl_target(node) -> str | None:
"""The `for:` target module of a `defimpl Proto, for: Type` argument
list. The grammar holds it in a trailing `keywords` node as a `pair`
whose keyword is `for:` and whose value is an `alias`."""
def _get_keyword_alias(node, key: str) -> str | None:
"""The module given for keyword ``key`` in an argument list, e.g. the
`for:` of `defimpl Proto, for: Type` or the `as:` of `alias A.B, as: C`.
The grammar holds it in a trailing `keywords` node as a `pair` whose
keyword is `key:` and whose value is an `alias`."""
for child in node.children:
if child.type != "keywords":
continue
@@ -116,10 +123,53 @@ def extract_elixir(path: Path) -> dict:
kw = source[sub.start_byte:sub.end_byte].decode("utf-8", errors="replace")
elif sub.type == "alias":
val = source[sub.start_byte:sub.end_byte].decode("utf-8", errors="replace")
if kw and kw.rstrip(": ").strip() == "for" and val:
if kw and kw.rstrip(": ").strip() == key and val:
return val
return None
def _expand_module(name: str, scope: str | None) -> str:
"""Expand the first segment of a module reference through the `alias`
tables visible from ``scope`` (or `__MODULE__` to the current module)."""
first, _, rest = name.partition(".")
target = None
if first == "__MODULE__":
# ``scope`` is a module, or a function directly inside one.
if scope is not None and scope not in module_names:
scope = enclosing.get(scope)
target = (module_names.get(scope) if scope is not None else None) or None
else:
seen: set[str | None] = set()
container = scope
while container not in seen:
seen.add(container)
target = aliases.get(container, {}).get(first)
if target is not None or container is None:
break
container = enclosing.get(container)
if target is None:
return name
return f"{target}.{rest}" if rest else target
def _record_aliases(arguments_node, scope: str | None) -> None:
modules = [_expand_module(m, scope) for m in _get_alias_modules(arguments_node)]
as_name = _get_keyword_alias(arguments_node, "as")
table = aliases.setdefault(scope, {})
if as_name and len(modules) == 1:
table[as_name] = modules[0]
elif not as_name:
for module in modules:
table[module.rsplit(".", 1)[-1]] = module
def _register_module(nid: str, name: str, parent: str | None) -> None:
# A nested `defmodule Inner` is `Outer.Inner`, and `Inner` is
# auto-aliased inside Outer.
parent_name = module_names.get(parent) if parent is not None else None
if parent_name:
first = name.split(".", 1)[0]
aliases.setdefault(parent, {})[first] = f"{parent_name}.{first}"
name = f"{parent_name}.{name}"
module_names[nid] = name
def walk(node, parent_module_nid: str | None = None) -> None:
if node.type != "call":
for child in node.children:
@@ -160,6 +210,7 @@ def extract_elixir(path: Path) -> dict:
**({"_elixir_module": True} if parent_module_nid is None else {}))
add_edge(file_nid, module_nid, "contains", line)
enclosing[module_nid] = parent_module_nid
_register_module(module_nid, module_name, parent_module_nid)
if do_block_node:
for child in do_block_node.children:
walk(child, parent_module_nid=module_nid)
@@ -179,6 +230,7 @@ def extract_elixir(path: Path) -> dict:
**({"_elixir_module": True} if parent_module_nid is None else {}))
add_edge(parent_module_nid or file_nid, proto_nid, "contains", line)
enclosing[proto_nid] = parent_module_nid
_register_module(proto_nid, proto_name, parent_module_nid)
if do_block_node:
for child in do_block_node.children:
walk(child, parent_module_nid=proto_nid)
@@ -190,12 +242,13 @@ def extract_elixir(path: Path) -> dict:
proto_name = _get_alias_text(arguments_node) if arguments_node else None
if not proto_name:
return
target = _get_defimpl_target(arguments_node)
target = _get_keyword_alias(arguments_node, "for")
impl_nid = _make_id(stem, "defimpl", proto_name, target or "")
label = f"{proto_name} (for {target})" if target else proto_name
add_node(impl_nid, label, line)
add_edge(parent_module_nid or file_nid, impl_nid, "contains", line)
enclosing[impl_nid] = parent_module_nid
module_names[impl_nid] = f"{proto_name}.{target}" if target else ""
# Link the implementation to the protocol it satisfies. A same-file
# protocol resolves directly; a cross-file target is filtered out by
# the dangling-edge guard below rather than left hanging.
@@ -247,6 +300,7 @@ def extract_elixir(path: Path) -> dict:
enclosing[func_nid] = parent_module_nid
if parent_module_nid:
add_edge(parent_module_nid, func_nid, "method", line)
module_defs.setdefault((parent_module_nid, func_name), func_nid)
else:
add_edge(file_nid, func_nid, "contains", line)
if do_block_node:
@@ -261,6 +315,8 @@ def extract_elixir(path: Path) -> dict:
# calls; alias/require do not.
if keyword in ("import", "use"):
call_scope.setdefault(parent_module_nid, []).append(module_name)
if keyword == "alias":
_record_aliases(arguments_node, parent_module_nid)
return
for child in node.children:
@@ -273,6 +329,9 @@ def extract_elixir(path: Path) -> dict:
normalised = n["label"].strip("()").lstrip(".")
label_to_nid[normalised] = n["id"]
module_nid_by_name = {name: nid for nid, name in module_names.items()}
_MODULE_REF = re.compile(r"(__MODULE__|[A-Z]\w*)(\.[A-Z]\w*)*")
seen_call_pairs: set[tuple[str, str]] = set()
raw_calls: list[dict] = []
_SKIP_KEYWORDS = frozenset({
@@ -304,12 +363,17 @@ def extract_elixir(path: Path) -> dict:
if child.type == "identifier":
kw = source[child.start_byte:child.end_byte].decode("utf-8", errors="replace")
if kw in _SKIP_KEYWORDS:
if kw == "alias": # a function-local alias
for c in node.children:
if c.type == "arguments":
_record_aliases(c, caller_nid)
for c in node.children:
walk_calls(c, caller_nid)
return
break
callee_name: str | None = None
is_member_call: bool = False
receiver_module: str | None = None
for child in node.children:
if child.type == "dot":
is_member_call = True
@@ -317,12 +381,26 @@ def extract_elixir(path: Path) -> dict:
parts = dot_text.rstrip(".").split(".")
if parts:
callee_name = parts[-1]
# `Mod.fun()` / `Alias.fun()` / `__MODULE__.fun()`: keep the
# module. A variable (`mod.fun()`) or atom receiver stays None.
if child.child_count == 3:
receiver = child.children[0]
receiver_text = source[receiver.start_byte:receiver.end_byte].decode(
"utf-8", errors="replace")
if _MODULE_REF.fullmatch(receiver_text):
receiver_module = _expand_module(receiver_text, caller_nid)
break
if child.type == "identifier":
callee_name = source[child.start_byte:child.end_byte].decode("utf-8", errors="replace")
break
if callee_name and callee_name not in _LANGUAGE_BUILTIN_GLOBALS:
tgt_nid = label_to_nid.get(callee_name)
if callee_name and (receiver_module or callee_name not in _LANGUAGE_BUILTIN_GLOBALS):
# A remote call only ever reaches the named module's own def: never
# the bare name, which bound `Repo.insert(x)` to a local insert/1.
if receiver_module is not None:
mod_nid = module_nid_by_name.get(receiver_module)
tgt_nid = module_defs.get((mod_nid, callee_name)) if mod_nid else None
else:
tgt_nid = None if is_member_call else label_to_nid.get(callee_name)
if tgt_nid and tgt_nid != caller_nid:
pair = (caller_nid, tgt_nid)
if pair not in seen_call_pairs:
@@ -338,6 +416,7 @@ def extract_elixir(path: Path) -> dict:
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
"elixir_call_scope": _call_scope_for(caller_nid),
**({"elixir_module": receiver_module} if receiver_module else {}),
})
for child in node.children:
walk_calls(child, caller_nid)
+279
View File
@@ -0,0 +1,279 @@
"""Qualified Elixir remote calls resolve to the named module (#4206).
`App.Accounts.get_user(id)` and `Accounts.get_user(id)` (after
`alias App.Accounts`) got no `calls` edge, because the extractor kept only the
function name. Worse, `App.Repo.insert(x)` inside a module with its own
`insert/1` was bound to that local function as EXTRACTED.
"""
from __future__ import annotations
import json
from pathlib import Path
from graphify.extract import extract
_ACCOUNTS = (
"defmodule App.Accounts do\n"
" def get_user(id) do\n"
" {:user, id}\n"
" end\n"
"end\n"
)
_REPO = (
"defmodule App.Repo do\n"
" def insert(changeset) do\n"
" {:ok, changeset}\n"
" end\n"
"end\n"
)
def _qualified(nodes: list[dict], edges: list[dict]) -> dict[tuple[str, str], str]:
"""`calls` edges as (Module.fun, Module.fun) -> confidence."""
name = {n["id"]: str(n["label"]).removesuffix("()") for n in nodes}
for e in edges:
if e["relation"] == "method" and e["source"] in name and e["target"] in name:
name[e["target"]] = f"{name[e['source']]}.{name[e['target']]}"
return {
(name[e["source"]], name[e["target"]]): e["confidence"]
for e in edges
if e["relation"] == "calls" and e["source"] in name and e["target"] in name
}
def _calls(tmp_path: Path, files: dict[str, str]) -> dict[tuple[str, str], str]:
paths = []
for rel, body in files.items():
path = tmp_path / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(body, encoding="utf-8")
paths.append(path)
result = extract(paths, cache_root=tmp_path / "graphify-out")
return _qualified(result["nodes"], result["edges"])
def _module(name: str, body: str) -> str:
return f"defmodule {name} do\n{body}end\n"
def test_fully_qualified_call(tmp_path: Path):
calls = _calls(tmp_path, {
"lib/app/accounts.ex": _ACCOUNTS,
"lib/app/web.ex": _module("App.Web", (
" def show(id) do\n"
" App.Accounts.get_user(id)\n"
" end\n"
)),
})
assert calls.get(("App.Web.show", "App.Accounts.get_user")) == "EXTRACTED"
def test_aliased_call(tmp_path: Path):
calls = _calls(tmp_path, {
"lib/app/accounts.ex": _ACCOUNTS,
"lib/app/web.ex": _module("App.Web", (
" alias App.Accounts\n"
"\n"
" def show(id) do\n"
" Accounts.get_user(id)\n"
" end\n"
)),
"lib/app/local.ex": _module("App.Local", (
" def show(id) do\n"
" alias App.Accounts\n"
" Accounts.get_user(id)\n"
" end\n"
)),
})
assert calls.get(("App.Web.show", "App.Accounts.get_user")) == "EXTRACTED"
assert calls.get(("App.Local.show", "App.Accounts.get_user")) == "EXTRACTED"
def test_alias_as(tmp_path: Path):
calls = _calls(tmp_path, {
"lib/app/accounts.ex": _ACCOUNTS,
"lib/app/web.ex": _module("App.Web", (
" alias App.Accounts, as: Acc\n"
"\n"
" def show(id) do\n"
" Acc.get_user(id)\n"
" end\n"
)),
})
assert calls.get(("App.Web.show", "App.Accounts.get_user")) == "EXTRACTED"
def test_multi_alias(tmp_path: Path):
calls = _calls(tmp_path, {
"lib/app/accounts.ex": _ACCOUNTS,
"lib/app/repo.ex": _REPO,
"lib/app/web.ex": _module("App.Web", (
" alias App.{Accounts, Repo}\n"
"\n"
" def show(id) do\n"
" Accounts.get_user(id)\n"
" end\n"
"\n"
" def save(x) do\n"
" Repo.insert(x)\n"
" end\n"
)),
})
assert calls.get(("App.Web.show", "App.Accounts.get_user")) == "EXTRACTED"
assert calls.get(("App.Web.save", "App.Repo.insert")) == "EXTRACTED"
def test_dunder_module(tmp_path: Path):
calls = _calls(tmp_path, {
"lib/app/web/helpers.ex": _module("App.Web.Helpers", (
" def fmt(x) do\n"
" x\n"
" end\n"
)),
"lib/app/web.ex": _module("App.Web", (
" alias __MODULE__.Helpers, as: H\n"
"\n"
" def show(x) do\n"
" __MODULE__.Helpers.fmt(x)\n"
" end\n"
"\n"
" def show_aliased(x) do\n"
" H.fmt(x)\n"
" end\n"
"\n"
" def again(x) do\n"
" __MODULE__.show(x)\n"
" end\n"
)),
})
assert calls.get(("App.Web.show", "App.Web.Helpers.fmt")) == "EXTRACTED"
assert calls.get(("App.Web.show_aliased", "App.Web.Helpers.fmt")) == "EXTRACTED"
assert calls.get(("App.Web.again", "App.Web.show")) == "EXTRACTED"
def test_nested_module_auto_alias(tmp_path: Path):
calls = _calls(tmp_path, {
"lib/app/web.ex": _module("App.Web", (
" defmodule Helpers do\n"
" def fmt(x) do\n"
" x\n"
" end\n"
" end\n"
"\n"
" def show(x) do\n"
" Helpers.fmt(x)\n"
" end\n"
)),
})
assert calls.get(("App.Web.show", "Helpers.fmt")) == "EXTRACTED"
def test_remote_call_does_not_bind_to_own_same_named_def(tmp_path: Path):
calls = _calls(tmp_path, {
"lib/app/repo.ex": _REPO,
"lib/app/admin.ex": _module("App.Admin", (
" def save(x) do\n"
" App.Repo.insert(x)\n"
" end\n"
"\n"
" def insert(x) do\n"
" x\n"
" end\n"
)),
})
assert ("App.Admin.save", "App.Admin.insert") not in calls
assert calls.get(("App.Admin.save", "App.Repo.insert")) == "EXTRACTED"
def test_external_module_emits_nothing(tmp_path: Path):
calls = _calls(tmp_path, {
"lib/app/repo.ex": _REPO,
"lib/app/admin.ex": _module("App.Admin", (
" alias MyDep.Repo\n"
"\n"
" def save(x) do\n"
" Repo.insert(x)\n"
" Enum.map(x, & &1)\n"
" Map.update(x, :k, 0, & &1)\n"
" end\n"
"\n"
" def insert(x), do: x\n"
" def map(x), do: x\n"
" def update(x), do: x\n"
)),
})
assert not [pair for pair in calls if pair[0] == "App.Admin.save"]
def test_variable_receiver_stays_unlinked(tmp_path: Path):
calls = _calls(tmp_path, {
"lib/app/repo.ex": _REPO,
"lib/app/admin.ex": _module("App.Admin", (
" def save(mod, conn) do\n"
" mod.insert(conn.assigns)\n"
" end\n"
"\n"
" def insert(x), do: x\n"
" def assigns(x), do: x\n"
)),
})
assert not [pair for pair in calls if pair[0] == "App.Admin.save"]
def test_import_and_local_calls_unchanged(tmp_path: Path):
calls = _calls(tmp_path, {
"lib/app/repo.ex": _REPO,
"lib/app/web.ex": _module("App.Web", (
" import App.Repo\n"
"\n"
" def create(attrs) do\n"
" insert(attrs)\n"
" end\n"
"\n"
" def create_local(attrs) do\n"
" build(attrs)\n"
" end\n"
"\n"
" defp build(attrs) do\n"
" attrs\n"
" end\n"
)),
})
assert calls.get(("App.Web.create", "App.Repo.insert")) == "INFERRED"
assert calls.get(("App.Web.create_local", "App.Web.build")) == "EXTRACTED"
def test_qualified_call_survives_incremental_rebuild(tmp_path: Path):
"""On `graphify update` the unchanged callee file arrives only as
resolution-context nodes and edges; the call must still resolve."""
from graphify.watch import _rebuild_code
corpus = tmp_path / "corpus"
corpus.mkdir()
(corpus / "accounts.ex").write_text(_ACCOUNTS, encoding="utf-8")
caller = corpus / "web.ex"
def _caller(extra: str = "") -> str:
return _module("App.Web", (
" alias App.Accounts\n"
"\n"
" def show(id) do\n"
" Accounts.get_user(id)\n"
" end\n"
f"{extra}"
))
caller.write_text(_caller(), encoding="utf-8")
graph_path = corpus / "graphify-out" / "graph.json"
def calls() -> dict[tuple[str, str], str]:
data = json.loads(graph_path.read_text(encoding="utf-8"))
return _qualified(data["nodes"], data["links"])
assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True
assert calls().get(("App.Web.show", "App.Accounts.get_user")) == "EXTRACTED"
caller.write_text(_caller("\n def other, do: :ok\n"), encoding="utf-8")
assert _rebuild_code(corpus, changed_paths=[caller], no_cluster=True,
acquire_lock=False) is True
assert calls().get(("App.Web.show", "App.Accounts.get_user")) == "EXTRACTED"