From 7be699cd917dd5ff13ab7c274a7b28a01293b96f Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 8 Sep 2026 12:55:13 -0500 Subject: [PATCH] fix(resolution): resolve Python aliased module imports (#1626) (#1785) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 (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 Co-authored-by: Claude Co-authored-by: Colby McHenry --- CHANGELOG.md | 2 ++ __tests__/resolution.test.ts | 59 +++++++++++++++++++++++++++++++ src/resolution/import-resolver.ts | 19 +++++++--- 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b90cdd7..5529bcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -251,6 +251,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Fixed a long-running `codegraph ui` session serving a symbol that a sync had already deleted. The viewer keeps one connection to your index open, and its in-memory lookup didn't notice when another process — your agent's sync, or `codegraph sync` — rewrote the file underneath it, so a symbol screen could keep showing a body with no callers while search correctly reported it had moved. Because a symbol's identity includes the line it starts on, this happened after almost any edit above it. +- Python calls and file dependencies through `from package import module as alias` now appear in the graph, so renamed imports no longer hide live callers or imported modules. Thanks @JoeyNPP. (#1626) + ## [1.6.0] - 2026-08-26 ### Highlights diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 5142edc..7c697a3 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -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 diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index aa173a2..3d9eb3e 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -1692,11 +1692,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 @@ -1884,9 +1892,12 @@ function resolveModuleImportToFile( modulePath = imp.source; } else if (ref.language === 'python') { // `from . import certs` — the imported NAME is a submodule of the source. + // As in resolvePythonModuleMember, use the exported name so an alias + // still links to the real module file (#1626). + const moduleName = imp.exportedName === '*' ? imp.localName : imp.exportedName; modulePath = imp.source.endsWith('.') - ? imp.source + imp.localName - : imp.source + '.' + imp.localName; + ? imp.source + moduleName + : imp.source + '.' + moduleName; } else { // A named TS/JS import binds a symbol, not a module — leave it alone. continue;