Skip to content

fix(python): resolve module-qualified calls from inside class methods - #2366

Open
rijefff wants to merge 1 commit into
Graphify-Labs:v8from
rijefff:fix/python-member-call-in-class-method
Open

fix(python): resolve module-qualified calls from inside class methods#2366
rijefff wants to merge 1 commit into
Graphify-Labs:v8from
rijefff:fix/python-member-call-in-class-method

Conversation

@rijefff

@rijefff rijefff commented Aug 1, 2026

Copy link
Copy Markdown

Summary

In _resolve_python_member_calls() (graphify/extract.py), qualified calls of the form
module.func() resolve only when the caller is a module-level function. When the same
call appears inside a class method, no calls edge is produced.

The module-alias arm bails because caller_file is None.

Reproduction

# pkg/util.py
def helper(x):
    return x + 1
# app.py
from pkg import util
from pkg.util import helper


class Machine:
    def method_qualified(self):
        return util.helper(1)      # <-- NO calls edge (bug)

    def method_bare(self):
        return helper(1)           # <-- ok


def function_qualified():
    return util.helper(1)          # <-- ok
caller shape calls edge on main
function_qualified() module-level + module.attr yes
Machine.method_bare() class method + bare name yes
Machine.method_qualified() class method + module.attr no

Root cause

file_of_node is populated only from contains edges:

caller_file = file_of_node.get(caller)
mods = [t for t in imported_by_filenode.get(caller_file, ()) ...]
if len(mods) != 1:
    continue

Methods are linked to their class through the method relation, not contains, so
methods get no entry in file_of_node. caller_file is therefore None,
imported_by_filenode.get(None, ()) is empty, and the arm continues.

Confirmed by instrumenting the resolver: caller_file=None for Machine.method_qualified(),
while function_qualified() resolves to its file.

Fix

  1. Populate file_of_node from method edges (class -> method) as well.
  2. Walk transitively method -> class -> file when resolving caller_file.

16 added lines, no existing line changed.

Test

tests/test_python_member_calls.py (new, follows the existing
test_<lang>_member_calls.py naming). It asserts the previously missing edge and the
two shapes that already worked, so the fix cannot silently regress them.

  • Without this change: failsassert _has_edge(result, method_qualified, helper, "calls") -> False
  • With this change: passes

Full suite

pytest tests/ on this branch (base v8): 3651 passed, 14 failed, 170 skipped.

The 14 failures are pre-existing and environmental, not caused by this change — verified by a
control run: reverting only the patch and re-running the full suite produces the same 14
failures
, and comm of the two sorted failure lists is empty. They are test_terraform.py
(7, tree_sitter_hcl not installed), test_ollama_retry_cap.py (4), and one each in
test_extract.py, test_install_references.py, test_manifest_ingest.py.

Impact

Measured on a private ~272-file Python codebase where most functions are methods on classes:

metric before after
total calls edges 5,436 8,191 (+51%)
cross-module edges between two core modules 0 17

On that repository's full graph the change adds +2,755 edges (+22.9%).

The failure is silent: the graph simply reports fewer callers, so an agent can conclude
"nothing else calls this" when callers do exist.

Related

Similar resolver gaps are open for other languages: #1313 (Go), #2135 (Rust), #1682 (PHP).
I did not find an existing issue for the Python class-method case.

Not covered here

from pkg import util as u2 inside a method (lazy import + alias) still produces no edge —
the alias is bound in function scope rather than file scope. Left out deliberately to keep this
change small; happy to file it separately.

`_resolve_python_member_calls()` looked `caller_file` up in `file_of_node`,
which was populated from `contains` edges only. Methods reach their file
through the `method` relation (class -> method) instead, so `caller_file`
was `None` for every method and the module-alias arm bailed out.

Result: `module.func()` produced a `calls` edge when written in a
module-level function, but never when written inside a class method.

Two-part fix:
1. Populate `file_of_node` from `method` edges as well, not just `contains`.
2. Walk transitively method -> class -> file when resolving `caller_file`.

Adds tests/test_python_member_calls.py, which fails without this change
and passes with it. The two shapes that already worked (bare-name call
from a method, qualified call from a module-level function) are asserted
too so the fix cannot regress them.
@rijefff
rijefff force-pushed the fix/python-member-call-in-class-method branch from 4c3c3cc to 195e6ed Compare August 1, 2026 09:08
@rijefff
rijefff changed the base branch from main to v8 August 1, 2026 09:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants