From 98af828f160e8e94cdab9b207128bd0cafaeb94b Mon Sep 17 00:00:00 2001 From: JacquesBLR Date: Thu, 6 Aug 2026 10:21:08 +0200 Subject: [PATCH] fix(resolution): resolve direct calls through aliased Python function imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two compounding gaps in `from mod import fn as alias; alias()`: 1. resolveViaImport's generic "reference name matches an import" loop resolved `imp.source` through resolveImportPath, which for Python only maps RELATIVE dotted paths (`.mod`, `..pkg.mod`). An ABSOLUTE dotted source (`from scripts.lire_fec import ...`) returned null there, and unlike resolvePythonModuleMember / resolveModuleImportToFile (#578), this loop had no findPythonModuleFile fallback — so it silently produced no edge for any direct (non-member) call through an absolute-module import, aliased or not. 2. Even once the file resolved, findExportedSymbol filters candidates on `n.isExported` — and the Python extractor never implemented `isExported`, so it was undefined/falsy for every Python symbol. This normally went unnoticed because an unaliased call (`from mod import summarize; summarize()`) still resolves via unrelated same-name fuzzy matching when the name is globally unique — but an aliased direct call has no name to fuzzy-match on, so this generic import path was its only route, and it always failed. Python has no export syntax, so every module/class-level def is importable by name; isExported is now false only for names nested inside a function body (closures). Found via a real project (`from scripts.lire_fec import summarize as fec_summary`, called as `fec_summary()`). --- __tests__/resolution.test.ts | 56 ++++++++++++++++++++++++++++++ src/extraction/languages/python.ts | 22 ++++++++++++ src/resolution/import-resolver.ts | 17 ++++++++- 3 files changed, 94 insertions(+), 1 deletion(-) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index eca1778ff..d066bed40 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -1243,6 +1243,62 @@ def external_caller(): expect(externalCalls).toHaveLength(0); }); + it('resolves a direct call through an aliased Python function import (`from mod import fn as alias; alias()`)', async () => { + // Two compounding gaps, both surfaced on a real project via a + // `from scripts.lire_fec import summarize as fec_summary` import, + // then a bare `fec_summary()` call: + // + // 1. resolveViaImport's generic "reference name matches an import" + // loop resolved `imp.source` through resolveImportPath, which for + // Python only maps RELATIVE dotted paths (`.mod`, `..pkg.mod`). + // An ABSOLUTE dotted source (`scripts.lire_fec`, from `from + // scripts.lire_fec import ...`) returned null there, and unlike + // resolvePythonModuleMember / resolveModuleImportToFile (#578), + // this loop had no findPythonModuleFile fallback — so it silently + // produced no edge for ANY direct (non-member) call through an + // absolute-module import, aliased or not. + // + // 2. Even once the file resolved, findExportedSymbol filters + // candidates on `n.isExported` — and the Python extractor never + // implemented `isExported`, so it was `undefined`/falsy for every + // Python symbol. This normally went unnoticed because an unaliased + // call (`from mod import summarize; summarize()`) still resolves + // via unrelated same-name fuzzy matching when the name is globally + // unique — but an ALIASED direct call has no name to fuzzy-match + // on (`fec_summary` isn't declared anywhere), so this was its only + // path, and it always failed. + fs.mkdirSync(path.join(tempDir, 'scripts')); + fs.writeFileSync(path.join(tempDir, 'scripts', '__init__.py'), ''); + fs.writeFileSync( + path.join(tempDir, 'scripts', 'lire_fec.py'), + 'def summarize():\n return 1\n' + ); + fs.mkdirSync(path.join(tempDir, 'services')); + fs.writeFileSync(path.join(tempDir, 'services', '__init__.py'), ''); + fs.writeFileSync( + path.join(tempDir, 'services', 'assistant.py'), + `from scripts.lire_fec import summarize as fec_summary + + +def build_context(): + fec = fec_summary() + return fec +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const buildContext = cg + .getNodesByKind('function') + .filter((n) => n.name === 'build_context')[0]; + expect(buildContext).toBeDefined(); + const calls = cg.getOutgoingEdges(buildContext!.id).filter((e) => e.kind === 'calls'); + expect(calls).toHaveLength(1); + const target = cg.getNode(calls[0]!.target); + expect(target?.name).toBe('summarize'); + expect(target?.filePath.replace(/\\/g, '/')).toBe('scripts/lire_fec.py'); + }); + 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/extraction/languages/python.ts b/src/extraction/languages/python.ts index 77807d667..b4f0768a7 100644 --- a/src/extraction/languages/python.ts +++ b/src/extraction/languages/python.ts @@ -50,4 +50,26 @@ export const pythonExtractor: LanguageExtractor = { // import_statement creates multiple imports - return null for core fallback return null; }, + isExported: (node) => { + // Python has no export syntax — every module- and class-level def/class is + // importable by name (`from module import _foo` works fine; a leading + // underscore is only a PEP 8 convention, not an enforcement, and `__all__` + // only restricts `import *`). The only names actually unreachable from + // outside the file are ones nested inside a function body (closures). + // Without this, the extractor left `isExported` unset for every Python + // symbol, so `findExportedSymbol`'s `byName` index (import-resolver.ts) was + // always empty for Python — the generic "named import used in a direct + // call" resolution path silently failed for every Python file. Other + // Python-specific paths (resolvePythonModuleMember, resolveModuleImportToFile) + // built their own unfiltered file scans and so masked this everywhere + // except a directly-called aliased function import + // (`from mod import fn as alias; alias()`), which has no other resolution + // path (found on a real project). + let parent = node.parent; + while (parent) { + if (parent.type === 'function_definition') return false; + parent = parent.parent; + } + return true; + }, }; diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 07f18cbb1..6a93159f1 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -1506,13 +1506,28 @@ export function resolveViaImport( for (const imp of imports) { if (imp.localName === ref.referenceName || ref.referenceName.startsWith(imp.localName + '.')) { // Resolve the import path - const resolvedPath = resolveImportPath( + let resolvedPath = resolveImportPath( imp.source, ref.filePath, ref.language, context ); + // Python ABSOLUTE dotted source (`from scripts.lire_fec import summarize + // as fec_summary`, source `scripts.lire_fec`) — resolveImportPath only + // maps RELATIVE dotted paths (`.mod`, `..pkg.mod`) for Python, so it + // returns null here and the whole block used to no-op, dropping the + // call edge for a directly-called (non-member, non-namespace) name + // imported through an absolute module. resolvePythonModuleMember and + // resolveModuleImportToFile already carry this exact fallback for the + // qualified-member and whole-module cases (#578); this loop needs it + // too for a bare aliased-function call (found on a real project: + // `from scripts.lire_fec import summarize as fec_summary`, then a + // bare `fec_summary()`). + if (!resolvedPath && ref.language === 'python') { + resolvedPath = findPythonModuleFile(imp.source, context, ref.filePath)?.filePath ?? null; + } + if (resolvedPath) { const exportedName = imp.isDefault ? 'default' : imp.exportedName; const memberName = imp.isNamespace