From f1b880c726f8953b097eb0cd841762ba5d808995 Mon Sep 17 00:00:00 2001 From: Ha1baraA11 <243105435+Ha1baraA11@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:19:03 +0800 Subject: [PATCH 1/4] fix: resolve absolute Python package imports --- graphify/extract.py | 32 +++++++++++++++++++++++++- tests/test_python_import_resolution.py | 26 +++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/graphify/extract.py b/graphify/extract.py index da66f41317..1a08e74ae4 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -482,7 +482,37 @@ def _import_python(node, source: bytes, file_nid: str, stem: str, edges: list, s target_path = base / rel tgt_nid = _make_id(str(target_path)) else: - tgt_nid = _make_id(raw) + # Absolute imports need the same package/file probing as the + # relative arm. Keeping the dotted-name slug here makes + # ``from pkg.sub import thing`` point at ``pkg_sub`` even when + # the real node is ``pkg_sub_init``; the symbol-resolution pass + # may add a second, correct edge, but it cannot repair this + # malformed file-level edge (#3723). Search upward from the + # importer and only probe non-package ancestors, mirroring the + # resolver's sys.path-root rule without requiring the scan root + # at per-file extraction time. + module_rel = raw.replace(".", "/") + current_path = Path(str_path) + try: + current_path = current_path.resolve() + except OSError: + pass + for ancestor in (current_path.parent, *current_path.parent.parents): + if (ancestor / "__init__.py").is_file(): + continue + resolved = _probe_python_module_candidate(ancestor / module_rel) + if resolved is not None: + try: + if resolved.resolve() == current_path: + # An external import can share the importing + # file's basename; never turn that coincidence + # into a self-loop. + continue + except OSError: + pass + target_path = resolved + break + tgt_nid = _make_id(str(target_path)) if target_path is not None else _make_id(raw) edge = { "source": file_nid, "target": tgt_nid, diff --git a/tests/test_python_import_resolution.py b/tests/test_python_import_resolution.py index 5de9e5a5eb..0efd37e057 100644 --- a/tests/test_python_import_resolution.py +++ b/tests/test_python_import_resolution.py @@ -96,6 +96,32 @@ def test_relative_subpackage_import_from_targets_package_init(tmp_path: Path): assert all(t.endswith("graphs_init") for t in health_targets), health_targets +def test_absolute_package_import_targets_package_init(tmp_path: Path): + """Absolute package imports must not leave dotted-name dangling edges (#3723).""" + files = [ + _write(tmp_path / "pkg/__init__.py", ""), + _write(tmp_path / "pkg/sub/__init__.py", ""), + _write(tmp_path / "pkg/sub/thing.py", "def run():\n return 1\n"), + _write(tmp_path / "pkg/consumer.py", "from pkg import sub\n"), + _write(tmp_path / "user.py", "from pkg.sub import thing\n"), + ] + + result = extract(files, cache_root=tmp_path) + + consumer = _node_id(result, "consumer.py", "pkg/consumer.py") + user = _node_id(result, "user.py", "user.py") + import_targets = { + edge["target"] + for edge in result["edges"] + if edge["relation"] == "imports_from" + and edge["source"] in {consumer, user} + } + + assert {"pkg_init", "pkg_sub_init"} <= import_targets + assert "pkg" not in import_targets + assert "pkg_sub" not in import_targets + + def test_python_package_reexport_resolves_import_and_call_to_origin_symbol(tmp_path: Path): origin = _write(tmp_path / "pkg/foo.py", "def Foo():\n return 1\n") barrel = _write(tmp_path / "pkg/__init__.py", "from .foo import Foo as PublicFoo\n") From 962a0569b12d3f1ed99f63a9bbd7748fb8dd7041 Mon Sep 17 00:00:00 2001 From: Ha1baraA11 <243105435+Ha1baraA11@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:41:53 +0800 Subject: [PATCH 2/4] fix: resolve Python imports within scan root --- graphify/extract.py | 125 +++++++++++++++---------- graphify/extractors/engine.py | 12 ++- tests/test_python_import_resolution.py | 55 +++++++++++ 3 files changed, 139 insertions(+), 53 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 1a08e74ae4..ebbaf52bf3 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -136,6 +136,7 @@ _resolve_lua_import_target, _probe_python_module_candidate, _resolve_python_module_path, + _resolve_python_namespace_dir, _resolve_tsconfig_alias, _resolve_workspace_import, _source_key, @@ -173,8 +174,12 @@ def _raise_recursion_limit() -> None: sys.setrecursionlimit(_RECURSION_LIMIT) -def _safe_extract(extractor: Callable, path: Path) -> dict: +def _safe_extract( + extractor: Callable, path: Path, *, scan_root: Path | None = None +) -> dict: try: + if extractor is extract_python: + return extractor(path, root=scan_root) return extractor(path) except RecursionError: print(f" warning: skipped {path} (recursion limit exceeded)", file=sys.stderr, flush=True) @@ -428,15 +433,51 @@ def _resolve_name(node, source: bytes, config: LanguageConfig) -> str | None: # ── Import handlers ─────────────────────────────────────────────────────────── -def _import_python(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None) -> None: +def _import_python( + node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, + scope_stack: list[str] | None = None, scan_root: Path | None = None, +) -> None: t = node.type + current_path = Path(str_path) + try: + current_path = current_path.resolve() + except OSError: + pass + root = Path(scan_root) if scan_root is not None else current_path.parent + try: + root = root.resolve() + except OSError: + pass if t == "import_statement": for child in node.children: if child.type in ("dotted_name", "aliased_import"): raw = _read_text(child, source) raw_module, _, raw_alias = raw.partition(" as ") module_name = raw_module.strip().lstrip(".") - tgt_nid = _make_id(module_name) + target_path = _resolve_python_module_path( + module_name, current_path, root, level=0 + ) + # The importer-relative resolver can find a module under one + # nested sys.path root even when the scan contains another file + # with the same dotted name under a different root. Keep those + # cases for the root-wide alias pass, which refuses ambiguous + # aliases; use a direct scan-root hit here. + root_target = _resolve_python_module_path( + module_name, root, root, level=0 + ) + if root_target is None or target_path != root_target: + target_path = None + if target_path is not None: + try: + if target_path.resolve() == current_path: + target_path = None + except OSError: + pass + tgt_nid = ( + _make_id(str(target_path)) + if target_path is not None + else _make_id(module_name) + ) edge = { "source": file_nid, "target": tgt_nid, @@ -447,6 +488,8 @@ def _import_python(node, source: bytes, file_nid: str, stem: str, edges: list, s "source_location": f"L{node.start_point[0] + 1}", "weight": 1.0, } + if target_path is not None: + edge["target_file"] = str(target_path) 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 @@ -463,55 +506,35 @@ def _import_python(node, source: bytes, file_nid: str, stem: str, edges: list, s # Relative import - resolve to full path so IDs match file node IDs dots = len(raw) - len(raw.lstrip(".")) module_name = raw.lstrip(".") - base = Path(str_path).parent - for _ in range(dots - 1): - base = base.parent - # A relative import can name a subpackage (a directory with an - # __init__.py), not a module file. Probing the candidate on disk - # (mirroring the companion `imports` edge's - # _resolve_python_module_path) resolves `graphs` -> graphs/__init__.py - # instead of a nonexistent graphs.py: without it the target keeps an - # absolute-path-derived slug that the target_file stamp below can't - # heal, so it dangles per-checkout (#2455). - candidate = base / module_name.replace(".", "/") if module_name else base - resolved = _probe_python_module_candidate(candidate) - if resolved is not None: - target_path = resolved - else: + target_path = _resolve_python_module_path( + module_name, current_path, root, level=dots + ) + if target_path is None: + base = current_path.parent + for _ in range(dots - 1): + base = base.parent rel = (module_name.replace(".", "/") + ".py") if module_name else "__init__.py" target_path = base / rel tgt_nid = _make_id(str(target_path)) else: - # Absolute imports need the same package/file probing as the - # relative arm. Keeping the dotted-name slug here makes - # ``from pkg.sub import thing`` point at ``pkg_sub`` even when - # the real node is ``pkg_sub_init``; the symbol-resolution pass - # may add a second, correct edge, but it cannot repair this - # malformed file-level edge (#3723). Search upward from the - # importer and only probe non-package ancestors, mirroring the - # resolver's sys.path-root rule without requiring the scan root - # at per-file extraction time. - module_rel = raw.replace(".", "/") - current_path = Path(str_path) - try: - current_path = current_path.resolve() - except OSError: - pass - for ancestor in (current_path.parent, *current_path.parent.parents): - if (ancestor / "__init__.py").is_file(): - continue - resolved = _probe_python_module_candidate(ancestor / module_rel) - if resolved is not None: - try: - if resolved.resolve() == current_path: - # An external import can share the importing - # file's basename; never turn that coincidence - # into a self-loop. - continue - except OSError: - pass - target_path = resolved - break + # Use the shared scan-root-aware resolver for absolute imports. + # It stops at the corpus boundary and handles package roots the + # same way as symbol resolution. A namespace package has no file + # node of its own; the corpus pass will emit edges to any + # imported submodules that exist on disk. + target_path = _resolve_python_module_path(raw, current_path, root, level=0) + if target_path is None and _resolve_python_namespace_dir( + raw, current_path, root, level=0 + ) is not None: + return + if target_path is not None: + try: + if target_path.resolve() == current_path: + # Do not turn an import of the current file into a + # self-loop. + target_path = None + except OSError: + pass tgt_nid = _make_id(str(target_path)) if target_path is not None else _make_id(raw) edge = { "source": file_nid, @@ -1718,9 +1741,9 @@ def placeholder(match: "re.Match[bytes]") -> bytes: # ── Public API ──────────────────────────────────────────────────────────────── -def extract_python(path: Path) -> dict: +def extract_python(path: Path, *, root: Path | None = None) -> dict: """Extract classes, functions, and imports from a .py file via tree-sitter AST.""" - result = _extract_generic(path, _PYTHON_CONFIG) + result = _extract_generic(path, _PYTHON_CONFIG, scan_root=root) if "error" not in result: _extract_python_rationale(path, result) return result @@ -6580,7 +6603,7 @@ def _safe_extract_with_xaml_root(extractor, path: Path, root: Path) -> dict: previous_root = _XAML_ACTIVE_EXTRACT_ROOT _XAML_ACTIVE_EXTRACT_ROOT = root.resolve() try: - return _safe_extract(extractor, path) + return _safe_extract(extractor, path, scan_root=root) finally: _XAML_ACTIVE_EXTRACT_ROOT = previous_root diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 22f669dabc..c016961ab2 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -3459,7 +3459,8 @@ def _lua_is_require_call(node, source: bytes) -> bool: def _extract_generic( - path: Path, config: LanguageConfig, *, source_override: bytes | None = None + path: Path, config: LanguageConfig, *, source_override: bytes | None = None, + scan_root: Path | None = None, ) -> dict: """Generic AST extractor driven by LanguageConfig. @@ -3707,7 +3708,14 @@ def walk(node, parent_class_nid: str | None = None) -> None: # Import types if t in config.import_types: if config.import_handler: - imported_modules = config.import_handler(node, source, file_nid, stem, edges, str_path, scope_stack) + if config.ts_module == "tree_sitter_python": + imported_modules = config.import_handler( + node, source, file_nid, stem, edges, str_path, scope_stack, scan_root + ) + else: + imported_modules = config.import_handler( + node, source, file_nid, stem, edges, str_path, scope_stack + ) # Module-level import handlers (Swift) name a module, not a file # path, so there is no pre-existing node to anchor the edge to. # They return (id, label) pairs for which we materialize a diff --git a/tests/test_python_import_resolution.py b/tests/test_python_import_resolution.py index 0efd37e057..3a7d578fa0 100644 --- a/tests/test_python_import_resolution.py +++ b/tests/test_python_import_resolution.py @@ -122,6 +122,61 @@ def test_absolute_package_import_targets_package_init(tmp_path: Path): assert "pkg_sub" not in import_targets +def test_plain_absolute_import_targets_package_module(tmp_path: Path): + package_init = _write(tmp_path / "pkg/__init__.py", "") + subpackage_init = _write(tmp_path / "pkg/sub/__init__.py", "") + consumer_path = _write(tmp_path / "app.py", "import pkg.sub\n") + + result = extract( + [package_init, subpackage_init, consumer_path], + cache_root=tmp_path, + root=tmp_path, + parallel=False, + ) + + consumer = _node_id(result, "app.py", "app.py") + subpackage = _node_id(result, "__init__.py", "pkg/sub/__init__.py") + assert _has_edge(result, consumer, subpackage, "imports") + + +def test_absolute_import_does_not_resolve_above_scan_root(tmp_path: Path): + scan_root = tmp_path / "scan" + source = _write(scan_root / "app.py", "from outside_pkg import thing\n") + _write(tmp_path / "outside_pkg/__init__.py", "") + _write(tmp_path / "outside_pkg/thing.py", "def run():\n return 1\n") + + result = extract( + [source], cache_root=tmp_path / "cache", root=scan_root, parallel=False + ) + + app = _node_id(result, "app.py", "app.py") + targets = { + edge["target"] + for edge in result["edges"] + if edge["source"] == app and edge["relation"] == "imports_from" + } + assert targets == {"outside_pkg"} + + +def test_absolute_from_import_keeps_namespace_package_submodule_edge(tmp_path: Path): + namespace_package = tmp_path / "namespace_pkg" + namespace_package.mkdir() + submodule = _write( + namespace_package / "subspace/worker.py", "def run():\n return 1\n" + ) + consumer_path = _write( + tmp_path / "app.py", "from namespace_pkg.subspace import worker\n" + ) + + result = extract( + [consumer_path, submodule], cache_root=tmp_path, root=tmp_path, parallel=False + ) + + consumer = _node_id(result, "app.py", "app.py") + worker = _node_id(result, "worker.py", "namespace_pkg/subspace/worker.py") + assert _has_edge(result, consumer, worker, "imports_from") + + def test_python_package_reexport_resolves_import_and_call_to_origin_symbol(tmp_path: Path): origin = _write(tmp_path / "pkg/foo.py", "def Foo():\n return 1\n") barrel = _write(tmp_path / "pkg/__init__.py", "from .foo import Foo as PublicFoo\n") From 238c9df3d0ceaa5c7a0bdb68bd937bf437153f83 Mon Sep 17 00:00:00 2001 From: Ha1baraA11 <243105435+Ha1baraA11@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:31:23 +0800 Subject: [PATCH 3/4] fix: guard ambiguous absolute Python imports --- graphify/extract.py | 260 ++++++++++++++++++++- graphify/extractors/resolution.py | 31 ++- tests/test_python_import_resolution.py | 33 ++- tests/test_src_layout_import_resolution.py | 214 +++++++++++++++++ 4 files changed, 521 insertions(+), 17 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index ebbaf52bf3..f261d2f967 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -202,6 +202,169 @@ def _file_node_id(rel_path: Path) -> str: return _make_id(_file_stem(rel_path)) +def _python_absolute_import_alias_files(paths, root) -> tuple[dict[str, set[str]], set[str]]: + """Map importable absolute module ids to their scanned file paths. + + A file can be importable from more than one sys.path root inside the scan + root (for example ``a/src/pkg/mod.py`` as either ``pkg.mod`` or + ``src.pkg.mod``). Probe each in-root, non-package ancestor the same way the + shared resolver does. Keeping every candidate lets the extraction passes + fail closed when the same absolute module name identifies multiple files. + """ + try: + root = Path(root).resolve() + except OSError: + root = Path(root) + + package_dirs: dict[Path, bool] = {} + + def _is_package(path: Path) -> bool: + if path not in package_dirs: + package_dirs[path] = (path / "__init__.py").is_file() + return package_dirs[path] + + aliases: dict[str, set[str]] = {} + scan_root_aliases: set[str] = set() + for raw_path in paths: + p = Path(raw_path) + if p.suffix.lower() != ".py": + # _resolve_python_module_path probes .py, not a lone .pyi file. + continue + try: + p = p.resolve() + rel = p.relative_to(root) + except (ValueError, OSError, RuntimeError): + continue + + module_path = p.parent if p.name == "__init__.py" else p.with_suffix("") + candidate_roots = [root] + for ancestor in p.parents: + if ancestor == root: + break + try: + ancestor.relative_to(root) + except ValueError: + break + if not _is_package(ancestor): + candidate_roots.append(ancestor) + + for candidate_root in candidate_roots: + try: + module_rel = module_path.relative_to(candidate_root) + except ValueError: + continue + if not module_rel.parts or not all(part.isidentifier() for part in module_rel.parts): + continue + module_name = ".".join(module_rel.parts) + candidate = candidate_root.joinpath(*module_rel.parts) + try: + hit = _probe_python_module_candidate(candidate) + if hit is None or hit.resolve() != p: + continue + except (OSError, RuntimeError): + continue + module_id = _make_id(module_name) + aliases.setdefault(module_id, set()).add(str(p)) + if candidate_root == root: + # The shared resolver probes the scan root before importer + # ancestors. A unique root hit is therefore authoritative even + # if a nested, independently importable tree has the same name. + scan_root_aliases.add(module_id) + return aliases, scan_root_aliases + + +def _suppress_ambiguous_python_imports( + edges: list[dict], + nodes: list[dict], + raw_calls: list[dict], + ambiguous_modules: set[str], +) -> 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. + """ + 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() + kept_edges: list[dict] = [] + for edge in edges: + if not isinstance(edge, dict): + kept_edges.append(edge) + continue + module_name = edge.pop("_python_import_module", None) + bindings = edge.pop("_python_import_bindings", []) + is_module_binding = edge.pop("_python_import_module_binding", False) + marker_only = edge.pop("_python_import_marker_only", False) + if not module_name: + if not marker_only: + kept_edges.append(edge) + continue + 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 + if is_module_binding: + if module_is_ambiguous: + ambiguous_bindings_by_file.setdefault( + str(edge.get("source_file", "")), set() + ).update(local for _, local in bindings) + else: + for imported, local in bindings: + if imported == "*": + module_prefix = _make_id(module_name) + "_" + if ( + module_is_ambiguous + or any(name.startswith(module_prefix) for name in ambiguous_modules) + ): + ambiguous_all_call_files.add( + str(edge.get("source_file", "")) + ) + continue + if ( + module_is_ambiguous + or _make_id(f"{module_name}.{imported}") in ambiguous_modules + ): + ambiguous_bindings_by_file.setdefault( + str(edge.get("source_file", "")), set() + ).add(local) + if marker_only: + continue + if not module_is_ambiguous: + kept_edges.append(edge) + continue + # A raw dotted alias could itself belong to an unrelated real node. Use + # a stable, node-less id so the graph builder drops the unresolved edge + # instead of fabricating a link to that node. + identity = "\0".join(( + str(module_name), str(edge.get("source_file", "")), + str(edge.get("source_location", "")), str(edge.get("source", "")), + )) + target = _make_id(f"ambiguous_python_import_{hashlib.sha1(identity.encode()).hexdigest()[:12]}") + while target in node_ids: + target += "_" + edge["target"] = target + edge.pop("target_file", None) + kept_edges.append(edge) + + edges[:] = kept_edges + + for raw_call in raw_calls: + source_file = str(raw_call.get("source_file", "")) + if source_file in ambiguous_all_call_files: + raw_call["_ambiguous_python_import"] = True + continue + bindings = ambiguous_bindings_by_file.get(source_file) + if not bindings: + continue + callee = str(raw_call.get("callee", "")) + receiver = str(raw_call.get("receiver", "")) + receiver_root = receiver.split(".", 1)[0] + if callee in bindings or receiver_root in bindings: + raw_call["_ambiguous_python_import"] = True + + def _repoint_python_package_imports(paths, all_nodes, all_edges, root) -> None: """Repoint Python absolute-import edges to the real file node under a nested (e.g. ``src/``) package root (#2072). @@ -433,6 +596,32 @@ def _resolve_name(node, source: bytes, config: LanguageConfig) -> str | None: # ── Import handlers ─────────────────────────────────────────────────────────── +def _python_import_bindings(node, source: bytes) -> list[tuple[str, str]]: + """Return (imported name, local binding) pairs from a Python import node.""" + bindings: list[tuple[str, str]] = [] + past_import = False + for child in node.children: + if child.type == "import": + past_import = True + continue + if not past_import: + continue + if child.type == "dotted_name": + imported = _read_text(child, source) + bindings.append((imported, imported.split(".")[-1])) + elif child.type == "aliased_import": + name_node = child.child_by_field_name("name") + alias_node = child.child_by_field_name("alias") + if name_node is None: + continue + imported = _read_text(name_node, source) + local = _read_text(alias_node, source) if alias_node is not None else imported.split(".")[-1] + bindings.append((imported, local)) + elif child.type == "wildcard_import": + bindings.append(("*", "*")) + return bindings + + def _import_python( node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str, scope_stack: list[str] | None = None, scan_root: Path | None = None, @@ -454,19 +643,15 @@ def _import_python( raw = _read_text(child, source) raw_module, _, raw_alias = raw.partition(" as ") module_name = raw_module.strip().lstrip(".") + local_binding = raw_alias.strip() if raw_alias else module_name.split(".")[0] target_path = _resolve_python_module_path( module_name, current_path, root, level=0 ) - # The importer-relative resolver can find a module under one - # nested sys.path root even when the scan contains another file - # with the same dotted name under a different root. Keep those - # cases for the root-wide alias pass, which refuses ambiguous - # aliases; use a direct scan-root hit here. - root_target = _resolve_python_module_path( - module_name, root, root, level=0 - ) - if root_target is None or target_path != root_target: - target_path = None + # Keep the target-file stamp even for a nested sys.path root so + # incremental extraction can canonicalize imports whose target + # is not in this batch. The corpus-wide ambiguity guard runs + # before symbol resolution and clears this edge when another + # scanned file claims the same absolute module name. if target_path is not None: try: if target_path.resolve() == current_path: @@ -487,6 +672,9 @@ def _import_python( "source_file": str_path, "source_location": f"L{node.start_point[0] + 1}", "weight": 1.0, + "_python_import_module": module_name, + "_python_import_bindings": [(module_name, local_binding)], + "_python_import_module_binding": True, } if target_path is not None: edge["target_file"] = str(target_path) @@ -526,6 +714,20 @@ def _import_python( if target_path is None and _resolve_python_namespace_dir( raw, current_path, root, level=0 ) is not None: + # Namespace packages have no file node for the named + # package, but their imported names can still bind to + # ambiguous submodules. Carry a private marker so the + # corpus-wide guard can suppress false symbol/call edges; + # it is removed before any graph resolver sees the edges. + edges.append({ + "source": file_nid, + "target": "", + "relation": "_python_import_marker", + "source_file": str_path, + "_python_import_module": raw, + "_python_import_bindings": _python_import_bindings(node, source), + "_python_import_marker_only": True, + }) return if target_path is not None: try: @@ -546,6 +748,9 @@ def _import_python( "source_location": f"L{node.start_point[0] + 1}", "weight": 1.0, } + if not raw.startswith("."): + edge["_python_import_module"] = raw + edge["_python_import_bindings"] = _python_import_bindings(node, source) # Stamp the resolved target file (mirroring _import_js, #1814) so # the #2169 remap pass can canonicalize this edge's target on an # incremental run where the target file itself is not in the @@ -3685,6 +3890,8 @@ def _emit_call(caller: str, target_nid: "str | None", rc: dict) -> None: }) for rc in all_raw_calls: + if rc.get("_ambiguous_python_import"): + continue if not rc.get("is_member_call"): continue receiver = rc.get("receiver") @@ -6936,6 +7143,24 @@ def extract( elif cache_root is not None: root = cache_root root = root.resolve() + python_scan_paths = list(paths) + for context_node in resolution_context_nodes or []: + source_file = context_node.get("source_file") + if not source_file or Path(source_file).suffix.lower() != ".py": + continue + context_path = Path(source_file) + if not context_path.is_absolute(): + context_path = root / context_path + if context_path.is_file(): + python_scan_paths.append(context_path) + python_module_alias_files, scan_root_aliases = _python_absolute_import_alias_files( + python_scan_paths, root + ) + ambiguous_python_modules = { + module_id + for module_id, module_paths in python_module_alias_files.items() + if len(module_paths) > 1 and module_id not in scan_root_aliases + } # #1774: the cache is an OUTPUT, so when no explicit cache_root is given it is # written under the current working directory — never `root` (the inferred @@ -7169,7 +7394,13 @@ def _describe_syntax_error(rel: str, line: "int | None", kept: int) -> str: # marker set in the per-file extractor. Populated just before the pass that uses it. callable_nids: set[str] = set() - _augment_symbol_resolution_edges(paths, all_nodes, all_edges, root) + _suppress_ambiguous_python_imports( + all_edges, all_nodes, all_raw_calls, ambiguous_python_modules + ) + _augment_symbol_resolution_edges( + paths, all_nodes, all_edges, root, + ambiguous_python_modules=ambiguous_python_modules, + ) # Merge a header-declared class (and its methods) with its sibling-impl # definition into ONE node (C/C++/ObjC #1547/#1556). Runs BEFORE the id-remap @@ -7645,7 +7876,10 @@ def _learn(e: dict) -> None: if py_paths: py_results = [r for r, p in zip(per_file, paths) if p.suffix == ".py"] try: - cross_file_edges = _resolve_cross_file_imports(py_results, py_paths, all_nodes, all_edges) + cross_file_edges = _resolve_cross_file_imports( + py_results, py_paths, all_nodes, all_edges, + ambiguous_python_modules=ambiguous_python_modules, + ) all_edges.extend(cross_file_edges) except Exception as exc: import logging @@ -7859,6 +8093,8 @@ def _looks_like_bash(result: object) -> bool: _JS_TS_CALL_SUFFIXES = (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs") _go_module_cache: dict[Path, str | None] = {} for rc in all_raw_calls: + if rc.get("_ambiguous_python_import"): + continue callee = rc.get("callee", "") if not callee: continue diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 54206d84ff..97f04210a0 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -2312,7 +2312,9 @@ def _collect_python_symbol_resolution_facts( paths: list[Path], root: Path, facts: _SymbolResolutionFacts, + ambiguous_python_modules: set[str] | None = None, ) -> None: + ambiguous_python_modules = ambiguous_python_modules or set() py_paths = [path for path in paths if path.suffix == ".py"] if not py_paths: return @@ -2332,6 +2334,8 @@ def _collect_python_symbol_resolution_facts( if module is None: continue level, module_name = module + if level == 0 and _make_id(module_name) in ambiguous_python_modules: + continue target_path = _resolve_python_module_path(module_name, path, root, level) if target_path is not None: # #1146: `from pkg import submod` — if the target is a package @@ -2355,6 +2359,15 @@ 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: + imported_module = ( + f"{module_name}.{imported_name}" + if module_name else imported_name + ) + if ( + level == 0 + and _make_id(imported_module) in ambiguous_python_modules + ): + continue facts.module_imports.append((path, submodule, line, local_name)) continue if target_path is None: @@ -2399,10 +2412,14 @@ def _augment_symbol_resolution_edges( nodes: list[dict], edges: list[dict], root: Path, + ambiguous_python_modules: set[str] | None = None, ) -> None: facts = _SymbolResolutionFacts() _collect_js_symbol_resolution_facts(paths, facts) - _collect_python_symbol_resolution_facts(paths, root, facts) + _collect_python_symbol_resolution_facts( + paths, root, facts, + ambiguous_python_modules=ambiguous_python_modules, + ) _apply_symbol_resolution_facts(paths, nodes, edges, root, facts) def _resolve_cross_file_imports( @@ -2410,6 +2427,7 @@ def _resolve_cross_file_imports( paths: list[Path], all_nodes: list[dict] | None = None, all_edges: list[dict] | None = None, + ambiguous_python_modules: set[str] | None = None, ) -> list[dict]: """ Two-pass import resolution: turn file-level imports into class-level edges. @@ -2429,6 +2447,7 @@ def _resolve_cross_file_imports( import tree_sitter_python # noqa: F401 (availability check only) except ImportError: return [] + ambiguous_python_modules = ambiguous_python_modules or set() # Pass 1: _file_stem(path) → {ClassName: node_id} # Keyed by directory-qualified stem (e.g. "auth_models") to avoid collisions @@ -2519,6 +2538,7 @@ def resolve_import(node) -> None: # importing file's directory; absolute imports fall back to the # bare-stem secondary index (first-writer-wins when names collide). target_fq: str | None = None + absolute_module: str | None = None for child in node.children: if child.type == "relative_import": prefix_text = "" @@ -2540,6 +2560,9 @@ def resolve_import(node) -> None: break if child.type == "dotted_name" and target_fq is None: dotted_name = _text(child) + absolute_module = dotted_name + if _make_id(dotted_name) in ambiguous_python_modules: + return dotted_as_path = "/".join(dotted_name.split(".")) if dotted_as_path in stem_to_entities: target_fq = dotted_as_path @@ -2578,6 +2601,12 @@ def resolve_import(node) -> None: local_name = _text(alias_node) if alias_node is not None else imported_name if not imported_name or not local_name: continue + if ( + absolute_module is not None + and _make_id(f"{absolute_module}.{imported_name}") + in ambiguous_python_modules + ): + continue tgt_nid = stem_to_entities[target_fq].get(imported_name) if tgt_nid: import_targets[local_name] = tgt_nid diff --git a/tests/test_python_import_resolution.py b/tests/test_python_import_resolution.py index 3a7d578fa0..9d120422c7 100644 --- a/tests/test_python_import_resolution.py +++ b/tests/test_python_import_resolution.py @@ -139,9 +139,30 @@ def test_plain_absolute_import_targets_package_module(tmp_path: Path): assert _has_edge(result, consumer, subpackage, "imports") +def test_nested_plain_import_target_is_stamped_for_incremental_remap(tmp_path: Path): + """A changed importer can target an unchanged module outside its batch.""" + _write(tmp_path / "src/pkg/__init__.py", "") + target = _write(tmp_path / "src/pkg/sub/__init__.py", "") + app_path = _write(tmp_path / "src/pkg/app.py", "import pkg.sub\n") + + result = extract( + [app_path], cache_root=tmp_path / "cache", root=tmp_path, parallel=False + ) + + app = next( + node["id"] for node in result["nodes"] if node.get("label") == "app.py" + ) + target_id = "src_pkg_sub_init" + assert _has_edge(result, app, target_id, "imports") + assert target.is_file() + + def test_absolute_import_does_not_resolve_above_scan_root(tmp_path: Path): scan_root = tmp_path / "scan" - source = _write(scan_root / "app.py", "from outside_pkg import thing\n") + source = _write( + scan_root / "app.py", + "from outside_pkg import thing\nimport outside_pkg\n", + ) _write(tmp_path / "outside_pkg/__init__.py", "") _write(tmp_path / "outside_pkg/thing.py", "def run():\n return 1\n") @@ -151,11 +172,15 @@ def test_absolute_import_does_not_resolve_above_scan_root(tmp_path: Path): app = _node_id(result, "app.py", "app.py") targets = { - edge["target"] + (edge["relation"], edge["target"]) for edge in result["edges"] - if edge["source"] == app and edge["relation"] == "imports_from" + if edge["source"] == app + and edge["relation"] in ("imports", "imports_from") + } + assert targets == { + ("imports", "outside_pkg"), + ("imports_from", "outside_pkg"), } - assert targets == {"outside_pkg"} def test_absolute_from_import_keeps_namespace_package_submodule_edge(tmp_path: Path): diff --git a/tests/test_src_layout_import_resolution.py b/tests/test_src_layout_import_resolution.py index beeb6b95b0..c37a298d2e 100644 --- a/tests/test_src_layout_import_resolution.py +++ b/tests/test_src_layout_import_resolution.py @@ -126,6 +126,220 @@ def test_ambiguous_package_alias_is_not_repointed(tmp_path): ) +def test_scan_root_module_wins_over_nested_same_name(tmp_path): + """A nested duplicate must not shadow the resolver's scan-root-first hit.""" + app_path = tmp_path / "app.py" + root_module = tmp_path / "pkg.py" + nested_module = tmp_path / "nested" / "pkg.py" + app_path.write_text("from pkg import Thing\n", encoding="utf-8") + root_module.write_text("class Thing:\n pass\n", encoding="utf-8") + nested_module.parent.mkdir(parents=True) + nested_module.write_text("class Thing:\n pass\n", encoding="utf-8") + + result = extract( + [app_path, root_module, nested_module], + cache_root=tmp_path / "cache", + root=tmp_path, + parallel=False, + ) + app_id = next( + node["id"] for node in result["nodes"] if node.get("label") == "app.py" + ) + root_module_id = next( + node["id"] for node in result["nodes"] + if node.get("label") == "pkg.py" and node.get("source_file") == "pkg.py" + ) + nested_module_id = next( + node["id"] for node in result["nodes"] + if node.get("label") == "pkg.py" and node.get("source_file") == "nested/pkg.py" + ) + targets = { + edge["target"] for edge in result["edges"] + if edge.get("source") == app_id and edge.get("relation") == "imports_from" + } + + assert root_module_id in targets + assert nested_module_id not in targets + + +def test_ambiguous_absolute_from_import_does_not_bind_symbols_or_calls(tmp_path): + """An importer-relative hit must not bypass corpus-wide module ambiguity.""" + def write_file(path: Path, content: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return path + + paths = [] + for sub in ("a", "b"): + pkg = tmp_path / sub / "src" / "pkg" + pkg.mkdir(parents=True) + paths.append(write_file(pkg / "__init__.py", "")) + paths.append(write_file( + pkg / "mod.py", + "class Thing:\n pass\n\ndef run():\n return 1\n", + )) + app_path = write_file( + tmp_path / "a" / "src" / "pkg" / "app.py", + "from pkg.mod import Thing, run\n\n" + "def invoke():\n return Thing(), run()\n", + ) + paths.append(app_path) + + result = extract( + paths, cache_root=tmp_path / "cache", root=tmp_path, parallel=False + ) + + app = next( + node["id"] for node in result["nodes"] + if node.get("label") == "app.py" + ) + module_targets = { + node["id"] for node in result["nodes"] + if node["id"].startswith(("a_src_pkg_mod", "b_src_pkg_mod")) + } + app_edges = [ + edge for edge in result["edges"] + if edge.get("source", "").startswith("a_src_pkg_app") + ] + + assert any( + edge.get("source") == app and edge.get("relation") == "imports_from" + for edge in app_edges + ), "keep the unresolved import evidence" + assert not any( + edge.get("target") in module_targets + and edge.get("relation") in ("imports", "imports_from", "calls", "uses") + for edge in app_edges + ), f"ambiguous module was resolved to one src tree: {app_edges}" + + +def test_ambiguous_namespace_submodule_does_not_bind_calls(tmp_path): + """Namespace-package submodule imports must use the same ambiguity guard.""" + def write_file(path: Path, content: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return path + + paths = [] + for sub in ("a", "b"): + paths.append(write_file( + tmp_path / sub / "src" / "pkg" / "mod.py", + "def run():\n return 1\n", + )) + app = write_file( + tmp_path / "a" / "src" / "app.py", + "from pkg import mod\n\ndef invoke():\n return mod.run()\n", + ) + paths.append(app) + + result = extract( + paths, cache_root=tmp_path / "cache", root=tmp_path, parallel=False + ) + module_targets = { + node["id"] for node in result["nodes"] + if node["id"].startswith(("a_src_pkg_mod", "b_src_pkg_mod")) + } + app_edges = [ + edge for edge in result["edges"] + if edge.get("source", "").startswith("a_src_app") + ] + + assert not any( + edge.get("target") in module_targets + and edge.get("relation") in ("imports", "imports_from", "calls", "uses") + for edge in app_edges + ), f"namespace submodule was resolved to one src tree: {app_edges}" + + +def test_ambiguous_star_import_does_not_bind_calls(tmp_path): + """An ambiguous wildcard import must not enable proximity-based call picks.""" + def write_file(path: Path, content: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return path + + paths = [] + for sub in ("a", "b"): + pkg = tmp_path / sub / "src" / "pkg" + write_file(pkg / "__init__.py", "") + paths.append(write_file( + pkg / "mod.py", "def run():\n return 1\n" + )) + app_path = write_file( + tmp_path / "a" / "src" / "pkg" / "app.py", + "from pkg.mod import *\n\ndef invoke():\n return run()\n", + ) + paths.append(app_path) + + result = extract( + paths, cache_root=tmp_path / "cache", root=tmp_path, parallel=False + ) + module_targets = { + node["id"] for node in result["nodes"] + if node["id"].startswith(("a_src_pkg_mod", "b_src_pkg_mod")) + } + app_edges = [ + edge for edge in result["edges"] + if edge.get("source", "").startswith("a_src_pkg_app") + ] + + assert not any( + edge.get("target") in module_targets + and edge.get("relation") in ("imports", "imports_from", "calls", "uses") + for edge in app_edges + ), f"ambiguous star import resolved to one src tree: {app_edges}" + + +def test_incremental_ambiguous_import_uses_unchanged_python_context(tmp_path): + """Unchanged files in resolver context still participate in ambiguity checks.""" + def write_file(path: Path, content: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return path + + for sub in ("a", "b"): + write_file( + tmp_path / sub / "src" / "pkg" / "mod.py", + "class Thing:\n pass\n\ndef run():\n return 1\n", + ) + app_path = write_file( + tmp_path / "a" / "src" / "pkg" / "app.py", + "from pkg.mod import Thing, run\n\n" + "def invoke():\n return Thing(), run()\n", + ) + context_nodes = [] + for sub in ("a", "b"): + source_file = f"{sub}/src/pkg/mod.py" + prefix = f"{sub}_src_pkg_mod" + context_nodes.extend([ + {"id": prefix, "label": "mod.py", "source_file": source_file, + "file_type": "code"}, + {"id": f"{prefix}_thing", "label": "Thing", + "source_file": source_file, "file_type": "code"}, + {"id": f"{prefix}_run", "label": "run()", + "source_file": source_file, "file_type": "code"}, + ]) + + result = extract( + [app_path], cache_root=tmp_path / "cache", root=tmp_path, parallel=False, + resolution_context_nodes=context_nodes, + ) + app_edges = [ + edge for edge in result["edges"] + if edge.get("source", "").startswith("a_src_pkg_app") + ] + module_targets = { + node["id"] for node in context_nodes + if "_src_pkg_mod" in node["id"] + } + + assert not any( + edge.get("target") in module_targets + and edge.get("relation") in ("imports", "imports_from", "calls", "uses") + for edge in app_edges + ), f"incremental import resolved to one unchanged src tree: {app_edges}" + + def test_non_python_import_edge_is_not_repointed(tmp_path): """#2072 review: the alias map is Python-only, but a non-Python import edge whose dangling target coincides with a Python alias must NOT be repointed From 61e3b8fcbcaa0418e21634da3f65915bfaa8b139 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 4/4] fix: preserve loose sibling import resolution --- 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 f261d2f967..eba1e05ff1 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -278,16 +278,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): @@ -304,7 +335,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( @@ -7395,7 +7434,8 @@ def _describe_syntax_error(rel: str, line: "int | None", kept: int) -> str: 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 2784ddf700..7652b2d146 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)