Summary
When two modules sit in a subdirectory and one calls the other through a module alias
(import mod_a as m … m.target()), the calls edge between the two functions is not created.
The same code at the repository root produces the edge correctly, and a from mod_a import target call produces the edge in both locations.
Notably graphify does resolve the module — the imports edge is created in the failing case —
so the import resolution succeeds and only the call linkage is lost.
Version 0.9.46 · Python 3.12.10 · Windows 11.
Minimal reproduction
Three files, all under a scripts/ directory:
scripts/mod_a.py
scripts/caller_direct.py — positive control
from mod_a import target
def go_direct():
return target()
scripts/caller_alias.py — the failing case
import mod_a as m
def go_alias():
return m.target()
Then:
git init . && git add -A && git commit -m init
graphify update .
Result
All edges produced (graphify-out/graph.json, 6 nodes / 7 links):
scripts_caller_direct_go_direct --calls--> scripts_mod_a_target <-- control, OK
scripts_caller_alias --imports--> scripts_mod_a <-- module IS resolved
scripts_caller_direct --imports--> scripts_mod_a_target
scripts_caller_direct --imports_from--> scripts_mod_a
scripts_caller_alias --contains--> scripts_caller_alias_go_alias
scripts_caller_direct --contains--> scripts_caller_direct_go_direct
scripts_mod_a --contains--> scripts_mod_a_target
- expected
scripts_caller_alias_go_alias --calls--> scripts_mod_a_target — absent
scripts_caller_direct_go_direct --calls--> scripts_mod_a_target — present
The discriminating variable is the subdirectory
Placing the same three files at the repository root instead produces 6 nodes / 8 links, and
both call edges are present:
caller_alias_go_alias --calls--> mod_a_target <-- present at root
caller_direct_go_direct --calls--> mod_a_target
So import X as Y + Y.foo() is handled correctly when X resolves at the root, and silently
loses the call edge when the modules are siblings inside a package-less subdirectory (the
layout you get from a plain scripts/ folder that is on sys.path at runtime).
Impact
This is quiet rather than loud — no warning is emitted, both endpoint nodes exist, and the graph
looks healthy. It only shows up when you ask a question that needs the edge.
In a real repository (~29k nodes), one alias-heavy module measured 18 distinct boundary.X()
calls, 17/18 callee nodes present, 0/18 call edges. graphify query "who calls describe_strand_origin" returned the module-internal neighbourhood and none of the three actual
production callers, because the edges had never been built. Measured call-edge coverage over
that subtree was 10.9% of the real call sites; adding the aliased edges in a post-pass raised it
to 14.4% and made the query return all three callers with correct file:line.
Codebases that use import module as alias for internal modules under a subdirectory are
affected across the board, so "who calls X" and impact analysis under-report silently.
Possible direction
Since the imports edge is created in the failing case, the module binding appears to be known
at the point the call is visited; the call-site resolution seems not to consult that binding
when the target module was found via the subdirectory/sibling path rather than the root path.
Happy to test a patch against the real repository — I have both the minimal case and a
~29k-node graph that exercises it.
Summary
When two modules sit in a subdirectory and one calls the other through a module alias
(
import mod_a as m…m.target()), thecallsedge between the two functions is not created.The same code at the repository root produces the edge correctly, and a
from mod_a import targetcall produces the edge in both locations.Notably graphify does resolve the module — the
importsedge is created in the failing case —so the import resolution succeeds and only the call linkage is lost.
Version
0.9.46· Python 3.12.10 · Windows 11.Minimal reproduction
Three files, all under a
scripts/directory:scripts/mod_a.pyscripts/caller_direct.py— positive controlscripts/caller_alias.py— the failing caseThen:
Result
All edges produced (
graphify-out/graph.json, 6 nodes / 7 links):scripts_caller_alias_go_alias --calls--> scripts_mod_a_target— absentscripts_caller_direct_go_direct --calls--> scripts_mod_a_target— presentThe discriminating variable is the subdirectory
Placing the same three files at the repository root instead produces 6 nodes / 8 links, and
both call edges are present:
So
import X as Y+Y.foo()is handled correctly whenXresolves at the root, and silentlyloses the call edge when the modules are siblings inside a package-less subdirectory (the
layout you get from a plain
scripts/folder that is onsys.pathat runtime).Impact
This is quiet rather than loud — no warning is emitted, both endpoint nodes exist, and the graph
looks healthy. It only shows up when you ask a question that needs the edge.
In a real repository (~29k nodes), one alias-heavy module measured 18 distinct
boundary.X()calls, 17/18 callee nodes present, 0/18 call edges.
graphify query "who calls describe_strand_origin"returned the module-internal neighbourhood and none of the three actualproduction callers, because the edges had never been built. Measured call-edge coverage over
that subtree was 10.9% of the real call sites; adding the aliased edges in a post-pass raised it
to 14.4% and made the query return all three callers with correct
file:line.Codebases that use
import module as aliasfor internal modules under a subdirectory areaffected across the board, so "who calls X" and impact analysis under-report silently.
Possible direction
Since the
importsedge is created in the failing case, the module binding appears to be knownat the point the call is visited; the call-site resolution seems not to consult that binding
when the target module was found via the subdirectory/sibling path rather than the root path.
Happy to test a patch against the real repository — I have both the minimal case and a
~29k-node graph that exercises it.