Skip to content

fix(resolution): resolve Python module-attribute calls to submodule members (#578)#587

Open
maxmilian wants to merge 1 commit into
colbymchenry:mainfrom
maxmilian:fix/578-python-module-attribute-calls
Open

fix(resolution): resolve Python module-attribute calls to submodule members (#578)#587
maxmilian wants to merge 1 commit into
colbymchenry:mainfrom
maxmilian:fix/578-python-module-attribute-calls

Conversation

@maxmilian
Copy link
Copy Markdown

@maxmilian maxmilian commented May 31, 2026

Summary

Fixes #578. On Python, a call through an imported module — mod.helper(...) where mod was bound via from pkg import mod or import pkg.mod as mod — produced no calls edge, so codegraph_callers / callees / impact / trace returned empty for the target even though both ends were indexed. The pattern is common in test suites and namespacing, and a missing edge can make a still-used function look like dead code.

This is the same root-cause class already fixed for Go (#388/#469), Java/Kotlin (#314), TypeScript (#359), and C# (#381) — Python was never covered.

Root cause

In resolveViaImport, the generic import-matching loop maps the receiver's import source to the package (pkg), then looks for the member there. For a module-attribute call the member (helper) lives in the submodule file (pkg/mod.py), so the lookup misses and the edge is silently dropped. The bare-name form (from pkg.mod import helper; helper()) was unaffected because it resolves by plain name.

Fix

Add a Python branch in resolveViaImport (mirroring the Go/Java branches) → resolvePythonModuleAttributeReference:

  1. Split the qualified reference into receiver + member.
  2. Find the import whose localName is the receiver, and derive its dotted module path:
    • from pkg import modpkg.mod
    • import pkg.mod as mpkg.mod
  3. Resolve the member by an exact root-relative module-path match (file-path stem == dotted path, with .py / /__init__ normalized). The exact match doubles as the disambiguation signal — a same-named member in a different module can't be picked by mistake.

No isExported filter for Python (it has no export keyword; module-level defs are importable and their nodes carry isExported: false).

Tests (__tests__/resolution.test.ts)

  • from pkg import mod; mod.helper() resolves to pkg/mod.py.
  • import pkg.mod as m; m.helper() resolves to pkg/mod.py.
  • No-regression guard: bare-name from pkg.mod import helper; helper() still resolves.
  • Disambiguation: two modules export helper; the call through a.mod lands on a/mod.py only — asserts exactly one edge and the correct target (no wrong edge).

Full suite green locally (npm test — 1098 passing).

Scope (intentionally deferred)

To keep the change minimal and sound, these are not handled here and left as follow-ups (all degrade to "no edge", never a wrong edge):

  • Re-exported aggregator modules (pkg/__init__.py does from .mod import helper, caller does import pkg; pkg.helper()) — needs __init__ re-export following.
  • Unaliased dotted call import pkg.mod then pkg.mod.helper() (multi-segment member) — bailed by the single-level member guard.
  • Relative imports from . import mod — needs resolving . against the file's package dir.
  • src-rooted layouts where the import path omits a project prefix — deliberately requires an exact root-relative match rather than a loose suffix guess, to avoid wrong edges.

Two known, narrow limitations (documented in code, not wrong-edge in normal use):

  • Receiver shadowing: keyed on the import binding only, so from pkg import mod; mod = X(); mod.f() could over-attribute — the resolver can't distinguish a shadowing local from the module. Same assumption the Go/Java resolvers make; rare in practice.
  • pkg/mod.py vs pkg/mod/__init__.py both present: both normalize to the same stem, so a member found in both would resolve to whichever is found first. This is an ambiguous import Python itself would conflict on.

Follow-up

Re-export aggregator support is the most valuable next step and could reuse the existing re-export chain-following machinery; happy to do it in a separate PR if you'd like it in scope.

@maxmilian maxmilian marked this pull request as ready for review May 31, 2026 12:03
…embers (colbymchenry#578)

A call through an imported module — `mod.helper(...)` where `mod` was
bound via `from pkg import mod` or `import pkg.mod as mod` — produced no
`calls` edge. The resolver mapped the receiver to the package, not the
submodule file, so the member lookup missed and callers/callees/impact/
trace returned empty for the target (a still-used function could look
like dead code).

Mirror the Go cross-package resolver (colbymchenry#388/colbymchenry#469): map the module-attribute
receiver to its module path and resolve the member by an exact
root-relative module-path match — which also disambiguates same-named
members in different modules. Covers both `from pkg import mod` and
`import pkg.mod as mod` forms.

Tests: the two required forms, a bare-name no-regression guard, and a
cross-module same-name disambiguation case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@maxmilian maxmilian force-pushed the fix/578-python-module-attribute-calls branch from ae76ca2 to 0112e61 Compare May 31, 2026 12:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant