Skip to content

Commit b834ec8

Browse files
Mpasha17Grok Bot
andcommitted
fix(elixir): resolve qualified and aliased remote calls (#4206)
Co-Authored-By: Grok Bot <noreply@x.ai>
1 parent 6478eb7 commit b834ec8

3 files changed

Lines changed: 440 additions & 8 deletions

File tree

‎graphify/extract.py‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5772,6 +5772,73 @@ def _resolve_elixir_import_targets(
57725772
e["target"] = target_nid
57735773

57745774

5775+
def _resolve_elixir_qualified_calls(
5776+
per_file: list[dict],
5777+
all_nodes: list[dict],
5778+
all_edges: list[dict],
5779+
) -> None:
5780+
"""Resolve Elixir remote calls that name their module (#4206).
5781+
5782+
extract_elixir expands the receiver of `App.Accounts.get_user(id)` /
5783+
`Accounts.get_user(id)` (through the caller's `alias` table and
5784+
`__MODULE__`) and stamps it on the raw_call as ``elixir_module``; calls
5785+
into a module of the same file are linked there already. The shared pass
5786+
skips member calls, so this pass is strictly additive, like
5787+
`_resolve_csharp_qualified_calls`.
5788+
5789+
The module must match exactly one top-level module in the corpus and the
5790+
function must be one of its own defs. A module outside the corpus (Ecto's
5791+
`Repo`, `Enum`, `Map`) gets no edge rather than a same-named guess.
5792+
"""
5793+
raw = [
5794+
rc
5795+
for result in per_file
5796+
for rc in result.get("raw_calls", [])
5797+
if rc.get("elixir_module") and rc.get("callee") and rc.get("caller_nid")
5798+
]
5799+
if not raw:
5800+
return
5801+
5802+
module_nids: dict[str, list[str]] = {}
5803+
labels: dict[str, str] = {}
5804+
for n in all_nodes:
5805+
labels[n["id"]] = str(n.get("label") or "")
5806+
if n.get("_elixir_module") and n.get("source_file"):
5807+
module_nids.setdefault(labels[n["id"]], []).append(n["id"])
5808+
defs: dict[tuple[str, str], str] = {}
5809+
for e in all_edges:
5810+
if e.get("relation") == "method":
5811+
name = labels.get(e.get("target", ""), "")
5812+
if name.endswith("()"):
5813+
defs.setdefault((e["source"], name[:-2]), e["target"])
5814+
5815+
existing_pairs = {
5816+
(e.get("source"), e.get("target"))
5817+
for e in all_edges
5818+
if e.get("relation") == "calls"
5819+
}
5820+
for rc in raw:
5821+
candidates = module_nids.get(rc["elixir_module"], [])
5822+
if len(candidates) != 1:
5823+
continue
5824+
caller = rc["caller_nid"]
5825+
tgt = defs.get((candidates[0], rc["callee"]))
5826+
if tgt is None or tgt == caller or (caller, tgt) in existing_pairs:
5827+
continue
5828+
existing_pairs.add((caller, tgt))
5829+
all_edges.append({
5830+
"source": caller,
5831+
"target": tgt,
5832+
"relation": "calls",
5833+
"context": "call",
5834+
"confidence": "EXTRACTED", # the module is written in source
5835+
"confidence_score": 1.0,
5836+
"source_file": rc.get("source_file", ""),
5837+
"source_location": rc.get("source_location"),
5838+
"weight": 1.0,
5839+
})
5840+
5841+
57755842
def _resolve_kotlin_member_calls(
57765843
per_file: list[dict],
57775844
all_nodes: list[dict],
@@ -5942,6 +6009,13 @@ def _resolve_kotlin_member_calls(
59426009
_resolve_elixir_import_targets,
59436010
)
59446011
)
6012+
register_language_resolver(
6013+
LanguageResolver(
6014+
"elixir_qualified_calls",
6015+
frozenset({".ex", ".exs"}),
6016+
_resolve_elixir_qualified_calls,
6017+
)
6018+
)
59456019
# Pascal/Delphi cross-file inherited-method-call resolution: a call from a
59466020
# manual descendant class to a method it inherits from an ancestor declared
59476021
# in a DIFFERENT file (the common generated-base/manual-descendant split,

‎graphify/extractors/elixir.py‎

Lines changed: 87 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Elixir extractor. Moved verbatim from graphify/extract.py."""
22
from __future__ import annotations
33

4+
import re
45
from pathlib import Path
56
from typing import Any
67

@@ -61,6 +62,11 @@ def add_edge(src: str, tgt: str, relation: str, line: int,
6162
# is the file's top level), along with each module's and function's parent.
6263
call_scope: dict[str | None, list[str]] = {}
6364
enclosing: dict[str, str | None] = {}
65+
# For qualified calls (`Mod.fun()`, #4206): each module's full name, each
66+
# scope's `alias` table (same scoping as call_scope) and each module's defs.
67+
module_names: dict[str, str] = {}
68+
aliases: dict[str | None, dict[str, str]] = {}
69+
module_defs: dict[tuple[str, str], str] = {}
6470

6571
_IMPORT_KEYWORDS = frozenset({"alias", "import", "require", "use"})
6672

@@ -99,10 +105,11 @@ def _text(n) -> str:
99105
return [_text(child)]
100106
return []
101107

102-
def _get_defimpl_target(node) -> str | None:
103-
"""The `for:` target module of a `defimpl Proto, for: Type` argument
104-
list. The grammar holds it in a trailing `keywords` node as a `pair`
105-
whose keyword is `for:` and whose value is an `alias`."""
108+
def _get_keyword_alias(node, key: str) -> str | None:
109+
"""The module given for keyword ``key`` in an argument list, e.g. the
110+
`for:` of `defimpl Proto, for: Type` or the `as:` of `alias A.B, as: C`.
111+
The grammar holds it in a trailing `keywords` node as a `pair` whose
112+
keyword is `key:` and whose value is an `alias`."""
106113
for child in node.children:
107114
if child.type != "keywords":
108115
continue
@@ -116,10 +123,53 @@ def _get_defimpl_target(node) -> str | None:
116123
kw = source[sub.start_byte:sub.end_byte].decode("utf-8", errors="replace")
117124
elif sub.type == "alias":
118125
val = source[sub.start_byte:sub.end_byte].decode("utf-8", errors="replace")
119-
if kw and kw.rstrip(": ").strip() == "for" and val:
126+
if kw and kw.rstrip(": ").strip() == key and val:
120127
return val
121128
return None
122129

130+
def _expand_module(name: str, scope: str | None) -> str:
131+
"""Expand the first segment of a module reference through the `alias`
132+
tables visible from ``scope`` (or `__MODULE__` to the current module)."""
133+
first, _, rest = name.partition(".")
134+
target = None
135+
if first == "__MODULE__":
136+
# ``scope`` is a module, or a function directly inside one.
137+
if scope is not None and scope not in module_names:
138+
scope = enclosing.get(scope)
139+
target = (module_names.get(scope) if scope is not None else None) or None
140+
else:
141+
seen: set[str | None] = set()
142+
container = scope
143+
while container not in seen:
144+
seen.add(container)
145+
target = aliases.get(container, {}).get(first)
146+
if target is not None or container is None:
147+
break
148+
container = enclosing.get(container)
149+
if target is None:
150+
return name
151+
return f"{target}.{rest}" if rest else target
152+
153+
def _record_aliases(arguments_node, scope: str | None) -> None:
154+
modules = [_expand_module(m, scope) for m in _get_alias_modules(arguments_node)]
155+
as_name = _get_keyword_alias(arguments_node, "as")
156+
table = aliases.setdefault(scope, {})
157+
if as_name and len(modules) == 1:
158+
table[as_name] = modules[0]
159+
elif not as_name:
160+
for module in modules:
161+
table[module.rsplit(".", 1)[-1]] = module
162+
163+
def _register_module(nid: str, name: str, parent: str | None) -> None:
164+
# A nested `defmodule Inner` is `Outer.Inner`, and `Inner` is
165+
# auto-aliased inside Outer.
166+
parent_name = module_names.get(parent) if parent is not None else None
167+
if parent_name:
168+
first = name.split(".", 1)[0]
169+
aliases.setdefault(parent, {})[first] = f"{parent_name}.{first}"
170+
name = f"{parent_name}.{name}"
171+
module_names[nid] = name
172+
123173
def walk(node, parent_module_nid: str | None = None) -> None:
124174
if node.type != "call":
125175
for child in node.children:
@@ -160,6 +210,7 @@ def walk(node, parent_module_nid: str | None = None) -> None:
160210
**({"_elixir_module": True} if parent_module_nid is None else {}))
161211
add_edge(file_nid, module_nid, "contains", line)
162212
enclosing[module_nid] = parent_module_nid
213+
_register_module(module_nid, module_name, parent_module_nid)
163214
if do_block_node:
164215
for child in do_block_node.children:
165216
walk(child, parent_module_nid=module_nid)
@@ -179,6 +230,7 @@ def walk(node, parent_module_nid: str | None = None) -> None:
179230
**({"_elixir_module": True} if parent_module_nid is None else {}))
180231
add_edge(parent_module_nid or file_nid, proto_nid, "contains", line)
181232
enclosing[proto_nid] = parent_module_nid
233+
_register_module(proto_nid, proto_name, parent_module_nid)
182234
if do_block_node:
183235
for child in do_block_node.children:
184236
walk(child, parent_module_nid=proto_nid)
@@ -190,12 +242,13 @@ def walk(node, parent_module_nid: str | None = None) -> None:
190242
proto_name = _get_alias_text(arguments_node) if arguments_node else None
191243
if not proto_name:
192244
return
193-
target = _get_defimpl_target(arguments_node)
245+
target = _get_keyword_alias(arguments_node, "for")
194246
impl_nid = _make_id(stem, "defimpl", proto_name, target or "")
195247
label = f"{proto_name} (for {target})" if target else proto_name
196248
add_node(impl_nid, label, line)
197249
add_edge(parent_module_nid or file_nid, impl_nid, "contains", line)
198250
enclosing[impl_nid] = parent_module_nid
251+
module_names[impl_nid] = f"{proto_name}.{target}" if target else ""
199252
# Link the implementation to the protocol it satisfies. A same-file
200253
# protocol resolves directly; a cross-file target is filtered out by
201254
# the dangling-edge guard below rather than left hanging.
@@ -247,6 +300,7 @@ def walk(node, parent_module_nid: str | None = None) -> None:
247300
enclosing[func_nid] = parent_module_nid
248301
if parent_module_nid:
249302
add_edge(parent_module_nid, func_nid, "method", line)
303+
module_defs.setdefault((parent_module_nid, func_name), func_nid)
250304
else:
251305
add_edge(file_nid, func_nid, "contains", line)
252306
if do_block_node:
@@ -261,6 +315,8 @@ def walk(node, parent_module_nid: str | None = None) -> None:
261315
# calls; alias/require do not.
262316
if keyword in ("import", "use"):
263317
call_scope.setdefault(parent_module_nid, []).append(module_name)
318+
if keyword == "alias":
319+
_record_aliases(arguments_node, parent_module_nid)
264320
return
265321

266322
for child in node.children:
@@ -273,6 +329,9 @@ def walk(node, parent_module_nid: str | None = None) -> None:
273329
normalised = n["label"].strip("()").lstrip(".")
274330
label_to_nid[normalised] = n["id"]
275331

332+
module_nid_by_name = {name: nid for nid, name in module_names.items()}
333+
_MODULE_REF = re.compile(r"(__MODULE__|[A-Z]\w*)(\.[A-Z]\w*)*")
334+
276335
seen_call_pairs: set[tuple[str, str]] = set()
277336
raw_calls: list[dict] = []
278337
_SKIP_KEYWORDS = frozenset({
@@ -304,25 +363,44 @@ def walk_calls(node, caller_nid: str) -> None:
304363
if child.type == "identifier":
305364
kw = source[child.start_byte:child.end_byte].decode("utf-8", errors="replace")
306365
if kw in _SKIP_KEYWORDS:
366+
if kw == "alias": # a function-local alias
367+
for c in node.children:
368+
if c.type == "arguments":
369+
_record_aliases(c, caller_nid)
307370
for c in node.children:
308371
walk_calls(c, caller_nid)
309372
return
310373
break
311374
callee_name: str | None = None
312375
is_member_call: bool = False
376+
receiver_module: str | None = None
313377
for child in node.children:
314378
if child.type == "dot":
315379
is_member_call = True
316380
dot_text = source[child.start_byte:child.end_byte].decode("utf-8", errors="replace")
317381
parts = dot_text.rstrip(".").split(".")
318382
if parts:
319383
callee_name = parts[-1]
384+
# `Mod.fun()` / `Alias.fun()` / `__MODULE__.fun()`: keep the
385+
# module. A variable (`mod.fun()`) or atom receiver stays None.
386+
if child.child_count == 3:
387+
receiver = child.children[0]
388+
receiver_text = source[receiver.start_byte:receiver.end_byte].decode(
389+
"utf-8", errors="replace")
390+
if _MODULE_REF.fullmatch(receiver_text):
391+
receiver_module = _expand_module(receiver_text, caller_nid)
320392
break
321393
if child.type == "identifier":
322394
callee_name = source[child.start_byte:child.end_byte].decode("utf-8", errors="replace")
323395
break
324-
if callee_name and callee_name not in _LANGUAGE_BUILTIN_GLOBALS:
325-
tgt_nid = label_to_nid.get(callee_name)
396+
if callee_name and (receiver_module or callee_name not in _LANGUAGE_BUILTIN_GLOBALS):
397+
# A remote call only ever reaches the named module's own def: never
398+
# the bare name, which bound `Repo.insert(x)` to a local insert/1.
399+
if receiver_module is not None:
400+
mod_nid = module_nid_by_name.get(receiver_module)
401+
tgt_nid = module_defs.get((mod_nid, callee_name)) if mod_nid else None
402+
else:
403+
tgt_nid = None if is_member_call else label_to_nid.get(callee_name)
326404
if tgt_nid and tgt_nid != caller_nid:
327405
pair = (caller_nid, tgt_nid)
328406
if pair not in seen_call_pairs:
@@ -338,6 +416,7 @@ def walk_calls(node, caller_nid: str) -> None:
338416
"source_file": str_path,
339417
"source_location": f"L{node.start_point[0] + 1}",
340418
"elixir_call_scope": _call_scope_for(caller_nid),
419+
**({"elixir_module": receiver_module} if receiver_module else {}),
341420
})
342421
for child in node.children:
343422
walk_calls(child, caller_nid)

0 commit comments

Comments
 (0)