fix(python): resolve module-qualified calls via moduleAliasMap

Previously, Python was added to WILDCARD_IMPORT_LANGUAGES which expanded
all exported symbols into namedImportMap using first-seen wins. This caused
`auth.User()` to incorrectly resolve to `models.py:User` when both modules
exported a class named User.

Root cause: Python `import models` is a namespace import, not wildcard
symbol expansion. Expanding all symbols produces ambiguous bindings that
cannot be disambiguated later.

Fix:
- Remove Python from WILDCARD_IMPORT_LANGUAGES
- Add ModuleAliasMap (callerFile → alias → sourceFile) to ResolutionContext
- In synthesizeWildcardImportBindings, build moduleAliasMap for Python
  using the filename stem as the module alias
- In resolveCallTarget, add module-alias disambiguation step: when multiple
  candidates survive filtering and the receiver name matches a module alias,
  narrow candidates to the aliased file

Result: `models.User()` → models.py:User, `auth.User()` → auth.py:User
even when both modules export a class named User.

Adds regression test for the ambiguity case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Shunsuke Hayashi 2026-03-23 16:33:06 +09:00
parent cd1c0ff7dc
commit f90aabf9a8
5 changed files with 66 additions and 3 deletions

View file

@ -600,6 +600,7 @@ export const processCalls = async (
argCount: countCallArguments(callNode),
callForm,
receiverTypeName,
receiverName,
}, file.path, ctx, hints);
if (!resolved) return;
@ -819,7 +820,7 @@ const tryOverloadDisambiguation = (
* If filtering still leaves multiple candidates, refuse to emit a CALLS edge.
*/
const resolveCallTarget = (
call: Pick<ExtractedCall, 'calledName' | 'argCount' | 'callForm' | 'receiverTypeName'>,
call: Pick<ExtractedCall, 'calledName' | 'argCount' | 'callForm' | 'receiverTypeName' | 'receiverName'>,
currentFile: string,
ctx: ResolutionContext,
overloadHints?: OverloadHints,
@ -837,6 +838,20 @@ const resolveCallTarget = (
filteredCandidates = filterCallableCandidates(tiered.candidates, call.argCount, 'constructor');
}
// Module-alias disambiguation: Python `import auth; auth.User()` — when both models.py and
// auth.py export User, receiverName='auth' selects auth.py via moduleAliasMap.
// Runs when multiple candidates survive filtering and the receiver is a known module alias.
if (filteredCandidates.length > 1 && call.callForm === 'member' && call.receiverName) {
const aliasMap = ctx.moduleAliasMap?.get(currentFile);
if (aliasMap) {
const moduleFile = aliasMap.get(call.receiverName);
if (moduleFile) {
const aliasFiltered = filteredCandidates.filter(c => c.filePath === moduleFile);
if (aliasFiltered.length > 0) filteredCandidates = aliasFiltered;
}
}
}
// D. Receiver-type filtering: for member calls with a known receiver type,
// resolve the type through the same tiered import infrastructure, then
// filter method candidates to the type's defining file. Fall back to

View file

@ -111,14 +111,18 @@ const MAX_SYNTHETIC_BINDINGS_PER_FILE = 1000;
/** Languages with whole-module import semantics (no per-symbol named imports).
* For these languages, namedImportMap entries are synthesized from graph-exported
* symbols after parsing, enabling Phase 14 cross-file binding propagation. */
* symbols after parsing, enabling Phase 14 cross-file binding propagation.
*
* Note: Python is intentionally excluded here. `import models` is a namespace import
* (not wildcard symbol expansion) — expanding all exported symbols produces ambiguous
* bindings when multiple modules export the same name (e.g. models.User vs auth.User).
* Python module aliases are built in synthesizeWildcardImportBindings via moduleAliasMap. */
const WILDCARD_IMPORT_LANGUAGES = new Set([
SupportedLanguages.Go,
SupportedLanguages.Ruby,
SupportedLanguages.C,
SupportedLanguages.CPlusPlus,
SupportedLanguages.Swift,
SupportedLanguages.Python, // `import models` imports all exported symbols from modules
]);
/** Synthesize namedImportMap entries for languages with whole-module imports.
@ -205,6 +209,31 @@ function synthesizeWildcardImportBindings(
synthesizeForFile(filePath, importedFiles);
}
// Build module alias map for Python namespace imports.
// `import models` in app.py → ctx.moduleAliasMap['app.py']['models'] = 'models.py'
// Enables `models.User()` to resolve to models.py:User without ambiguous symbol expansion.
const buildPythonModuleAliasForFile = (callerFile: string, importedFiles: Iterable<string>) => {
let aliasMap = ctx.moduleAliasMap.get(callerFile);
for (const importedFile of importedFiles) {
// Derive the module alias from the imported filename stem (e.g. "models.py" → "models")
const lastSlash = importedFile.lastIndexOf('/');
const base = lastSlash >= 0 ? importedFile.slice(lastSlash + 1) : importedFile;
const dot = base.lastIndexOf('.');
const stem = dot >= 0 ? base.slice(0, dot) : base;
if (!stem) continue;
if (!aliasMap) {
aliasMap = new Map();
ctx.moduleAliasMap.set(callerFile, aliasMap);
}
aliasMap.set(stem, importedFile);
}
};
for (const [filePath, importedFiles] of ctx.importMap) {
if (getLanguageFromFilename(filePath) !== SupportedLanguages.Python) continue;
buildPythonModuleAliasForFile(filePath, importedFiles);
}
return totalSynthesized;
}

View file

@ -39,6 +39,9 @@ export const TIER_CONFIDENCE: Record<ResolutionTier, number> = {
export type ImportMap = Map<string, Set<string>>;
export type PackageMap = Map<string, Set<string>>;
export type NamedImportMap = Map<string, Map<string, NamedImportBinding>>;
/** Maps callerFile → (moduleAlias → sourceFilePath) for Python namespace imports.
* e.g. `import models` in app.py → moduleAliasMap.get('app.py')?.get('models') === 'models.py' */
export type ModuleAliasMap = Map<string, Map<string, string>>;
export interface ResolutionContext {
/**
@ -56,6 +59,8 @@ export interface ResolutionContext {
readonly importMap: ImportMap;
readonly packageMap: PackageMap;
readonly namedImportMap: NamedImportMap;
/** Module-alias map for Python namespace imports: callerFile → (alias → sourceFile). */
readonly moduleAliasMap: ModuleAliasMap;
// --- Per-file cache lifecycle ---
enableCache(filePath: string): void;
@ -71,6 +76,7 @@ export const createResolutionContext = (): ResolutionContext => {
const importMap: ImportMap = new Map();
const packageMap: PackageMap = new Map();
const namedImportMap: NamedImportMap = new Map();
const moduleAliasMap: ModuleAliasMap = new Map();
// Per-file cache state
let cacheFile: string | null = null;
@ -173,6 +179,7 @@ export const createResolutionContext = (): ResolutionContext => {
importMap.clear();
packageMap.clear();
namedImportMap.clear();
moduleAliasMap.clear();
clearCache();
cacheHits = 0;
cacheMisses = 0;
@ -184,6 +191,7 @@ export const createResolutionContext = (): ResolutionContext => {
importMap,
packageMap,
namedImportMap,
moduleAliasMap,
enableCache,
clearCache,
getStats,

View file

@ -3,3 +3,4 @@ import auth
u = models.User()
a = auth.Admin()
au = auth.User()

View file

@ -1615,4 +1615,14 @@ describe('Python module import CALLS resolution (Issue #337)', () => {
);
expect(adminCall).toBeDefined();
});
it('resolves auth.User() CALLS edge to auth.py:User (not models.py:User) when both modules export User', () => {
// Both models.py and auth.py export a class named User.
// auth.User() must resolve to auth.py:User — the receiver module disambiguates.
const calls = getRelationships(result, 'CALLS');
const authUserCall = calls.find(c =>
c.target === 'User' && c.targetFilePath === 'auth.py',
);
expect(authUserCall).toBeDefined();
});
});