Repository: colbymchenry/codegraph (2026-06-07T06:10:06Z)

Issue:
Python: codegraph_callers misses module-attribute call sites (module.func(...) after `from pkg import module`) — zero recall on a common test/namespacing pattern
# Python: `codegraph_callers` misses module-attribute call sites (`module.func(...)` after `from pkg import module`) — zero recall on a common test/namespacing pattern

## Summary

On Python, a call made through a **module object attribute** — `module.func(...)` where `module` was bound via `import pkg.module as module` or `from pkg import module` — produces **no `calls` edge**, so `codegraph_callers` / `callees` / `impact` / `trace` return empty for the target. Both the caller node and the callee node are correctly indexed; only the edge between them is dropped.

This is the **same root cause class** confirmed and fixed for Go in #388 / #469 (`isExternalImport` → `resolveViaImport` returning `null` → fall-through), and analogous to the per-language import-resolution fixes already shipped for Java/Kotlin (#314), TypeScript (#359), and C# (#381). **Python was never covered.** This was flagged in a comment on #388, but since that issue was scoped and closed as Go-specific, it has no dedicated tracking — hence this issue.

## Environment

- `@colbymchenry/codegraph@0.9.7` (the release containing the Go fix #469 `f1b79ee`, Java #314, TS #359, C# #381)
- Linux
- Index freshly rebuilt under 0.9.7 (`codegraph init` re-run immediately before querying — not a stale 0.9.4 index)
- Project: real-world Python codebase (RAGFlow fork), 881 Python files indexed, 46k nodes / 108k edges

## Minimal reproduction

```
repro/
  pkg/
    __init__.py
    mod.py          # def helper(x): return x
  tests/
    test_mod.py
```

`pkg/mod.py`:
```python
def helper(x):
    return x
```

`tests/test_mod.py` — both common module-attribute forms:
```python
from pkg import mod              # form 1: from-import of the module
import pkg.mod as mod2           # form 2: aliased module import

def test_helper():
    assert mod.helper(1) == 1    # attribute access on module object
    assert mod2.helper(2) == 2
```

**Expected:** `codegraph_callers helper` → 2 callers (the two `*.helper(...)` calls in `test_mod.py`).
**Observed:** `No callers found`.

Compare with the form that *does* work today:
```python
from pkg.mod import helper       # bare-name import
def test_helper():
    assert helper(1) == 1        # bare call → edge IS created
```

So the gap is specifically the **qualified/attribute call shape**, exactly mirroring Go's `pkg.Func(...)`.

## Production evidence (this is not a toy-only failure)

In the RAGFlow codebase, `dialog_service._repair_grounded_citation_alignment` is called by **9 regression tests** via module-attribute access:

```python
# test/unit_test/api/db/services/test_dialog_service_prompt_sanitization.py
from api.db.services import dialog_service      # line 21
...
answer, cited = dialog_service._repair_grounded_citation_alignment(...)   # lines 1766, 1786, 1805, 1822
answer = dialog_service._preserve_grounded_reference_labels(...)          # lines 1730, 1747, 1770, 1840, 1857
```

Graph state on the fresh 0.9.7 index:

| Node | Indexed? | Evidence |
|---|---|---|
| `_repair_grounded_citation_alignment` (callee) | ✅ | `codegraph_search` → `dialog_service.py:754` |
| `test_repair_grounded_citation_alignment_*` (callers) | ✅ | `codegraph_search` → `test_dialog_service_prompt_sanitization.py:1757/1777/1796/1815` |
| `calls` edge between them | ❌ | `codegraph_callers _repair_grounded_citation_alignment` → **"No callers found"** |

Same result for `_preserve_grounded_reference_labels`. Both ends exist; the edge does not.

**Real-world impact:** `codegraph_callers` reporting zero callers led to a (correctly caught, but only via textual grep) near-miss where these functions looked like dead code. For any user relying on `callers`/`impact` to make "is this safe to delete?" decisions, this is a silent high-recall miss. The pattern (`from pkg import module` then `module.helper(...)`) is extremely common in Python test suites that use module-attribute access for namespacing and ergonomic monkeypatching.

## Hypothesis

Parallel to the #469 Go root cause: the Python resolver likely resolves a call's qualifier (`module`) to the imported module, but the qualified-member lookup (`module.helper` → the `helper` def node) either isn't attempted for the `module.attr(...)` call shape or fails to match because the target's stored name is the bare `helper` and the resolver doesn't strip/relate the module qualifier. Edge is then silently dropped (no fall-through to a bad match, just nothing — consistent with the clean ~0 recall).

## Ask

1. Can the Python import/call resolver get the same treatment Go received in #469 — resolve `module.func(...)` attribute calls (where `module` is a `from pkg import module` / `import pkg.module as module` binding) to the exported member node and emit the `calls` edge?
2. Both `from pkg import module` and `import pkg.module as module` qualifier forms should be covered, plus re-exported aggregator modules.
3. Happy to run targeted queries against the local `.codegraph/codegraph.db` (e.g. dump `edges` filtered to the test file) to validate any fix or hypothesis.

## Related

- #388 — Go: same symptom, fixed Go-only in #469 (`f1b79ee`). This Python case was noted in a comment there but the issue was closed as Go-specific.
- #314 — Java/Kotlin import resolution (fixed)
- #359 — TypeScript false/missing edges (fixed)
- #381 — C# missing reference edges (fixed)



Discussion:
Thanks for `04f36970` ("resolve Python module-attribute calls to submodule members") — that closes the `mod.helper()` / imported-submodule half of this issue.

One thing to flag so it doesn't get marked fully resolved: that commit explicitly **excludes `ClassName.method()` and `cls.method()`** (class/instance-attribute calls), and those are the cases that actually under-report for us in practice.

**Version-anchored evidence (0.9.9):** `codegraph_callers` on a `@staticmethod` returned **6 of 10** real call sites. The 6 found were `Class.method(...)` / `cls.method(...)` style; the **4 missed** were all class-attribute calls too:
- a `ClassName.method(...)` call inside an ordinary named function (not a comprehension),
- a `cls.method(...)` call, and
- two calls inside **list comprehensions** (`[ClassName.method(x) for x in …]`).

So the same `ast.Attribute` receiver problem you just fixed for modules still applies to **class names and `cls`**, and is compounded inside comprehension scopes.

**Minimal repro:**
```python
# a.py
class Foo:
    @staticmethod
    def bar(): ...
    @classmethod
    def viacls(cls): return cls.bar()          # cls.method()

# b.py
from a import Foo
def named():     return Foo.bar()              # ClassName.method() in a function
xs = [Foo.bar() for _ in range(3)]             # ClassName.method() in a comprehension
```
Expected: `codegraph_callers bar` → `viacls`, `named`, and the comprehension site. Observed at 0.9.9: the class-attribute sites are under-reported.

Is class-attribute (`ClassName.method` / `cls.method`) resolution tracked under this issue, or should it be split out?

Fixed on `main` via #715 — shipping in the next release. Python now records the `calls` edge for a function invoked through an imported module (`from pkg import module`, or `import pkg.module as module`, then `module.func()`), so `codegraph_callers` / `codegraph_callees` / impact on the target resolve instead of coming back empty.

Root cause was exactly the class you pointed at: the member resolver bailed when the absolute package path (`pkg.module`) didn't resolve, while the file→file import edge had a fallback it lacked — now both share it. Verified against your repro and the aliased-import form; relative imports and stdlib modules (`import os`) are unaffected. I'll update here when the release is out — thanks for the precise report and the #388 pointer.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 06de7752f..e63b7d152 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -56,6 +56,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 - Python now follows whole-module imports — `from . import certs` then `certs.where()`, or `from pkg import sub` then `sub.run()`. Calls and attribute access through an imported submodule now resolve to that submodule, and importing a module is recorded as a dependency on it even when the member you use is itself re-exported from a third-party package. This also fixed Python relative-import path resolution generally (`from .sub.mod import x`), so `codegraph affected` and impact see the real module graph of a package.
 - Python now also links a whole-module **absolute** import (`import conduit.apps.signals`) to that module's file, not just `from x import y`. A module imported by its dotted path — common in package setup and side-effect imports — is no longer reported as having no dependents. Standard-library imports (`import os`) correctly create no edge. (Python)
 - Python `from package import submodule` now links to that submodule's file, resolved through the import's package so it lands on the right one when same-named modules exist in sibling packages (the FastAPI / Django router-aggregator pattern: `from app.api.routes import authentication` with an unrelated `authentication.py` elsewhere). So a route/handler module pulled in only by an aggregator is no longer reported as having no dependents. (Python)
+- Python now records the actual call edge for a function invoked through an imported module — `from pkg import module` (or `import pkg.module`) followed by `module.func()`, a common testing and namespacing pattern. Previously only the module-level dependency was tracked, so `codegraph_callers`, `codegraph_callees`, and impact on the target function came back empty even though the import itself resolved. (#578) (Python)
 - Django `include('app.urls')` now records a dependency from the project URLconf onto the included app's `urls.py`, so an app's routes module is no longer reported as having no dependents and editing it surfaces the project that mounts it. (Django)
 - A chained method call (`builder.Services.AddCoreServices(...)`) now resolves to its definition. Previously only a single-segment receiver (`obj.method()`) resolved, so a call through a property/member chain — very common for C# extension methods like ASP.NET dependency-injection registration (`AddCoreServices`/`AddWebServices`) and Guard clauses — found no definition. (C#, and any language with chained calls)
 - A renamed default import (`import articlesController from './article.controller'` where the module does `export default router`) now records a dependency on the imported module. Previously only named imports linked, so a module consumed only through a default import — the standard Express/NestJS route-controller pattern — looked like nothing depended on it. External packages (`import React from 'react'`) still create no edge. (TypeScript/JavaScript)
diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts
index fd3388858..77a78834d 100644
--- a/src/resolution/import-resolver.ts
+++ b/src/resolution/import-resolver.ts
@@ -1277,7 +1277,17 @@ function resolvePythonModuleMember(
         ? imp.source + imp.localName
         : imp.source + '.' + imp.localName;
 
-    const resolvedPath = resolveImportPath(modulePath, ref.filePath, ref.language, context);
+    // resolveImportPath only maps RELATIVE dotted paths (`.mod`, `..pkg.mod`); an
+    // ABSOLUTE package path (`pkg.module` from `from pkg import module`, or a bare
+    // `import pkg.mod`) resolves to null there, so fall back to the dotted-module
+    // file lookup — the same asymmetry resolveModuleImportToFile already handles
+    // for the file→file import edge. Without this, a `module.func()` call after
+    // `from pkg import module` dropped its `calls` edge even though the import
+    // edge resolved (#578).
+    let resolvedPath = resolveImportPath(modulePath, ref.filePath, ref.language, context);
+    if (!resolvedPath) {
+      resolvedPath = findPythonModuleFile(modulePath, context, ref.filePath)?.filePath ?? null;
+    }
     if (!resolvedPath || resolvedPath === ref.filePath) continue;
 
     // Find the member as a top-level definition in the module file. Exclude


Test patch:
diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts
index 9d0788fc7..e06d3d3f5 100644
--- a/__tests__/resolution.test.ts
+++ b/__tests__/resolution.test.ts
@@ -853,6 +853,56 @@ func UseAliased() {
       expect(target?.filePath.replace(/\\/g, '/')).toBe('pkgb/lib.go');
     });
 
+    it('resolves Python module-attribute calls after `from pkg import module` (#578)', async () => {
+      // Pre-#578, a `module.func()` call where `module` was bound via
+      // `from pkg import module` dropped its `calls` edge. The file→file import
+      // edge resolved (resolveModuleImportToFile falls back to a dotted-module
+      // file lookup for absolute package paths), but resolvePythonModuleMember
+      // had no such fallback — resolveImportPath returns null for an absolute
+      // package path like `pkg.module`, so the member never resolved and
+      // callers/callees/impact on the target came back empty. Same root-cause
+      // class as the Go cross-package qualified call (#388).
+      fs.mkdirSync(path.join(tempDir, 'pkg'));
+      fs.writeFileSync(path.join(tempDir, 'pkg', '__init__.py'), '');
+      fs.writeFileSync(
+        path.join(tempDir, 'pkg', 'module.py'),
+        'def func():\n    return 1\n'
+      );
+      fs.writeFileSync(
+        path.join(tempDir, 'main.py'),
+        `from pkg import module
+import os
+
+
+def caller():
+    return module.func()
+
+
+def external_caller():
+    return os.getcwd()
+`
+      );
+
+      cg = await CodeGraph.init(tempDir, { index: true });
+
+      const caller = cg.getNodesByKind('function').filter((n) => n.name === 'caller')[0];
+      expect(caller).toBeDefined();
+      const calls = cg.getOutgoingEdges(caller!.id).filter((e) => e.kind === 'calls');
+      // module.func() must resolve to the real function in the submodule file.
+      expect(calls).toHaveLength(1);
+      const target = cg.getNode(calls[0]!.target);
+      expect(target?.name).toBe('func');
+      expect(target?.filePath.replace(/\\/g, '/')).toBe('pkg/module.py');
+
+      // The flip side of the fix: an attribute call through a *stdlib* module
+      // (`os.getcwd()`) must still create no edge — the fallback only matches
+      // real in-repo module files.
+      const externalCaller = cg.getNodesByKind('function').filter((n) => n.name === 'external_caller')[0];
+      expect(externalCaller).toBeDefined();
+      const externalCalls = cg.getOutgoingEdges(externalCaller!.id).filter((e) => e.kind === 'calls');
+      expect(externalCalls).toHaveLength(0);
+    });
+
     it('TS type_alias object-shape members resolve method calls (#359)', async () => {
       // Pre-#359, `recorder.stop()` (recorder: RecorderHandle) attached
       // to `StdioMcpClient.stop` in a sibling directory via path-proximity