Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion graphify/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand Down
42 changes: 37 additions & 5 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:
Expand Down Expand Up @@ -2279,9 +2287,17 @@ def _key(label: str) -> str:
_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)
Expand Down Expand Up @@ -2331,11 +2347,15 @@ def _emit_call(caller: str, target_nid: "str | None", rc: dict) -> None:
# 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), [])
Expand Down Expand Up @@ -5225,6 +5245,18 @@ def _portable_out_of_root_sf(p: Path) -> str:
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
Expand Down
6 changes: 4 additions & 2 deletions graphify/extractors/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
16 changes: 12 additions & 4 deletions graphify/extractors/resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -796,7 +796,7 @@ def ensure_symbol_node(path: Path, name: str, line: int) -> str:
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
Expand All @@ -816,6 +816,11 @@ def add_edge(source: str, target: str, relation: str, context: str, line: int, s
# 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:
Expand Down Expand Up @@ -962,15 +967,18 @@ def resolve_exported_origin(target_path: Path, imported_name: str, seen: set[tup
)

# #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)
except ValueError:
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()
Expand Down Expand Up @@ -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)
Expand Down
171 changes: 171 additions & 0 deletions tests/test_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -1074,6 +1074,177 @@ def test_python_module_qualified_call_requires_the_import(tmp_path):
assert bad == [], f"non-imported receiver must not link cross-file: {bad}"


def test_python_from_import_alias_module_call_resolves(tmp_path):
"""`from pkg import mod as alias` must resolve `alias.func()` the same way the
unaliased `from pkg import mod` / `mod.func()` form already does (#2082). The
local alias binding was untracked, so the aliased receiver never matched the
submodule's own stem and the `calls` edge silently disappeared while the
file-level `imports_from` edge stayed present and made the graph look intact.

Also covers the fix's `local_alias` hint hygiene: like the existing
`target_file` transient hint (#1814), it must be popped once the resolver
that reads it has run, never surviving into the returned edges/graph.json --
otherwise an internal local-variable name from the source tree leaks into
every graph.json produced from an aliased import."""
pkg = tmp_path / "pkg"
pkg.mkdir()
(pkg / "__init__.py").write_text("")
(pkg / "gate.py").write_text("def validate(rows):\n return bool(rows)\n")
caller = pkg / "caller.py"
caller.write_text(
"from pkg import gate as m_gate\n\n"
"def use_alias(rows):\n"
" return m_gate.validate(rows)\n"
)
result = extract(
[caller, pkg / "gate.py", pkg / "__init__.py"],
cache_root=tmp_path,
root=tmp_path,
)
nodes = {n["id"]: n for n in result["nodes"]}
edges = [
e for e in result["edges"]
if e["relation"] == "calls"
and "use_alias" in nodes[e["source"]]["label"]
and "validate" in nodes[e["target"]]["label"]
and "gate.py" in (nodes[e["target"]].get("source_file") or "")
]
assert len(edges) == 1, f"expected one use_alias->validate edge, got {edges}"
assert edges[0]["confidence"] == "EXTRACTED"
leaked = [e for e in result["edges"] if "local_alias" in e]
assert leaked == [], f"local_alias hint must not survive into the output: {leaked}"


def test_python_import_as_alias_module_call_resolves(tmp_path):
"""`import mod as alias` must resolve `alias.func()` the same way `import mod`
/ `mod.func()` already does (#1883) -- the same untracked-alias regression as
`from pkg import mod as alias` (#2082), on the plain `import` form."""
mathlib = tmp_path / "mathlib.py"
caller = tmp_path / "caller.py"
mathlib.write_text("def compute(x):\n return x * 2\n")
caller.write_text(
"import mathlib as m\n\n"
"def use_aliased_import(n):\n"
" return m.compute(n)\n"
)
result = extract([caller, mathlib], cache_root=tmp_path)
nodes = {n["id"]: n for n in result["nodes"]}
edges = [
e for e in result["edges"]
if e["relation"] == "calls"
and "use_aliased_import" in nodes[e["source"]]["label"]
and "compute" in nodes[e["target"]]["label"]
and "mathlib.py" in (nodes[e["target"]].get("source_file") or "")
]
assert len(edges) == 1, f"expected one use_aliased_import->compute edge, got {edges}"
assert edges[0]["confidence"] == "EXTRACTED"


def test_python_try_except_from_import_alias_module_call_resolves(tmp_path):
"""The issue's own motivating shape (#2082): `from pkg import mod as alias`
guarded by a `try:`/`except ImportError:` fallback assignment, the pattern
real code uses for an optional dependency. The issue explicitly called out
that the drop is independent of the `try:` nesting -- this locks that in as
a regression test rather than relying only on the unwrapped module-level
form above."""
pkg = tmp_path / "pkg"
pkg.mkdir()
(pkg / "__init__.py").write_text("")
(pkg / "gate.py").write_text("def validate(rows):\n return bool(rows)\n")
caller = pkg / "caller_try.py"
caller.write_text(
"try:\n"
" from pkg import gate as t_gate\n"
"except ImportError:\n"
" t_gate = None\n\n"
"def use_try_alias(rows):\n"
" return t_gate.validate(rows)\n"
)
result = extract(
[caller, pkg / "gate.py", pkg / "__init__.py"],
cache_root=tmp_path,
root=tmp_path,
)
nodes = {n["id"]: n for n in result["nodes"]}
edges = [
e for e in result["edges"]
if e["relation"] == "calls"
and "use_try_alias" in nodes[e["source"]]["label"]
and "validate" in nodes[e["target"]]["label"]
and "gate.py" in (nodes[e["target"]].get("source_file") or "")
]
assert len(edges) == 1, f"expected one use_try_alias->validate edge, got {edges}"
assert edges[0]["confidence"] == "EXTRACTED"


def test_python_dotted_import_alias_module_call_resolves(tmp_path):
"""`import pkg.mod as alias` -- the dotted absolute-import form the issue
flagged as needing coverage -- must resolve `alias.func()` the same way the
single-segment `import mathlib as m` form above does. This exercises the
`aliased_import` branch of `_import_python`'s `import_statement` arm with a
multi-segment module name, where the target id comes from collapsing
`pkg.gate` rather than a bare stem."""
pkg = tmp_path / "pkg"
pkg.mkdir()
(pkg / "__init__.py").write_text("")
(pkg / "gate.py").write_text("def validate(rows):\n return bool(rows)\n")
caller = pkg / "caller_dotted.py"
caller.write_text(
"import pkg.gate as g_alias\n\n"
"def use_dotted_alias(rows):\n"
" return g_alias.validate(rows)\n"
)
result = extract(
[caller, pkg / "gate.py", pkg / "__init__.py"],
cache_root=tmp_path,
root=tmp_path,
)
nodes = {n["id"]: n for n in result["nodes"]}
edges = [
e for e in result["edges"]
if e["relation"] == "calls"
and "use_dotted_alias" in nodes[e["source"]]["label"]
and "validate" in nodes[e["target"]]["label"]
and "gate.py" in (nodes[e["target"]].get("source_file") or "")
]
assert len(edges) == 1, f"expected one use_dotted_alias->validate edge, got {edges}"
assert edges[0]["confidence"] == "EXTRACTED"


def test_python_relative_from_import_alias_module_call_resolves(tmp_path):
"""`from . import mod as alias` -- a relative sibling-module import with an
alias -- must resolve `alias.func()` the same way the absolute `from pkg
import mod as alias` form above does. Relative imports route through the
same #1146 submodule-import path (module_imports' local_name slot) with a
level instead of an absolute module name, which is a distinct branch from
the absolute-import case already covered."""
pkg = tmp_path / "pkg"
pkg.mkdir()
(pkg / "__init__.py").write_text("")
(pkg / "gate.py").write_text("def validate(rows):\n return bool(rows)\n")
caller = pkg / "caller_relative.py"
caller.write_text(
"from . import gate as r_gate\n\n"
"def use_relative_alias(rows):\n"
" return r_gate.validate(rows)\n"
)
result = extract(
[caller, pkg / "gate.py", pkg / "__init__.py"],
cache_root=tmp_path,
root=tmp_path,
)
nodes = {n["id"]: n for n in result["nodes"]}
edges = [
e for e in result["edges"]
if e["relation"] == "calls"
and "use_relative_alias" in nodes[e["source"]]["label"]
and "validate" in nodes[e["target"]]["label"]
and "gate.py" in (nodes[e["target"]].get("source_file") or "")
]
assert len(edges) == 1, f"expected one use_relative_alias->validate edge, got {edges}"
assert edges[0]["confidence"] == "EXTRACTED"


def test_python_qualified_call_resolves_when_method_name_collides_with_caller(tmp_path):
"""The real #1446 shape: a viewset action `approve()` delegates to a SERVICE
action of the SAME name via `Service.approve()`. The bare-name in-file lookup
Expand Down
Loading