From 3792ff1fdacaa2719c910da5651bc94b64cbc951 Mon Sep 17 00:00:00 2001 From: Ha1baraA11 <243105435+Ha1baraA11@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:23:39 +0800 Subject: [PATCH] fix: preserve loose sibling import resolution (cherry picked from commit 61e3b8fcbcaa0418e21634da3f65915bfaa8b139) --- graphify/extract.py | 46 +++++++++++++++++-- tests/test_loose_sibling_import_resolution.py | 7 ++- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index da27650e1..45617905c 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -286,16 +286,47 @@ def _suppress_ambiguous_python_imports( nodes: list[dict], raw_calls: list[dict], ambiguous_modules: set[str], + module_alias_files: dict[str, set[str]], + root: Path, ) -> None: """Keep ambiguous Python imports dangling instead of choosing one file. The transient module name is also consumed by the symbol-level resolvers; removing it here prevents a direct file edge and any later inferred symbol - or call edge from binding to an arbitrary same-named package. + or call edge from binding to an arbitrary same-named package. A plain + ``import helper`` resolved to a scanned loose sibling in the importer's own + non-package directory is an exception: that directory-local target is + unambiguous even when another loose directory has its own ``helper.py``. """ node_ids = {n.get("id") for n in nodes if isinstance(n, dict)} ambiguous_bindings_by_file: dict[str, set[str]] = {} ambiguous_all_call_files: set[str] = set() + + def _is_resolved_loose_sibling(edge: dict, module_id: str) -> bool: + """Trust only a concrete, scanned same-directory target for a bare import.""" + target_file = edge.get("target_file") + source_file = edge.get("source_file") + if not target_file or not source_file: + return False + candidates = module_alias_files.get(module_id, set()) + if not candidates: + return False + try: + source_path = Path(source_file) + if not source_path.is_absolute(): + source_path = root / source_path + source_path = source_path.resolve() + target_path = Path(target_file).resolve() + if target_path.parent != source_path.parent: + return False + if (source_path.parent / "__init__.py").is_file() or ( + source_path.parent / "__init__.pyi" + ).is_file(): + return False + return any(Path(candidate).resolve() == target_path for candidate in candidates) + except (OSError, RuntimeError): + return False + kept_edges: list[dict] = [] for edge in edges: if not isinstance(edge, dict): @@ -312,7 +343,15 @@ def _suppress_ambiguous_python_imports( if not marker_only and edge.get("relation") not in ("imports", "imports_from"): kept_edges.append(edge) continue - module_is_ambiguous = _make_id(module_name) in ambiguous_modules + module_id = _make_id(module_name) + module_is_ambiguous = module_id in ambiguous_modules + if ( + module_is_ambiguous + and is_module_binding + and "." not in module_name + and _is_resolved_loose_sibling(edge, module_id) + ): + module_is_ambiguous = False if is_module_binding: if module_is_ambiguous: ambiguous_bindings_by_file.setdefault( @@ -7444,7 +7483,8 @@ def extract( callable_nids: set[str] = set() _suppress_ambiguous_python_imports( - all_edges, all_nodes, all_raw_calls, ambiguous_python_modules + all_edges, all_nodes, all_raw_calls, ambiguous_python_modules, + python_module_alias_files, root, ) _augment_symbol_resolution_edges( paths, all_nodes, all_edges, root, diff --git a/tests/test_loose_sibling_import_resolution.py b/tests/test_loose_sibling_import_resolution.py index 2784ddf70..7652b2d14 100644 --- a/tests/test_loose_sibling_import_resolution.py +++ b/tests/test_loose_sibling_import_resolution.py @@ -127,7 +127,12 @@ def test_same_name_modules_in_separate_loose_directories(tmp_path): tools / "helper.py", tools / "main.py", ] - res = extract(paths, root=tmp_path, parallel=False) + # Keep this cold: the shared content-hash cache can replay an extraction + # that predates private import-resolution metadata and mask the ambiguity + # guard's behavior. + res = extract( + paths, root=tmp_path, parallel=False, cache_root=tmp_path / "graphify-cache" + ) G = build_from_json(res, root=str(tmp_path), directed=True) edges = _edge_set(G)