fix(extract): resolve calls edges through an aliased Python import (#2082)

`from pkg import mod as alias` correctly emits the file-level
`imports_from` edge, but every downstream `alias.func()` call was
dropped: the module arm of the cross-file member-call resolver
(#1883, _resolve_python_member_calls in extract.py) matches a call
receiver against the imported module's own file stem, with no
awareness that the local binding in the importing file can be a
different name. `from pkg import mod` / `mod.func()` resolves because
the receiver ("mod") equals the stem; aliasing breaks the match
because the receiver ("alias") never does, and the calls edge
silently disappears while imports_from stays present -- the graph
looks connected, only the symbol-level reverse query comes back
empty. `import pkg.mod as alias` regresses the same way through the
same resolver.

Root cause is a missing propagation, not a missing feature: the local
alias is already parsed correctly in two places (_python_imported_names
in extractors/resolution.py, and the aliased_import branch of
_import_python in extract.py) but discarded before it reaches the
edges the module arm reads.

Fix threads the alias through as a `local_alias` field on the
`imports`/`imports_from` edge (mirroring the existing `target_file`
transient-hint pattern, #1814, including its pop-once-consumed
hygiene so the hint never reaches graph.json):
- _import_python now splits the alias off `import pkg.mod as alias`
  and stamps it on the edge instead of only using it to compute the
  bare module_name.
- _SymbolResolutionFacts.module_imports gains a 4th `local_name` slot
  so the `from pkg import submod [as alias]` submodule path (#1146)
  carries the binding through to _apply_symbol_resolution_facts,
  which now stamps `local_alias` on the edge whenever it differs from
  the submodule's own stem.
- The module arm's receiver match now accepts the tracked alias in
  addition to the module's real stem, keyed per (importing file,
  target module) so two files aliasing the same module differently
  each match their own binding.
- extract() pops `local_alias` off every edge right after
  run_language_resolvers runs, and build_from_json drops it from edge
  attrs too -- the same two spots target_file is dropped at (#1814),
  except the extract()-side pop has to happen AFTER the resolver
  reads the field, not at the earlier point _disambiguate_colliding_
  node_ids already pops target_file: that function runs before
  run_language_resolvers, so popping local_alias there would strip it
  before the resolver ever sees it and silently undo the fix above.

Known limitation, left out of scope: two different aliases bound to
the same module in the same file only resolve the last one
registered, since the match is one alias slot keyed per (importing
file, target module) -- not a regression, since the parent resolved
neither.

Adds five regression tests in tests/test_extract.py: the issue's own
shape (`from pkg import gate as m_gate`), its try/except-guarded
variant (the issue's literal repro, confirming the drop is
independent of try: nesting), the adjacent `import mathlib as m` and
dotted `import pkg.gate as g_alias` forms, and the relative `from .
import gate as r_gate` form -- plus an assertion that `local_alias`
never survives into the returned edges. All fail on the parent commit
with the exact missing-edge symptom and pass after the fix. Full
suite: 3554 passed vs. 3549 on the unfixed parent, a delta of exactly
these five new tests; ruff clean. #2080's calls-edge-direction
regression tests (test_serve.py) still pass unchanged.
This commit is contained in:
Yyunozor
2026-07-22 15:32:08 +01:00
committed by safishamsi
parent 4c759b6dd6
commit cc70085f3b
5 changed files with 230 additions and 12 deletions
+6 -1
View File
@@ -802,6 +802,11 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
# dropping it here as well keeps a pre-fix graph's stale absolute hint
# from surviving an incremental build_merge, which re-serializes base
# edges through here without re-running disambiguation.
# `local_alias` is the same shape of transient hint (#2082): it exists only
# for the module arm of _resolve_python_member_calls to match an aliased
# import receiver, and extract() already drops it once that pass has run.
# Dropping it here too covers a stale pre-fix graph re-serialized through
# an incremental build_merge, same rationale as target_file above.
# Sanitize numeric edge fields (#1960): an explicit ``"weight": null`` in
# the extraction JSON survives ``.get("weight", 1.0)`` (the key is present,
# so the default never applies) and reaches Louvain/Leiden as None,
@@ -811,7 +816,7 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
# strings, NaN/inf, negatives — while numeric strings coerce cleanly.
# Repair (not drop) the key so graph.json round-trips a clean value and a
# cluster-only/--update reload never re-ingests the null.
attrs = {k: v for k, v in edge.items() if k not in ("source", "target", "target_file")}
attrs = {k: v for k, v in edge.items() if k not in ("source", "target", "target_file", "local_alias")}
for _num_key in ("weight", "confidence_score"):
if _num_key in attrs:
try:
+37 -5
View File
@@ -311,9 +311,10 @@ def _import_python(node, source: bytes, file_nid: str, stem: str, edges: list, s
for child in node.children:
if child.type in ("dotted_name", "aliased_import"):
raw = _read_text(child, source)
module_name = raw.split(" as ")[0].strip().lstrip(".")
raw_module, _, raw_alias = raw.partition(" as ")
module_name = raw_module.strip().lstrip(".")
tgt_nid = _make_id(module_name)
edges.append({
edge = {
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
@@ -322,7 +323,14 @@ def _import_python(node, source: bytes, file_nid: str, stem: str, edges: list, s
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
"weight": 1.0,
})
}
if raw_alias:
# `import pkg.mod as alias` binds the local name `alias`, not
# `mod`'s own stem, to the module -- stash it so the cross-file
# member-call resolver can match `alias.func()` against this
# edge instead of dropping it (#2082).
edge["local_alias"] = raw_alias.strip()
edges.append(edge)
elif t == "import_from_statement":
module_node = node.child_by_field_name("module_name")
if module_node:
@@ -2279,9 +2287,17 @@ def _resolve_python_member_calls(
_key(tnode.get("label", "")), []).append(tgt)
file_of_node[tgt] = src
imported_by_filenode: dict[str, set[str]] = {}
# Local alias bound by `as` on a specific import edge (#2082): `from pkg import
# mod as alias` / `import pkg.mod as alias` bind `alias`, not `mod`'s own stem,
# to the module in the importing file. Keyed by (importing file, target module)
# so two files aliasing the same module differently each match their own.
import_alias_by_filenode: dict[str, dict[str, str]] = {}
for e in all_edges:
if e.get("relation") in ("imports", "imports_from"):
imported_by_filenode.setdefault(e.get("source"), set()).add(e.get("target"))
alias = e.get("local_alias")
if alias:
import_alias_by_filenode.setdefault(e.get("source"), {})[e.get("target")] = _key(alias)
def _module_stem_key(nid: str) -> str:
n = node_by_id.get(nid)
@@ -2331,11 +2347,15 @@ def _resolve_python_member_calls(
# Module arm (#1883): a lowercase receiver may be an imported module.
# Resolve it against the modules imported into the caller's own file
# (so `self`/`obj`/local instances, which are not imported modules,
# never match), then to the single callable that module contains.
# never match), then to the single callable that module contains. A
# receiver also matches the local alias bound on that import edge
# (#2082), so an aliased import resolves the same as the bare name.
rkey = _key(receiver)
caller_file = file_of_node.get(caller)
file_aliases = import_alias_by_filenode.get(caller_file, {})
mods = [t for t in imported_by_filenode.get(caller_file, ())
if t in contains_children and _module_stem_key(t) == rkey]
if t in contains_children
and (_module_stem_key(t) == rkey or file_aliases.get(t) == rkey)]
if len(mods) != 1: # not an imported module, or ambiguous -> bail
continue
children = contains_children[mods[0]].get(_key(callee), [])
@@ -5225,6 +5245,18 @@ def extract(
n.pop("origin_file", None)
n.pop("_callable", None) # internal indirect_call marker — never ships to graph.json
# local_alias is a transient import-resolution hint (#2082), same shape as
# target_file (#1814): it exists only so the module arm of
# _resolve_python_member_calls (run above via run_language_resolvers) can
# match an aliased receiver against the import edge it came from. Nothing
# reads it after that pass runs, so drop it here rather than let an internal
# local variable name ship into graph.json. Popped post-resolution, unlike
# target_file (which _disambiguate_colliding_node_ids pops earlier in the
# pipeline) — local_alias must survive until run_language_resolvers has run,
# so it cannot be popped at that earlier point without breaking the fix.
for e in all_edges:
e.pop("local_alias", None)
# Tag AST provenance so the incremental watch rebuild can distinguish
# AST-extracted nodes from semantic/LLM nodes. On a full re-extraction
# the watcher drops any AST-marked node missing from the fresh output
+4 -2
View File
@@ -115,5 +115,7 @@ class _SymbolResolutionFacts:
namespace_exports: list[_NamespaceExportFact] = field(default_factory=list)
uses: list[_SymbolUseFact] = field(default_factory=list)
# File-to-file submodule imports from `from pkg import submod` (#1146).
# Each entry is (importing_file, submodule_file, line).
module_imports: list[tuple[Path, Path, int]] = field(default_factory=list)
# Each entry is (importing_file, submodule_file, line, local_name) -- local_name
# is the binding introduced in the importing file: the alias when `from pkg
# import submod as alias` is used, otherwise the submodule's own name (#2082).
module_imports: list[tuple[Path, Path, int, str]] = field(default_factory=list)
+12 -4
View File
@@ -796,7 +796,7 @@ def _apply_symbol_resolution_facts(
for edge in edges
}
def add_edge(source: str, target: str, relation: str, context: str, line: int, source_path: Path, target_file: str | None = None) -> None:
def add_edge(source: str, target: str, relation: str, context: str, line: int, source_path: Path, target_file: str | None = None, local_alias: str | None = None) -> None:
key = (source, target, relation, context or "")
if key in existing_edges:
return
@@ -816,6 +816,11 @@ def _apply_symbol_resolution_facts(
# the id-disambiguation salt is keyed by the TARGET, not the importer (#1814).
if target_file is not None:
edge["target_file"] = target_file
# The local name this import bound in the importing file, when it differs
# from the target's own name (`from pkg import mod as alias`) -- lets the
# cross-file member-call resolver match `alias.func()` (#2082).
if local_alias is not None:
edge["local_alias"] = local_alias
edges.append(edge)
for declaration in facts.declarations:
@@ -962,7 +967,7 @@ def _apply_symbol_resolution_facts(
)
# #1146: emit file-to-file imports_from edges for package-form submodule imports.
for from_path, to_path, line in facts.module_imports:
for from_path, to_path, line, local_name in facts.module_imports:
try:
from_rel = from_path.relative_to(root)
to_rel = to_path.relative_to(root)
@@ -970,7 +975,10 @@ def _apply_symbol_resolution_facts(
continue
source_id = _make_id(_file_stem(from_rel))
target_id = _make_id(_file_stem(to_rel))
add_edge(source_id, target_id, "imports_from", "submodule_import", line, from_path)
add_edge(
source_id, target_id, "imports_from", "submodule_import", line, from_path,
local_alias=local_name if local_name != to_path.stem else None,
)
for use_fact in facts.uses:
file_path = use_fact.file_path.resolve()
@@ -1717,7 +1725,7 @@ def _collect_python_symbol_resolution_facts(
sub_pkg = pkg_dir / imported_name / "__init__.py"
submodule = sub_py if sub_py.is_file() else (sub_pkg if sub_pkg.is_file() else None)
if submodule is not None:
facts.module_imports.append((path, submodule, line))
facts.module_imports.append((path, submodule, line, local_name))
continue
facts.imports.append(
_SymbolImportFact(path, local_name, target_path, imported_name, line)