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
56 changes: 56 additions & 0 deletions __tests__/resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions src/extraction/languages/python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
};
17 changes: 16 additions & 1 deletion src/resolution/import-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down