diff --git a/CHANGELOG.md b/CHANGELOG.md index cb65991657..b0a3efb286 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.61 (unreleased) +- Fix: Elixir `alias`/`import`/`require`/`use` edges now land on the `defmodule` node they name instead of being pruned as dangling. Module definitions are keyed `_make_id(file_stem, name)` while references were keyed `_make_id(name)`, so the two could never match and build dropped every Elixir alias/import edge, leaving the graph almost entirely intra-file. The extractor now stamps the written module name on the edge and a corpus-wide resolver repoints it onto the single module with that exact name; ambiguous names and genuine externals (`Ecto.Query`, `Logger`) are left alone rather than guessed at, exactly as in the Kotlin fix (#2526) this mirrors. Resolution runs against the unchanged-corpus context too, so an alias into a file you did not touch still resolves on an incremental/`watch` rebuild, and a self-import (`__using__`/`quote`) is left alone so it cannot overwrite the file's `contains` edge. `_AST_CACHE_SCHEMA` is bumped to 3: the extractor's output changed, so a cache written by an earlier build would otherwise keep serving pre-fix results and make the fix silently inert (#3562). - Fix: `graphify.serve` now imports cleanly on Python 3.12 and 3.13. The `chinese` extra pins `jieba-py` from 3.12 onward (0.9.60 mistakenly kept the old `jieba` until 3.14, and its invalid regex escapes are a hard error on 3.12+), and the jieba import now suppresses the tokenizer's `SyntaxWarning` regardless of message or line so it never escalates under `-W error`. - Fix: the git hook's rebuild-root guard now rejects a symlink-loop or dangling `.graphify_root` on Python 3.13, whose `Path.resolve()` no longer raises on a loop — the saved root must resolve to a real directory inside the repo before it is adopted. diff --git a/graphify/cache.py b/graphify/cache.py index a70cff03dc..bd76824617 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -36,7 +36,7 @@ _EXTRACTOR_VERSION = "unknown" # Bump when AST cache-key semantics change independently of the package version. -_AST_CACHE_SCHEMA = 2 +_AST_CACHE_SCHEMA = 3 # Version dirs already swept this process — cleanup runs once per (base, version). _cleaned_ast_dirs: set[str] = set() diff --git a/graphify/extract.py b/graphify/extract.py index 90a6ab0267..519198938f 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4530,6 +4530,87 @@ def _resolve_kotlin_import_targets( e["target"] = candidates[0] +_ELIXIR_MODULE_LABEL_RE = re.compile(r"^[A-Z][A-Za-z0-9_]*(?:\.[A-Z][A-Za-z0-9_]*)*$") + + +def _resolve_elixir_import_targets( + per_file: list[dict], + all_nodes: list[dict], + all_edges: list[dict], +) -> None: + """Rewrite Elixir ``imports`` edge targets from the bare module name to the + ``defmodule`` node that name actually defines (#3562). + + ``extract_elixir`` mints module DEFINITIONS as ``_make_id(file_stem, name)`` + (the corpus-wide invariant documented on ``_file_stem`` — same-named symbols + in different files must not collide) but emits + ``file --imports--> _make_id(module_name)`` for every + ``alias``/``import``/``require``/``use``, with the written name stamped as + ``metadata.target_fqn``. The two ids can never be equal, so build's + ``src not in node_set or tgt not in node_set`` prune dropped EVERY Elixir + alias/import edge — the same failure Kotlin had in #2526, and the reason + ``extract_elixir`` deliberately exempts ``imports`` from its own dangling-edge + filter: it assumes a downstream pass will resolve them. + + An Elixir alias names a BEAM module, not a path, so the per-file extractor + cannot resolve it — and guessing a file from ``Macro.underscore`` conventions + would break on umbrella apps, ``:elixirc_paths`` config, acronym modules and + multi-module files. Hence a corpus-wide pass here instead, matching the + written name against module labels EXACTLY (the label is the alias text as + written, so no normalisation is involved and confidence stays EXTRACTED). A + name defined exactly ONCE in the corpus is rewritten to that node id; zero or + several candidates leave the edge untouched, dangling like other languages' + external imports (``Ecto.Query``, ``Logger``, any dependency), so no stub node + is ever fabricated. + + Must run BEFORE the shared call pass builds its import-evidence index, for the + same reason the Kotlin pass does — see the call site in ``extract()``. + """ + # label -> [module node ids]. Restricted to nodes that came from an Elixir + # source file and whose label has the shape of a module alias: extract_elixir + # emits exactly three node kinds, and neither file nodes (basename, e.g. + # `user.ex`) nor function nodes (`create()`) can match this pattern. + modules_by_name: dict[str, list[tuple[str, str]]] = {} + for n in all_nodes: + # `_elixir_module` is stamped by extract_elixir on TOP-LEVEL defmodule + # nodes only. Gating on it (rather than on a label regex) excludes + # nested modules — whose label is the written inner name, not their real + # BEAM name, so they would capture aliases meant for a different module — + # and also excludes any same-labelled node a semantic/LLM tier might add, + # which would otherwise make the name ambiguous and suppress resolution. + if not n.get("_elixir_module"): + continue + label = str(n.get("label") or "") + if not _ELIXIR_MODULE_LABEL_RE.match(label): + continue + modules_by_name.setdefault(label, []).append( + (n["id"], str(n.get("source_file") or "")) + ) + if not modules_by_name: + return + for e in all_edges: + if e.get("relation") != "imports": + continue + if not str(e.get("source_file") or "").endswith((".ex", ".exs")): + continue + fqn = str((e.get("metadata") or {}).get("target_fqn") or "") + if not fqn: + continue + candidates = modules_by_name.get(fqn, []) + if len(candidates) != 1: # single-candidate guard: never fabricate + continue + target_id, target_src = candidates[0] + # Self-import: `defmodule MyApp.Context` whose own `__using__`/`quote` + # body does `import MyApp.Context`. Retargeting would land a second + # edge on the `file --contains--> module` pair; the graph is a plain + # (non-multi) Graph, so one relation would silently overwrite the + # other and destroy the `contains` edge. A self-import carries no + # cross-file information, so leave it dangling as before. + if target_src and target_src == str(e.get("source_file") or ""): + continue + e["target"] = target_id + + def _resolve_csharp_qualified_calls( per_file: list[dict], all_nodes: list[dict], @@ -4723,6 +4804,14 @@ def _resolve_kotlin_qualified_calls( "kotlin_import_targets", frozenset({".kt", ".kts"}), _resolve_kotlin_import_targets ) +# Elixir import-target resolution (#3562) — same shape, same reason, same early +# slot as the Kotlin pass above: alias/import targets are name-derived and must be +# repointed onto the real `defmodule` node before import-evidence promotion reads +# the edges. +_ELIXIR_IMPORT_TARGET_RESOLVER = LanguageResolver( + "elixir_import_targets", frozenset({".ex", ".exs"}), _resolve_elixir_import_targets +) + # Register the cross-file, language-specific member-call resolvers into the shared # registry (framework lives in graphify.resolver_registry). A new language plugs in @@ -7276,15 +7365,23 @@ def _looks_like_bash(result: object) -> bool: # them from the indirect_call guard below to avoid false edges (#2137). class_nids = {n["id"] for n in resolution_nodes if n.get("_callable_class")} - # Kotlin import targets (#2526): rewrite each `imports` edge from the bare - # last-segment id to the node its written FQN names, via the per-file - # package declarations. Runs HERE — after the id-remap/disambiguation passes - # (ids are final) but before the import-evidence index just below reads the - # edges — so genuine imported calls get promoted INFERRED -> EXTRACTED. The - # tail registry run (run_language_resolvers below) would be too late. + # Kotlin (#2526) and Elixir (#3562) import targets: rewrite each `imports` + # edge from its name-derived id to the node its written FQN names. Runs HERE — + # after the id-remap/disambiguation passes (ids are final) but before the + # import-evidence index just below reads the edges — so genuine imported calls + # get promoted INFERRED -> EXTRACTED. The tail registry run + # (run_language_resolvers below) would be too late. + # `resolution_nodes`, NOT `all_nodes` (#2406): on an incremental rebuild + # `all_nodes` holds only the re-extracted batch, so every alias pointing into + # an UNCHANGED file would fail to resolve and be pruned — the graph would + # decay back toward the bug on exactly the `graphify watch` / hook path. It + # would also bypass the single-candidate guard, since a name that is + # ambiguous corpus-wide can look unique within the changed subset and get an + # arbitrary, fabricated target. Inert for Kotlin, which indexes off + # `per_file`. run_language_resolvers( - paths, per_file, all_nodes, all_edges, - resolvers=[_KOTLIN_IMPORT_TARGET_RESOLVER], + paths, per_file, resolution_nodes, all_edges, + resolvers=[_KOTLIN_IMPORT_TARGET_RESOLVER, _ELIXIR_IMPORT_TARGET_RESOLVER], ) # Build evidence index from import edges so cross-file calls backed by an diff --git a/graphify/extractors/elixir.py b/graphify/extractors/elixir.py index 0c26d07164..affb1a87a7 100644 --- a/graphify/extractors/elixir.py +++ b/graphify/extractors/elixir.py @@ -5,6 +5,7 @@ from typing import Any from graphify.extractors.base import _LANGUAGE_BUILTIN_GLOBALS, _file_stem, _make_id +from graphify.security import sanitize_metadata def extract_elixir(path: Path) -> dict: @@ -30,6 +31,7 @@ def extract_elixir(path: Path) -> dict: edges: list[dict] = [] seen_ids: set[str] = set() function_bodies: list[tuple[str, Any]] = [] + module_def_nids: set[str] = set() def add_node(nid: str, label: str, line: int) -> None: if nid not in seen_ids: @@ -39,12 +41,15 @@ def add_node(nid: str, label: str, line: int) -> None: def add_edge(src: str, tgt: str, relation: str, line: int, confidence: str = "EXTRACTED", weight: float = 1.0, - context: str | None = None) -> None: + context: str | None = None, + metadata: dict | None = None) -> None: edge = {"source": src, "target": tgt, "relation": relation, "confidence": confidence, "source_file": str_path, "source_location": f"L{line}", "weight": weight} if context: edge["context"] = context + if metadata: + edge["metadata"] = sanitize_metadata(metadata) edges.append(edge) file_nid = _make_id(str(path)) @@ -118,6 +123,16 @@ def walk(node, parent_module_nid: str | None = None) -> None: return module_nid = _make_id(stem, module_name) add_node(module_nid, module_name, line) + # Only TOP-LEVEL modules are indexable by the corpus-wide resolver. + # A nested `defmodule Supervisor` inside `MyApp.Application` is + # labelled with the written inner name, not its real BEAM name + # (`MyApp.Application.Supervisor`), so indexing it would let it + # capture `use Supervisor` from unrelated files and assert a false + # EXTRACTED edge. Marker rides on the node dict so it survives the + # id-remap/disambiguation passes (same rationale as `_callable`, + # #1566); export strips `_`-prefixed attrs. + if parent_module_nid is None: + module_def_nids.add(module_nid) add_edge(file_nid, module_nid, "contains", line) if do_block_node: for child in do_block_node.children: @@ -164,8 +179,17 @@ def walk(node, parent_module_nid: str | None = None) -> None: if keyword in _IMPORT_KEYWORDS and arguments_node: for module_name in _get_alias_modules(arguments_node): + # An Elixir `alias`/`import`/`require`/`use` names a BEAM module, + # not a path, so a per-file extractor cannot know which file + # defines it. Target stays the name-derived id for now; + # `_resolve_elixir_import_targets` rewrites it to the real + # `defmodule` node id via the `target_fqn` stamped here, once the + # corpus-wide module index exists (#3562). Targets that resolve + # to nothing (Ecto, Logger, any dep) stay dangling like other + # languages' external imports and are pruned by build. tgt_nid = _make_id(module_name) - add_edge(file_nid, tgt_nid, "imports", line, context="import") + add_edge(file_nid, tgt_nid, "imports", line, context="import", + metadata={"target_fqn": module_name}) return for child in node.children: @@ -173,6 +197,10 @@ def walk(node, parent_module_nid: str | None = None) -> None: walk(root) + for n in nodes: + if n["id"] in module_def_nids: + n["_elixir_module"] = True + label_to_nid: dict[str, str] = {} for n in nodes: normalised = n["label"].strip("()").lstrip(".") diff --git a/tests/test_elixir_import_ids.py b/tests/test_elixir_import_ids.py new file mode 100644 index 0000000000..d1641db8cd --- /dev/null +++ b/tests/test_elixir_import_ids.py @@ -0,0 +1,365 @@ +"""Regression tests for #3562: Elixir alias/import edges must land on the +``defmodule`` node they name, instead of dangling on a name-derived id. + +``extract_elixir`` mints a module DEFINITION as ``_make_id(_file_stem(path), +module_name)`` — the corpus-wide invariant documented on ``_file_stem`` (#1504), +which keeps same-named symbols in different files from collapsing into one +last-writer-wins node. But it emitted the REFERENCE side of an +``alias``/``import``/``require``/``use`` as ``_make_id(module_name)``, with no +stem. So defining ``MyApp.Repo`` in ``lib/my_app/repo.ex`` produced the node id +``lib_my_app_repo_myapp_repo`` while ``alias MyApp.Repo`` produced the edge +target ``myapp_repo``. Those can never be equal, so ``build_from_json``'s +``if src not in node_set or tgt not in node_set: continue`` prune dropped EVERY +Elixir alias/import edge — silently: extraction reported success, both nodes +existed, only the edge was missing, leaving the graph almost entirely +intra-file. (``extract_elixir``'s own ``clean_edges`` filter deliberately +exempts ``imports``, i.e. it already assumed a downstream pass would resolve +them. Nothing did.) + +An Elixir alias names a BEAM module, not a path, so the per-file extractor +cannot resolve it — the same shape as Kotlin #2526. Fixed the same way: the +extractor stamps the written name as ``metadata.target_fqn`` and +``_resolve_elixir_import_targets`` (run from ``extract()``, before the +import-evidence index is built) repoints the edge onto the one module node with +that exact label. Zero or several candidates leave the edge alone — externals +(``Ecto.Query``, ``Logger``) keep dangling and are pruned by build, and NO stub +node is ever fabricated for them. +""" +from __future__ import annotations + +import os +from pathlib import Path + +from graphify.build import build_from_json +from graphify.extract import extract + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _elixir_project(tmp_path: Path) -> tuple[Path, list[Path]]: + """A miniature Phoenix-shaped app: aliases, multi-alias, use, and externals. + + realpath: on macOS pytest's tmp dir lives under /private/var but is handed + out as /var — extract() resolves paths, so anchor on the resolved form. + """ + root = Path(os.path.realpath(tmp_path)) + paths = [ + _write( + root / "lib/my_app/accounts/user.ex", + """defmodule MyApp.Accounts.User do + alias MyApp.Repo + alias MyApp.Schemas.{Account, Token} + use MyAppWeb + import Ecto.Query + require Logger + + def create(attrs) do + Repo.insert(attrs) + end +end +""", + ), + _write( + root / "lib/my_app/repo.ex", + "defmodule MyApp.Repo do\n def insert(x), do: x\nend\n", + ), + _write( + root / "lib/my_app/schemas/account.ex", + "defmodule MyApp.Schemas.Account do\n def new, do: %{}\nend\n", + ), + _write( + root / "lib/my_app/schemas/token.ex", + "defmodule MyApp.Schemas.Token do\n def new, do: %{}\nend\n", + ), + _write( + root / "lib/my_app_web.ex", + "defmodule MyAppWeb do\n def router, do: :ok\nend\n", + ), + ] + return root, paths + + +# Modules the fixture corpus actually defines, and the external ones it names +# but does not define. +_IN_CORPUS = { + "MyApp.Repo", + "MyApp.Schemas.Account", + "MyApp.Schemas.Token", + "MyAppWeb", +} +_EXTERNAL = {"Ecto.Query", "Logger"} + + +def _imports(result: dict) -> list[dict]: + return [e for e in result["edges"] if e.get("relation") == "imports"] + + +def _node_by_label(result: dict, label: str) -> dict: + matches = [n for n in result["nodes"] if n.get("label") == label] + assert len(matches) == 1, f"expected exactly one {label!r} node, got {matches}" + return matches[0] + + +def _fqn(edge: dict) -> str: + return str((edge.get("metadata") or {}).get("target_fqn") or "") + + +def test_elixir_alias_target_equals_defmodule_node_id(tmp_path: Path): + """The defining node id and the alias edge target must be the SAME string.""" + root, paths = _elixir_project(tmp_path) + result = extract(paths, cache_root=root) + + repo_node = _node_by_label(result, "MyApp.Repo") + alias_edges = [e for e in _imports(result) if _fqn(e) == "MyApp.Repo"] + assert alias_edges, "no imports edge for `alias MyApp.Repo`" + for e in alias_edges: + assert e["target"] == repo_node["id"], ( + f"alias target {e['target']!r} != defmodule node id {repo_node['id']!r}" + ) + + +def test_elixir_definition_ids_keep_their_file_stem(tmp_path: Path): + """The fix must NOT be 'drop the stem from the definition id' (#1504). + + Dropping it is the tempting one-liner, but function ids are + ``_make_id(module_nid, func_name)``, so a stemless ``def router`` in + ``MyAppWeb`` would collapse onto the module ``MyAppWeb.Router`` — the + ``use MyAppWeb, :router`` idiom of every Phoenix app. + """ + root, paths = _elixir_project(tmp_path) + result = extract(paths, cache_root=root) + + for label, stem in ( + ("MyApp.Repo", "lib_my_app_repo"), + ("MyApp.Schemas.Account", "lib_my_app_schemas_account"), + ("MyAppWeb", "lib_my_app_web"), + ): + nid = _node_by_label(result, label)["id"] + assert nid.startswith(stem + "_"), ( + f"{label} node id {nid!r} lost its file-stem prefix {stem!r}" + ) + assert len({n["id"] for n in result["nodes"]}) == len(result["nodes"]), ( + "node ids collided" + ) + + +def test_elixir_no_dangling_import_for_in_corpus_module(tmp_path: Path): + """Every alias/import naming a module DEFINED in the corpus must resolve.""" + root, paths = _elixir_project(tmp_path) + result = extract(paths, cache_root=root) + + node_ids = {n["id"] for n in result["nodes"]} + resolved = set() + for e in _imports(result): + fqn = _fqn(e) + assert fqn, f"imports edge carries no target_fqn: {e}" + if fqn in _IN_CORPUS: + assert e["target"] in node_ids, ( + f"dangling imports edge for in-corpus module {fqn}: " + f"target {e['target']!r} is not a node" + ) + resolved.add(fqn) + assert resolved == _IN_CORPUS, f"missing import edges for {_IN_CORPUS - resolved}" + + +def test_elixir_external_modules_dangle_without_stub_node(tmp_path: Path): + """`import Ecto.Query` / `require Logger` keep their edge, mint no node. + + Externals have no definition in the corpus, so the designed behaviour is the + same as every other language's external imports: the edge stays name-derived + and build prunes it. Fabricating a stub node instead would put a phantom + ``Ecto.Query`` in the graph (the ghost-node failure of #2195). + """ + root, paths = _elixir_project(tmp_path) + result = extract(paths, cache_root=root) + + node_ids = {n["id"] for n in result["nodes"]} + node_labels = {n.get("label") for n in result["nodes"]} + seen = set() + for e in _imports(result): + fqn = _fqn(e) + if fqn in _EXTERNAL: + seen.add(fqn) + assert e["target"] not in node_ids, ( + f"external module {fqn} was resolved to a node — a stub was fabricated" + ) + assert seen == _EXTERNAL, f"missing import edges for {_EXTERNAL - seen}" + for fqn in _EXTERNAL: + assert fqn not in node_labels, f"stub node minted for external module {fqn}" + + +def test_elixir_import_edges_survive_build(tmp_path: Path): + """The user-visible claim: the edges are still there after graph assembly.""" + root, paths = _elixir_project(tmp_path) + result = extract(paths, cache_root=root) + G = build_from_json(result, directed=True) + + kept = [ + (u, v) for u, v, d in G.edges(data=True) if d.get("relation") == "imports" + ] + assert len(kept) == len(_IN_CORPUS), ( + f"expected {len(_IN_CORPUS)} surviving import edges, got {len(kept)}: {kept}" + ) + user_file = next( + n["id"] for n in result["nodes"] if n.get("label") == "user.ex" + ) + repo_module = _node_by_label(result, "MyApp.Repo")["id"] + assert (user_file, repo_module) in kept + # The externals were pruned, not turned into nodes. + for fqn in _EXTERNAL: + assert fqn not in {G.nodes[n].get("label") for n in G.nodes} + + +def test_elixir_ambiguous_module_name_is_left_unresolved(tmp_path: Path): + """Two files defining the same module => no guess (single-candidate guard). + + Picking one would be a fabricated edge. The edge stays dangling, exactly as + it did before the fix, rather than becoming wrong. + """ + root = Path(os.path.realpath(tmp_path)) + paths = [ + _write( + root / "lib/a.ex", + "defmodule MyApp.Dup do\n def go, do: :a\nend\n", + ), + _write( + root / "test/support/b.ex", + "defmodule MyApp.Dup do\n def go, do: :b\nend\n", + ), + _write( + root / "lib/caller.ex", + "defmodule MyApp.Caller do\n alias MyApp.Dup\n def go, do: Dup.go()\nend\n", + ), + ] + result = extract(paths, cache_root=root) + + node_ids = {n["id"] for n in result["nodes"]} + dup_edges = [e for e in _imports(result) if _fqn(e) == "MyApp.Dup"] + assert dup_edges, "no imports edge for `alias MyApp.Dup`" + for e in dup_edges: + assert e["target"] not in node_ids, ( + "ambiguous module name must not be resolved to an arbitrary candidate" + ) + + +def test_elixir_self_import_preserves_contains_edge(tmp_path: Path): + """A module that imports ITSELF must not destroy its own `contains` edge. + + The `__using__`/`quote do import MyApp.Context end` idiom makes a file + alias a module it also defines. Retargeting that import onto the defmodule + node lands it on the same (source, target) pair as `file --contains--> + module`; the built graph is a plain non-multi Graph, so one relation would + silently overwrite the other. Measured at 17 lost `contains` edges on a + 980-file app before the self-import guard. + """ + root = Path(os.path.realpath(tmp_path)) + paths = [_write( + root / "lib/my_app/context.ex", + """defmodule MyApp.Context do + defmacro __using__(_) do + quote do + import MyApp.Context + end + end + + def helper(x), do: x +end +""", + )] + result = extract(paths, cache_root=tmp_path / "cache", root=str(root)) + graph = build_from_json(result, root=str(root), directed=False) + relations = {d.get("relation") for _, _, d in graph.edges(data=True)} + assert "contains" in relations, ( + "self-import overwrote the file --contains--> module edge" + ) + + +def test_elixir_nested_defmodule_does_not_capture_foreign_alias(tmp_path: Path): + """A nested `defmodule` must never absorb another file's alias. + + `extract_elixir` labels a nested module with the name as WRITTEN, not its + real BEAM name: `defmodule Supervisor` inside `MyApp.Application` is + labelled `Supervisor`, not `MyApp.Application.Supervisor`. Indexing it + would let it capture `use Supervisor` — the Elixir stdlib module — from + unrelated files and assert a false EXTRACTED edge. Only top-level + defmodules carry the `_elixir_module` marker the resolver indexes on. + """ + root = Path(os.path.realpath(tmp_path)) + paths = [ + _write( + root / "lib/my_app/application.ex", + """defmodule MyApp.Application do + defmodule Supervisor do + def start_link(_), do: :ok + end + + def start(_type, _args), do: :ok +end +""", + ), + _write( + root / "lib/my_app/workers/pool.ex", + "defmodule MyApp.Workers.Pool do\n use Supervisor\n def init(_), do: :ok\nend\n", + ), + ] + result = extract(paths, cache_root=tmp_path / "cache", root=str(root)) + node_ids = {n["id"] for n in result["nodes"]} + captured = [ + e for e in _imports(result) + if "pool.ex" in str(e.get("source_file") or "") and e["target"] in node_ids + ] + assert captured == [], ( + f"nested defmodule captured a foreign alias: {captured}" + ) + + +def test_elixir_resolution_survives_incremental_rebuild(tmp_path: Path): + """An alias into an UNCHANGED file must still resolve on a partial re-extract. + + On an incremental run `all_nodes` holds only the re-extracted batch, so + resolving against it drops every alias pointing into an unchanged file — + the graph would decay back toward the bug on the `graphify watch` / hook + path — and would also bypass the single-candidate guard, since a + corpus-wide-ambiguous name can look unique within the changed subset. The + resolver therefore indexes `resolution_nodes`, which includes the + caller-supplied unchanged-corpus nodes (#2406). + """ + root = Path(os.path.realpath(tmp_path)) + paths = [ + _write( + root / "lib/my_app/repo.ex", + "defmodule MyApp.Repo do\n def insert(x), do: x\nend\n", + ), + _write( + root / "lib/my_app/user.ex", + """defmodule MyApp.User do + alias MyApp.Repo + + def save(x), do: Repo.insert(x) +end +""", + ), + ] + full = extract(paths, cache_root=tmp_path / "c1", root=str(root)) + full_ids = {n["id"] for n in full["nodes"]} + resolved_full = [e for e in _imports(full) if e["target"] in full_ids] + assert len(resolved_full) == 1 + + # Re-extract ONLY user.ex; repo.ex arrives as unchanged-corpus context. + context = [n for n in full["nodes"] if "repo.ex" in str(n.get("source_file") or "")] + incremental = extract( + [root / "lib/my_app/user.ex"], + cache_root=tmp_path / "c2", + root=str(root), + resolution_context_nodes=context, + ) + known = {n["id"] for n in incremental["nodes"]} | {n["id"] for n in context} + resolved_inc = [e for e in _imports(incremental) if e["target"] in known] + assert len(resolved_inc) == 1, ( + "alias into an unchanged file did not resolve on incremental rebuild" + ) + assert resolved_inc[0]["target"] == resolved_full[0]["target"]