Просмотр исходного кода

fix(python): resolve call edges through imported modules (#578) (#715)

Give resolvePythonModuleMember the same absolute-dotted-path fallback that
resolveModuleImportToFile already uses, so a `module.func()` call after
`from pkg import module` / `import pkg.module as module` records its `calls`
edge. Adds a regression test and a CHANGELOG entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby Mchenry 2 недель назад
Родитель
Сommit
8d35931c3b
3 измененных файлов с 62 добавлено и 1 удалено
  1. 1 0
      CHANGELOG.md
  2. 50 0
      __tests__/resolution.test.ts
  3. 11 1
      src/resolution/import-resolver.ts

+ 1 - 0
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)

+ 50 - 0
__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

+ 11 - 1
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