mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 07:29:33 +00:00
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:
1 parent
ab3c3553cc
commit
e63386fc0c
3 files changed
+440
-8
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user