fix(resolution): resolve Python aliased module imports (#1626) (#1785)

* fix(resolution): resolve Python module members through an aliased from-import (#1626)

resolvePythonModuleMember rebuilt the submodule's dotted path by joining the
import source with the LOCAL name. Under 'from pkg import mod as alias' that
produces 'pkg.alias' — a module that does not exist — so the file lookup found
nothing and the call fell through to unresolved_refs with status='failed'.
codegraph_callers then reported the target as having fewer callers than it
does, which is the same wrong 'is this dead code?' answer #578 produced for
the unaliased form.

Join with the exported name instead. For an unaliased import the two names are
identical, so nothing changes there; '*' (the namespace form) keeps using the
local name, which is what it already bound to.

Scope note: the issue also reports 'import top as alias' failing. That form is
a namespace import and binds at source, so it resolves on current main — a
probe against the reverted resolver confirms it already produces its call edge.
The regression test pins both halves so the working one cannot silently break.

Co-Authored-By: Claude <noreply@anthropic.com>
(cherry picked from commit f7a8940e679b5d4093dd2d00412306a6b4800723)

* fix(resolution): restore aliased Python module import edges (#1626)

Use the exported module name in the file-import resolver, matching the member resolver from upstream PR #1635. Keep both aliased call assertions and verify the file-to-file imports edge in the #1626 regression test. Update the Unreleased note to cover file dependencies.

Validation on Node 22.19.0: npm run build; supplied cg1626 repro; vitest run __tests__/resolution.test.ts -t 1626. Pass evidence saved in /workspace/cg1626-PASS.json and /workspace/cg1626-VERIFY.json.

---------

Co-authored-by: Max Hsu <maxmilian@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
This commit is contained in:
Colby Mchenry
2026-09-08 12:55:13 -05:00
committed by GitHub
co-authored by Max Hsu Claude Colby McHenry
parent 2f8cce5c57
commit 7be699cd91
3 changed files with 76 additions and 4 deletions
+59
View File
@@ -1558,6 +1558,65 @@ def add_outcome(row):
expect(buildMapCalls.map((e) => e.target)).not.toContain(ledgerAppend!.id);
});
it('resolves Python module-attribute calls and file imports through an alias (#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');
// The file dependency must resolve too: fixing only the member lookup
// restores calls but leaves the aliased module's imports edge missing.
const mainFile = cg.getNodesByKind('file').find((n) => n.filePath === 'main.py');
const moduleFile = cg.getNodesByKind('file').find((n) => n.filePath.replace(/\\/g, '/') === 'pkg/module.py');
expect(mainFile).toBeDefined();
expect(moduleFile).toBeDefined();
const fileImports = cg.getOutgoingEdges(mainFile!.id).filter((e) => e.kind === 'imports');
expect(fileImports.map((e) => e.target)).toContain(moduleFile!.id);
});
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