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.
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_statementderives 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_initsuffix, so the emitted id matches no node, the edge dangles, and the package's own__init__.pynode 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, theimport_from_statementbranch (L347-375 in 0.9.55):So
pkg.subbecomes the idpkg_sub, while the node forpkg/sub/__init__.pyispkg_sub_init.Minimal reproducer
Five files, no LLM, ~1 second:
Observed
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 thoughpkg/consumer.pyimports it.pkg_sub_initends with 1 instead of 2.Expected
pkg_consumer --imports_from--> pkg_initanduser --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__.pyandoilwatch/connectors/__init__.pyboth finish with zero in-edges, so "who imports this package?" is unanswerable from the graph andexplain/affectedunder-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
import mod) — the suggested fix matches a candidate node byPath(source_file).stem == module_name, which cannot match a package: the stem of__init__.pyis__init__.mod.func()calls edges — 177/177 unresolved on a real repo (explains #2428 §3's unexplained 42%) #2447 Cause A — the plainimport modbranch (import_statement), where the module isn't at the scan root. This is theimport_from_statementbranch, and the target is a package rather than a module.from <pkg> import <submodule>binds to the package__init__.pyand drops the submodule + allmod.attr()calls when the importer is outside the package tree #3272 — the opposite outcome: there the edge binds to the real__init__.pynode and no edge dangles, while the submodule edge is dropped. Here the submodule edge is emitted correctly and the package edge dangles.Suggested direction
Mirror the relative arm: probe/resolve the absolute dotted name to a path (as the companion
importsedge's_resolve_python_module_pathalready 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.