Skip to content

Commit e508142

Browse files
fix(elixir): scope import/use to the declaring module, not the file
The unqualified-call scope added for #4001 collected every `import` and `use` in a file into one list, so an import in one module leaked into a sibling module defined in the same file: `MyApp.Sibling.go/1` calling `fmt(x)` still bound to `MyApp.Helpers.fmt/1` because `MyApp.Importer` imported it. Elixir scopes `import`/`use` to the module body it appears in and to the modules nested in it. Record targets per enclosing module, with the file's top level as its own scope, and build each call's scope from its own module, the modules enclosing it, and the top level. Each raw call now gets its own list rather than sharing one mutable list with every other call in the file. A module nested in a same-named module shares its node id, so the walk up the enclosing chain stops at a module it has already visited. Follow-up to #4015. Refs #4001
1 parent 48d7c0e commit e508142

3 files changed

Lines changed: 59 additions & 5 deletions

File tree

‎graphify/extract.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8544,8 +8544,9 @@ def _looks_like_bash(result: object) -> bool:
85448544
continue
85458545
go_exact_import = True
85468546
# An unqualified Elixir call can only reach the caller's own module
8547-
# (resolved in-file by the extractor), Kernel, or a module the file
8548-
# `import`s or `use`s. A same-named def in any other module is out of
8547+
# (resolved in-file by the extractor), Kernel, or a module imported or
8548+
# used in the caller's lexical scope (the extractor records those as
8549+
# `elixir_call_scope`). A same-named def in any other module is out of
85498550
# scope: binding to it by name landed every migration's `table(:users)`
85508551
# (Ecto.Migration, pulled in by `use`) on an unrelated Phoenix
85518552
# component's `table/1` and made it the top god node (#4001).

‎graphify/extractors/elixir.py‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,11 @@ def add_edge(src: str, tgt: str, relation: str, line: int,
5151

5252
file_nid = _make_id(str(path))
5353
add_node(file_nid, path.name, 1)
54-
call_scope_modules: list[str] = []
54+
# Elixir scopes `import`/`use` to the module body it appears in and to the
55+
# modules nested in it, so targets are recorded per enclosing module (None
56+
# is the file's top level), along with each module's and function's parent.
57+
call_scope: dict[str | None, list[str]] = {}
58+
enclosing: dict[str, str | None] = {}
5559

5660
_IMPORT_KEYWORDS = frozenset({"alias", "import", "require", "use"})
5761

@@ -150,6 +154,7 @@ def walk(node, parent_module_nid: str | None = None) -> None:
150154
add_node(module_nid, module_name, line,
151155
**({"_elixir_module": True} if parent_module_nid is None else {}))
152156
add_edge(file_nid, module_nid, "contains", line)
157+
enclosing[module_nid] = parent_module_nid
153158
if do_block_node:
154159
for child in do_block_node.children:
155160
walk(child, parent_module_nid=module_nid)
@@ -168,6 +173,7 @@ def walk(node, parent_module_nid: str | None = None) -> None:
168173
add_node(proto_nid, proto_name, line,
169174
**({"_elixir_module": True} if parent_module_nid is None else {}))
170175
add_edge(parent_module_nid or file_nid, proto_nid, "contains", line)
176+
enclosing[proto_nid] = parent_module_nid
171177
if do_block_node:
172178
for child in do_block_node.children:
173179
walk(child, parent_module_nid=proto_nid)
@@ -184,6 +190,7 @@ def walk(node, parent_module_nid: str | None = None) -> None:
184190
label = f"{proto_name} (for {target})" if target else proto_name
185191
add_node(impl_nid, label, line)
186192
add_edge(parent_module_nid or file_nid, impl_nid, "contains", line)
193+
enclosing[impl_nid] = parent_module_nid
187194
# Link the implementation to the protocol it satisfies. A same-file
188195
# protocol resolves directly; a cross-file target is filtered out by
189196
# the dangling-edge guard below rather than left hanging.
@@ -232,6 +239,7 @@ def walk(node, parent_module_nid: str | None = None) -> None:
232239
container = parent_module_nid or file_nid
233240
func_nid = _make_id(container, func_name)
234241
add_node(func_nid, f"{func_name}()", line)
242+
enclosing[func_nid] = parent_module_nid
235243
if parent_module_nid:
236244
add_edge(parent_module_nid, func_nid, "method", line)
237245
else:
@@ -247,7 +255,7 @@ def walk(node, parent_module_nid: str | None = None) -> None:
247255
# Only import/use bring functions into scope for unqualified
248256
# calls; alias/require do not.
249257
if keyword in ("import", "use"):
250-
call_scope_modules.append(module_name)
258+
call_scope.setdefault(parent_module_nid, []).append(module_name)
251259
return
252260

253261
for child in node.children:
@@ -269,6 +277,19 @@ def walk(node, parent_module_nid: str | None = None) -> None:
269277
"if", "unless", "case", "cond", "with", "for",
270278
})
271279

280+
def _call_scope_for(caller_nid: str) -> list[str]:
281+
"""import/use targets visible to a call inside ``caller_nid``: its own
282+
module's, each enclosing module's, then the file's top-level ones."""
283+
scope: list[str] = []
284+
seen: set[str] = set() # a module nested in a same-named one shares its nid
285+
container = enclosing.get(caller_nid)
286+
while container is not None and container not in seen:
287+
seen.add(container)
288+
scope.extend(call_scope.get(container, ()))
289+
container = enclosing.get(container)
290+
scope.extend(call_scope.get(None, ()))
291+
return scope
292+
272293
def walk_calls(node, caller_nid: str) -> None:
273294
if node.type != "call":
274295
for child in node.children:
@@ -311,7 +332,7 @@ def walk_calls(node, caller_nid: str) -> None:
311332
"is_member_call": is_member_call,
312333
"source_file": str_path,
313334
"source_location": f"L{node.start_point[0] + 1}",
314-
"elixir_call_scope": call_scope_modules,
335+
"elixir_call_scope": _call_scope_for(caller_nid),
315336
})
316337
for child in node.children:
317338
walk_calls(child, caller_nid)

‎tests/test_elixir_unqualified_call_scope.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,38 @@ def test_imported_and_used_module_calls_still_resolve(tmp_path: Path):
107107
assert ("opts()", "timestamps_opts()") in calls
108108

109109

110+
def test_import_is_scoped_to_the_module_that_declares_it(tmp_path: Path):
111+
"""An `import` applies to the module body it appears in and to modules
112+
nested in it, not to a sibling module that happens to share the file."""
113+
calls = _cross_file_calls(tmp_path, {
114+
"lib/my_app/helpers.ex": _HELPERS,
115+
"lib/my_app/pair.ex": (
116+
"defmodule MyApp.Importer do\n"
117+
" import MyApp.Helpers\n"
118+
"\n"
119+
" def show(x) do\n"
120+
" fmt(x)\n"
121+
" end\n"
122+
"\n"
123+
" defmodule Nested do\n"
124+
" def inner(x) do\n"
125+
" fmt(x)\n"
126+
" end\n"
127+
" end\n"
128+
"end\n"
129+
"\n"
130+
"defmodule MyApp.Sibling do\n"
131+
" def go(x) do\n"
132+
" fmt(x)\n"
133+
" end\n"
134+
"end\n"
135+
),
136+
})
137+
assert ("show()", "fmt()") in calls
138+
assert ("inner()", "fmt()") in calls
139+
assert ("go()", "fmt()") not in calls
140+
141+
110142
def test_imported_call_survives_incremental_rebuild(tmp_path: Path):
111143
"""On `graphify update` the unchanged helpers file arrives only as
112144
resolution-context nodes; its module must still count as in scope."""

0 commit comments

Comments
 (0)