Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
50 changes: 50 additions & 0 deletions __tests__/resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions src/resolution/import-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down