diff --git a/graphify/build.py b/graphify/build.py index 9acf342bb..4bdf502d2 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -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: diff --git a/graphify/extract.py b/graphify/extract.py index 67ce012c4..00f8dda6e 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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 _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) @@ -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), []) @@ -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 diff --git a/graphify/extractors/models.py b/graphify/extractors/models.py index e6da5173e..63c1d8a18 100644 --- a/graphify/extractors/models.py +++ b/graphify/extractors/models.py @@ -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) diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index d988a72d6..e88e36372 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -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 @@ -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: @@ -962,7 +967,7 @@ 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) @@ -970,7 +975,10 @@ def resolve_exported_origin(target_path: Path, imported_name: str, seen: set[tup 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) diff --git a/tests/test_extract.py b/tests/test_extract.py index 98b73edb9..9d53a932f 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -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