diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca5b9ff1..7c7d34e58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixes + +- Python calls made through a renamed import are no longer missing from the graph. After `from package import module as alias`, a call like `alias.func()` produced no call edge, so `codegraph_callers` could report a function as uncalled while a live caller reached it under the alias — the same wrong answer to "is this dead code?" that the unaliased form used to give. Thanks @JoeyNPP. (#1626) + ## [1.6.0] - 2026-08-26 diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index decaadee5..dfb09840d 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -1504,6 +1504,56 @@ def external_caller(): expect(externalCalls).toHaveLength(0); }); + it('resolves Python module-attribute calls through an ALIASED import (#1626)', async () => { + // #715 taught resolvePythonModuleMember to fall back to a dotted-module + // file lookup, which fixed `from pkg import module` (#578). The aliased + // form still missed: the module path was rebuilt from the LOCAL name, so + // `from pkg import module as alias` looked for `pkg.alias` — a file that + // does not exist — and the call landed in unresolved_refs. The plain + // `import top as alias` form is a namespace import and binds at `source`, + // so it was already correct; it is pinned here so the fix can't regress it. + 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, 'top_level.py'), + 'def top_func():\n return 2\n' + ); + fs.writeFileSync( + path.join(tempDir, 'main.py'), + `from pkg import module as mod_alias +import top_level as tl + + +def from_import_caller(): + return mod_alias.func() + + +def plain_import_caller(): + return tl.top_func() +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const fromImportCaller = cg.getNodesByKind('function').filter((n) => n.name === 'from_import_caller')[0]; + expect(fromImportCaller).toBeDefined(); + const aliasCalls = cg.getOutgoingEdges(fromImportCaller!.id).filter((e) => e.kind === 'calls'); + expect(aliasCalls).toHaveLength(1); + const aliasTarget = cg.getNode(aliasCalls[0]!.target); + expect(aliasTarget?.name).toBe('func'); + expect(aliasTarget?.filePath.replace(/\\/g, '/')).toBe('pkg/module.py'); + + const plainCaller = cg.getNodesByKind('function').filter((n) => n.name === 'plain_import_caller')[0]; + expect(plainCaller).toBeDefined(); + const plainCalls = cg.getOutgoingEdges(plainCaller!.id).filter((e) => e.kind === 'calls'); + expect(plainCalls).toHaveLength(1); + expect(cg.getNode(plainCalls[0]!.target)?.name).toBe('top_func'); + }); + it('attaches Go methods to their receiver type across files (#583, cross-file half)', async () => { // In Go a type's methods are commonly declared in a different file from the // `type` declaration (`type Box` in box.go, `func (b *Box) Get()` in diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 60c7b3008..0e7a4212a 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -1622,11 +1622,19 @@ function resolvePythonModuleMember( // `import mod` / `import numpy as np` bind the module at `source` itself; // `from . import certs` / `from pkg import mod` bind a SUBMODULE whose // dotted path is the source joined with the imported name. + // + // Join with the EXPORTED name, not the local one: under + // `from pkg import mod as alias` the receiver is `alias` but the module on + // disk is `pkg.mod`, and building `pkg.alias` looked for a file that does + // not exist — so the aliased form dropped its `calls` edge while the plain + // form (where the two names coincide) worked (#1626). For an unaliased + // import the two are identical, so this changes nothing there. + const moduleName = imp.exportedName === '*' ? imp.localName : imp.exportedName; const modulePath = imp.isNamespace ? imp.source : imp.source.endsWith('.') - ? imp.source + imp.localName - : imp.source + '.' + imp.localName; + ? imp.source + moduleName + : imp.source + '.' + moduleName; // resolveImportPath only maps RELATIVE dotted paths (`.mod`, `..pkg.mod`); an // ABSOLUTE package path (`pkg.module` from `from pkg import module`, or a bare