Skip to content

Python: _import_python's absolute import arm skips the path resolution the relative arm does, so package __init__.py nodes lose their in-edges #3723

Description

@waynegault

Version: graphifyy 0.9.55 (pipx), Python 3.12, Linux (WSL). AST only (--code-only, --no-cluster), no LLM.
Also reproduced on: a real 110-file Python package (~52 modules + ~53 test modules).

Summary

The absolute arm of import_from_statement derives its edge target from the raw dotted module name with no attempt to resolve it to a file. For a package target the file node id carries an _init suffix, so the emitted id matches no node, the edge dangles, and the package's own __init__.py node loses the in-edge its importer produced.

The relative arm of the same handler already resolves the target to a path, and its comment cites #2455 for precisely this failure mode ("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"). The absolute arm has no equivalent, so the two arms disagree about how to build a target id.

Location

graphify/extract.py, _import_python, the import_from_statement branch (L347-375 in 0.9.55):

elif t == "import_from_statement":
    module_node = node.child_by_field_name("module_name")
    if module_node:
        raw = _read_text(module_node, source)
        target_path: "Path | None" = None
        if raw.startswith("."):
            # Relative import - resolve to full path so IDs match file node IDs
            ...
            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:
                rel = (module_name.replace(".", "/") + ".py") if module_name else "__init__.py"
                target_path = base / rel
            tgt_nid = _make_id(str(target_path))   # path-derived: a package keeps _init
        else:
            tgt_nid = _make_id(raw)                # <-- dotted-name-derived, no probing

So pkg.sub becomes the id pkg_sub, while the node for pkg/sub/__init__.py is pkg_sub_init.

Minimal reproducer

Five files, no LLM, ~1 second:

pkg/__init__.py            (empty)
pkg/sub/__init__.py        (empty)
pkg/sub/thing.py           def run(): return 1
pkg/consumer.py            from pkg import sub            + def go(): return sub.thing.run()
user.py                    from pkg.sub import thing      + def go(): return thing.run()
graphify extract /tmp/initprobe --code-only --no-cluster --out /tmp/initprobe-out

Observed

NODES:
  pkg_consumer        consumer.py          pkg/consumer.py:L1
  pkg_consumer_go     go()                 pkg/consumer.py:L4
  pkg_init            pkg/__init__.py      pkg/__init__.py:L1
  pkg_sub_init        sub/__init__.py      pkg/sub/__init__.py:L1
  pkg_sub_thing       thing.py             pkg/sub/thing.py:L1
  pkg_sub_thing_run   run()                pkg/sub/thing.py:L1
  user                user.py              user.py:L1
  user_go             go()                 user.py:L4

EDGES:
  pkg_consumer --imports_from--> pkg            <== DANGLING ENDPOINT
  pkg_consumer --contains--> pkg_consumer_go
  pkg_sub_thing --contains--> pkg_sub_thing_run
  user --imports_from--> pkg_sub                <== DANGLING ENDPOINT
  user --contains--> user_go
  pkg_consumer --imports_from--> pkg_sub_init
  user --imports_from--> pkg_sub_thing
  user_go --calls--> pkg_sub_thing_run

2 of 8 edges dangle, both to a package path id missing _init. Note each statement also emits a correct edge to the resolved submodule (pkg_sub_init, pkg_sub_thing) — so this is a redundant malformed second edge, not a replacement.

pkg_init — the package's own __init__.py — ends with zero in-edges, even though pkg/consumer.py imports it. pkg_sub_init ends with 1 instead of 2.

Expected

pkg_consumer --imports_from--> pkg_init and user --imports_from--> pkg_sub_init, or no package-target edge at all. Either way, no dangling endpoint.

Impact on a real corpus

A 110-file Python package produces 3 dangling package ids: oilwatch (10 edges), oilwatch_connectors (2), oilwatch_connectors_suppliers (4) — 16 edge-ends, out of 424 dangling edge-ends in total (the rest are stdlib/third-party). Because every importer of a package lands on the non-existent id, oilwatch/__init__.py and oilwatch/connectors/__init__.py both finish with zero in-edges, so "who imports this package?" is unanswerable from the graph and explain/affected under-report. Both modules also read as unreferenced, which is how I found it.

Worth noting these are in-repo dangling endpoints, not external refs — the signal #2191 asked to keep distinguishable from ref_* noise.

Why the existing reports don't cover it

Suggested direction

Mirror the relative arm: probe/resolve the absolute dotted name to a path (as the companion imports edge's _resolve_python_module_path already does) and build the id from the resolved path, so a package target keeps _init. A test asserting "no dangling endpoints in a fixture containing an absolute package import" would pin it.

Happy to test a patch against the real corpus and report before/after connectivity.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions