From e63386fc0ce3adf4798da69c6ec097b4ed71f830 Mon Sep 17 00:00:00 2001 From: Mahaboob pasha Date: Thu, 8 Oct 2026 09:03:08 +0530 Subject: [PATCH] fix(elixir): resolve qualified and aliased remote calls (#4206) Co-Authored-By: Grok Bot (cherry picked from commit b834ec821fc41b564e49ee208798aea08cae4afb) --- graphify/extract.py | 74 +++++++ graphify/extractors/elixir.py | 95 ++++++++- tests/test_elixir_qualified_calls.py | 279 +++++++++++++++++++++++++++ 3 files changed, 440 insertions(+), 8 deletions(-) create mode 100644 tests/test_elixir_qualified_calls.py diff --git a/graphify/extract.py b/graphify/extract.py index 70c9a739c..2331031f6 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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, diff --git a/graphify/extractors/elixir.py b/graphify/extractors/elixir.py index 85bf867c0..a90dcde47 100644 --- a/graphify/extractors/elixir.py +++ b/graphify/extractors/elixir.py @@ -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) diff --git a/tests/test_elixir_qualified_calls.py b/tests/test_elixir_qualified_calls.py new file mode 100644 index 000000000..efa5088c2 --- /dev/null +++ b/tests/test_elixir_qualified_calls.py @@ -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"