Skip to content

Cross-module calls edge is dropped for aliased imports when the modules live in a subdirectory #2943

Description

@jankeydadondc-byte

Summary

When two modules sit in a subdirectory and one calls the other through a module alias
(import mod_a as mm.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

def target():
    return 1

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_targetabsent
  • 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.

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