From f90aabf9a8d918e2aed5f10cd397994d2f5f4ecf Mon Sep 17 00:00:00 2001 From: Shunsuke Hayashi Date: Mon, 23 Mar 2026 16:33:06 +0900 Subject: [PATCH] fix(python): resolve module-qualified calls via moduleAliasMap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- gitnexus/src/core/ingestion/call-processor.ts | 17 +++++++++- gitnexus/src/core/ingestion/pipeline.ts | 33 +++++++++++++++++-- .../src/core/ingestion/resolution-context.ts | 8 +++++ .../python-module-import/app.py | 1 + .../test/integration/resolvers/python.test.ts | 10 ++++++ 5 files changed, 66 insertions(+), 3 deletions(-) diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 450a18322..dba077a0c 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -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, + call: Pick, 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 diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index ebe23534e..09041f223 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -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) => { + 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; } diff --git a/gitnexus/src/core/ingestion/resolution-context.ts b/gitnexus/src/core/ingestion/resolution-context.ts index 7184bd954..0adf5a731 100644 --- a/gitnexus/src/core/ingestion/resolution-context.ts +++ b/gitnexus/src/core/ingestion/resolution-context.ts @@ -39,6 +39,9 @@ export const TIER_CONFIDENCE: Record = { export type ImportMap = Map>; export type PackageMap = Map>; export type NamedImportMap = Map>; +/** 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>; 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, diff --git a/gitnexus/test/fixtures/lang-resolution/python-module-import/app.py b/gitnexus/test/fixtures/lang-resolution/python-module-import/app.py index 311703852..58b11fe9b 100644 --- a/gitnexus/test/fixtures/lang-resolution/python-module-import/app.py +++ b/gitnexus/test/fixtures/lang-resolution/python-module-import/app.py @@ -3,3 +3,4 @@ import auth u = models.User() a = auth.Admin() +au = auth.User() diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index ef2580392..478f350ac 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -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(); + }); });