mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-15 16:39:43 +00:00
A module with no matching definition anywhere in the corpus, stdlib
or a hex dependency, must stay an external reference rather than
getting fabricated.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit da5e23a921)
157 lines
5.1 KiB
Python
157 lines
5.1 KiB
Python
"""Elixir cross-file alias/import/require/use resolution (#2556).
|
|
|
|
`extract_elixir` mints a module's own node id with the defining file's stem
|
|
(`_make_id(stem, module_name)`) but an alias/import/require/use target with
|
|
just the bare module name (`_make_id(module_name)`) -- the two can only
|
|
match when a module refers to itself, so a reference to a module declared
|
|
in ANY other file was silently dropped as dangling at build time. On a real
|
|
900-file Elixir/Phoenix project this discarded 13% of extracted edges --
|
|
the entire internal module dependency graph.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from graphify.extract import extract
|
|
|
|
|
|
def _extract(tmp_path: Path, files: dict[str, str]):
|
|
paths = []
|
|
for name, body in files.items():
|
|
path = tmp_path / name
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(body, encoding="utf-8")
|
|
paths.append(path)
|
|
return extract(paths, cache_root=tmp_path / "graphify-out")
|
|
|
|
|
|
def _find(result: dict, label: str, id_contains: str = "") -> str:
|
|
return next(
|
|
node["id"]
|
|
for node in result["nodes"]
|
|
if node.get("label") == label and id_contains in node["id"]
|
|
)
|
|
|
|
|
|
def _find_file(result: dict, filename: str) -> str:
|
|
"""An `imports` edge's source is the FILE node (extract_elixir emits it
|
|
at file scope), not the module node the file happens to declare."""
|
|
return next(
|
|
node["id"]
|
|
for node in result["nodes"]
|
|
if node.get("label") == filename
|
|
)
|
|
|
|
|
|
_REPRO_CORPUS = {
|
|
"lib/demo/accounts.ex": (
|
|
"defmodule Demo.Accounts do\n"
|
|
" def list_users, do: []\n"
|
|
"end\n"
|
|
),
|
|
"lib/demo/web.ex": (
|
|
"defmodule Demo.Web do\n"
|
|
" alias Demo.Accounts\n"
|
|
"\n"
|
|
" def index, do: Accounts.list_users()\n"
|
|
"end\n"
|
|
),
|
|
}
|
|
|
|
|
|
def test_alias_resolves_to_the_module_declared_in_another_file(tmp_path: Path):
|
|
result = _extract(tmp_path, _REPRO_CORPUS)
|
|
web_file = _find_file(result, "web.ex")
|
|
accounts = _find(result, "Demo.Accounts")
|
|
imports = {
|
|
(e["source"], e["target"])
|
|
for e in result["edges"]
|
|
if e["relation"] == "imports"
|
|
}
|
|
assert (web_file, accounts) in imports
|
|
node_ids = {n["id"] for n in result["nodes"]}
|
|
assert accounts in node_ids, "the import target must be a real, non-dangling node"
|
|
|
|
|
|
def test_same_file_module_reference_is_unaffected(tmp_path: Path):
|
|
"""Negative control: a module importing something declared in the SAME
|
|
file already resolved before this fix (both ids share the same stem)
|
|
and must keep working exactly as before."""
|
|
result = _extract(tmp_path, {
|
|
"lib/demo.ex": (
|
|
"defmodule Demo.Inner do\n"
|
|
" def go, do: 1\n"
|
|
"end\n"
|
|
"\n"
|
|
"defmodule Demo.Outer do\n"
|
|
" alias Demo.Inner\n"
|
|
"\n"
|
|
" def run, do: Inner.go()\n"
|
|
"end\n"
|
|
),
|
|
})
|
|
demo_file = _find_file(result, "demo.ex")
|
|
inner = _find(result, "Demo.Inner")
|
|
imports = {
|
|
(e["source"], e["target"])
|
|
for e in result["edges"]
|
|
if e["relation"] == "imports"
|
|
}
|
|
assert (demo_file, inner) in imports
|
|
|
|
|
|
def test_ambiguous_module_name_across_files_yields_no_resolution(tmp_path: Path):
|
|
"""Two DIFFERENT files each declare a module with the same bare name --
|
|
the exactly-one-candidate guard must leave the reference exactly as
|
|
extracted rather than guessing which one the caller meant."""
|
|
result = _extract(tmp_path, {
|
|
"a/dup.ex": (
|
|
"defmodule Demo.Dup do\n"
|
|
" def f, do: 1\n"
|
|
"end\n"
|
|
),
|
|
"b/dup.ex": (
|
|
"defmodule Demo.Dup do\n"
|
|
" def g, do: 2\n"
|
|
"end\n"
|
|
),
|
|
"caller.ex": (
|
|
"defmodule Demo.Caller do\n"
|
|
" alias Demo.Dup\n"
|
|
"\n"
|
|
" def run, do: Dup.f()\n"
|
|
"end\n"
|
|
),
|
|
})
|
|
caller_file = _find_file(result, "caller.ex")
|
|
node_ids = {n["id"] for n in result["nodes"]}
|
|
import_targets = {
|
|
e["target"] for e in result["edges"]
|
|
if e["relation"] == "imports" and e["source"] == caller_file
|
|
}
|
|
assert import_targets, "the alias edge must still exist"
|
|
assert not (import_targets & node_ids), \
|
|
"an ambiguous module name must not resolve to either same-named definition"
|
|
|
|
|
|
def test_genuinely_external_module_stays_unresolved(tmp_path: Path):
|
|
"""A module with no matching definition anywhere in the corpus (stdlib,
|
|
a hex dependency) must be left as an external reference, not fabricated."""
|
|
result = _extract(tmp_path, {
|
|
"caller.ex": (
|
|
"defmodule Demo.Caller do\n"
|
|
" alias Logger\n"
|
|
"\n"
|
|
" def run, do: Logger.info(\"hi\")\n"
|
|
"end\n"
|
|
),
|
|
})
|
|
caller_file = _find_file(result, "caller.ex")
|
|
node_ids = {n["id"] for n in result["nodes"]}
|
|
import_targets = {
|
|
e["target"] for e in result["edges"]
|
|
if e["relation"] == "imports" and e["source"] == caller_file
|
|
}
|
|
assert import_targets
|
|
assert not (import_targets & node_ids)
|