From cb1293b71835cfbf723295e69bb9bc83b20e33c0 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Mon, 23 Mar 2026 09:36:33 +0000 Subject: [PATCH] fix(python): route module aliases directly to moduleAliasMap in import processor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `import models as m` aliases were stored in namedImportMap (a symbol-binding map) then cross-referenced in pipeline.ts — semantic misuse and inefficient. Refactored: NamedBinding gains `isModuleAlias` flag. applyImportResult routes tagged bindings directly to moduleAliasMap at import time. Removes the pipeline.ts post-processing loop entirely. Added test fixture and 5 integration tests for `import X as Y` with multi-module disambiguation (both models.py and auth.py export User). --- .../src/core/ingestion/import-processor.ts | 27 ++++++++-- .../src/core/ingestion/import-resolution.ts | 6 ++- .../ingestion/named-binding-extraction.ts | 23 +++------ .../python-plain-import-alias/app.py | 12 +++++ .../python-plain-import-alias/auth.py | 3 ++ .../python-plain-import-alias/models.py | 7 +++ .../test/integration/resolvers/python.test.ts | 51 +++++++++++++++++++ 7 files changed, 108 insertions(+), 21 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/python-plain-import-alias/app.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-plain-import-alias/auth.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-plain-import-alias/models.py diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index eef7543da..b6330e2d2 100644 --- a/gitnexus/src/core/ingestion/import-processor.ts +++ b/gitnexus/src/core/ingestion/import-processor.ts @@ -10,7 +10,7 @@ import { getTreeSitterBufferSize } from './constants.js'; import { loadImportConfigs } from './language-config.js'; import { buildSuffixIndex } from './resolvers/index.js'; import { callRouters } from './call-routing.js'; -import type { ResolutionContext } from './resolution-context.js'; +import type { ResolutionContext, ModuleAliasMap } from './resolution-context.js'; import type { SuffixIndex } from './resolvers/index.js'; import { importResolvers, namedBindingExtractors, preprocessImportPath } from './import-resolution.js'; import type { ImportResult, ResolveCtx, NamedBinding } from './import-resolution.js'; @@ -101,6 +101,7 @@ function createImportEdgeHelpers(graph: KnowledgeGraph, importMap: ImportMap) { * Apply an ImportResult: emit graph edges and update ImportMap/PackageMap. * If namedBindings are provided and the import resolves to a single file, * also populate the NamedImportMap for precise Tier 2a resolution. + * Bindings tagged with `isModuleAlias` are routed to moduleAliasMap instead. */ function applyImportResult( result: ImportResult, @@ -111,6 +112,7 @@ function applyImportResult( addImportGraphEdge: (from: string, to: string) => void, namedBindings?: NamedBinding[], namedImportMap?: NamedImportMap, + moduleAliasMap?: ModuleAliasMap, ): void { if (!result) return; @@ -128,6 +130,21 @@ function applyImportResult( addImportEdge(filePath, resolvedFile); } + // Route module aliases (import X as Y) directly to moduleAliasMap. + // These are module-level aliases, not symbol bindings — they don't belong in namedImportMap. + if (namedBindings && moduleAliasMap && files.length === 1) { + const resolvedFile = files[0]; + for (const binding of namedBindings) { + if (!binding.isModuleAlias) continue; + let aliasMap = moduleAliasMap.get(filePath); + if (!aliasMap) { + aliasMap = new Map(); + moduleAliasMap.set(filePath, aliasMap); + } + aliasMap.set(binding.local, resolvedFile); + } + } + // Record named bindings for precise Tier 2a resolution. // If the same local name is imported from multiple files (e.g., Java static imports // of overloaded methods), remove the entry so resolution falls through to Tier 2a @@ -139,6 +156,7 @@ function applyImportResult( if (files.length === 1) { const resolvedFile = files[0]; for (const binding of namedBindings) { + if (binding.isModuleAlias) continue; // already routed to moduleAliasMap const existing = fileBindings.get(binding.local); if (existing && existing.sourcePath !== resolvedFile) { fileBindings.delete(binding.local); @@ -151,6 +169,7 @@ function applyImportResult( // Match each binding to a resolved file by comparing the lowercase binding name // to the file's basename (without extension). If no match, skip the binding. for (const binding of namedBindings) { + if (binding.isModuleAlias) continue; const lowerName = binding.exported.toLowerCase(); const matchedFile = files.find(f => { const base = f.replace(/\\/g, '/').split('/').pop() ?? ''; @@ -187,6 +206,7 @@ export const processImports = async ( const importMap = ctx.importMap; const packageMap = ctx.packageMap; const namedImportMap = ctx.namedImportMap; + const moduleAliasMap = ctx.moduleAliasMap; // Use allPaths (full repo) when available for cross-chunk resolution, else fall back to chunk files const allFileList = allPaths ?? files.map(f => f.path); const allFilePaths = new Set(allFileList); @@ -285,7 +305,7 @@ export const processImports = async ( const result = importResolvers[language](rawImportPath, file.path, resolveCtx); const extractor = namedBindingExtractors[language]; const bindings = namedImportMap && extractor ? extractor(captureMap['import']) : undefined; - applyImportResult(result, file.path, importMap, packageMap, addImportEdge, addImportGraphEdge, bindings, namedImportMap); + applyImportResult(result, file.path, importMap, packageMap, addImportEdge, addImportGraphEdge, bindings, namedImportMap, moduleAliasMap); } // ---- Language-specific call-as-import routing (Ruby require, etc.) ---- @@ -335,6 +355,7 @@ export const processImportsFromExtracted = async ( const importMap = ctx.importMap; const packageMap = ctx.packageMap; const namedImportMap = ctx.namedImportMap; + const moduleAliasMap = ctx.moduleAliasMap; const importCtx = prebuiltCtx ?? buildImportResolutionContext(files.map(f => f.path)); const { allFilePaths, allFileList, normalizedFileList, index, resolveCache } = importCtx; @@ -369,7 +390,7 @@ export const processImportsFromExtracted = async ( totalImportsFound++; const result = importResolvers[imp.language](imp.rawImportPath, filePath, resolveCtx); - applyImportResult(result, filePath, importMap, packageMap, addImportEdge, addImportGraphEdge, imp.namedBindings, namedImportMap); + applyImportResult(result, filePath, importMap, packageMap, addImportEdge, addImportGraphEdge, imp.namedBindings, namedImportMap, moduleAliasMap); } } diff --git a/gitnexus/src/core/ingestion/import-resolution.ts b/gitnexus/src/core/ingestion/import-resolution.ts index 1dc30d205..36b9406f6 100644 --- a/gitnexus/src/core/ingestion/import-resolution.ts +++ b/gitnexus/src/core/ingestion/import-resolution.ts @@ -83,8 +83,10 @@ export type ImportResolverFn = ( resolveCtx: ResolveCtx, ) => ImportResult; -/** A single named import binding: local name in the importing file and exported name from the source. */ -export interface NamedBinding { local: string; exported: string } +/** A single named import binding: local name in the importing file and exported name from the source. + * When `isModuleAlias` is true, the binding represents a Python `import X as Y` module alias + * and is routed to moduleAliasMap instead of namedImportMap during import processing. */ +export interface NamedBinding { local: string; exported: string; isModuleAlias?: boolean } /** Per-language named binding extractor -- optional (returns undefined if language has no named imports). */ type NamedBindingExtractorFn = (importNode: SyntaxNode) => NamedBinding[] | undefined; diff --git a/gitnexus/src/core/ingestion/named-binding-extraction.ts b/gitnexus/src/core/ingestion/named-binding-extraction.ts index f7124e271..9d031d03e 100644 --- a/gitnexus/src/core/ingestion/named-binding-extraction.ts +++ b/gitnexus/src/core/ingestion/named-binding-extraction.ts @@ -146,27 +146,18 @@ export function extractPythonNamedBindings(importNode: SyntaxNode): NamedBinding } // Handle: import numpy as np (import_statement with aliased_import child) - // Records local alias "np" → exported module name "numpy" so that call sites - // like np.array() resolve correctly via namedImportMap. + // Tagged with isModuleAlias so applyImportResult routes these directly to + // moduleAliasMap (e.g. "np" → "numpy.py") instead of namedImportMap. if (importNode.type === 'import_statement') { const bindings: NamedBinding[] = []; for (let i = 0; i < importNode.namedChildCount; i++) { const child = importNode.namedChild(i); - if (!child) continue; + if (!child || child.type !== 'aliased_import') continue; - if (child.type === 'aliased_import') { - // import numpy as np → name=(dotted_name "numpy"), alias=(identifier "np") - const dottedName = findChild(child, 'dotted_name'); - const aliasIdent = findChild(child, 'identifier'); - if (dottedName && aliasIdent) { - // exported = last segment of dotted module path (e.g. "numpy" from "numpy") - // local = alias used at call sites (e.g. "np") - const moduleName = dottedName.text; - const lastSegment = moduleName.includes('.') - ? moduleName.split('.').pop()! - : moduleName; - bindings.push({ local: aliasIdent.text, exported: lastSegment }); - } + const dottedName = findChild(child, 'dotted_name'); + const aliasIdent = findChild(child, 'identifier'); + if (dottedName && aliasIdent) { + bindings.push({ local: aliasIdent.text, exported: dottedName.text, isModuleAlias: true }); } } diff --git a/gitnexus/test/fixtures/lang-resolution/python-plain-import-alias/app.py b/gitnexus/test/fixtures/lang-resolution/python-plain-import-alias/app.py new file mode 100644 index 000000000..4866b9537 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-plain-import-alias/app.py @@ -0,0 +1,12 @@ +import models as m +import auth as a + +def main(): + u = m.User() + u.save() + + r = m.Repo() + r.persist() + + v = a.User() + v.login() diff --git a/gitnexus/test/fixtures/lang-resolution/python-plain-import-alias/auth.py b/gitnexus/test/fixtures/lang-resolution/python-plain-import-alias/auth.py new file mode 100644 index 000000000..88818246a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-plain-import-alias/auth.py @@ -0,0 +1,3 @@ +class User: + def login(self): + pass diff --git a/gitnexus/test/fixtures/lang-resolution/python-plain-import-alias/models.py b/gitnexus/test/fixtures/lang-resolution/python-plain-import-alias/models.py new file mode 100644 index 000000000..da29eb1e9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-plain-import-alias/models.py @@ -0,0 +1,7 @@ +class User: + def save(self): + pass + +class Repo: + def persist(self): + pass diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index b7d680d75..22a05aa1b 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -285,6 +285,57 @@ describe('Python alias import resolution', () => { }); }); +// --------------------------------------------------------------------------- +// Plain import alias: import models as m → m.User() resolves to models.py +// --------------------------------------------------------------------------- + +describe('Python plain import alias resolution (import X as Y)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-plain-import-alias'), + () => {}, + ); + }, 60000); + + it('detects User classes in both models.py and auth.py', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('User'); + expect(classes).toContain('Repo'); + }); + + it('emits IMPORTS edges: app.py → models.py and app.py → auth.py', () => { + const imports = getRelationships(result, 'IMPORTS'); + const importFiles = imports + .filter(i => i.sourceFilePath === 'app.py') + .map(i => i.targetFilePath) + .sort(); + expect(importFiles).toEqual(['auth.py', 'models.py']); + }); + + it('resolves m.User() and u.save() to models.py via alias', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.source === 'main'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('models.py'); + }); + + it('resolves m.Repo() and r.persist() to models.py via alias', () => { + const calls = getRelationships(result, 'CALLS'); + const persistCall = calls.find(c => c.target === 'persist' && c.source === 'main'); + expect(persistCall).toBeDefined(); + expect(persistCall!.targetFilePath).toBe('models.py'); + }); + + it('resolves a.User() and v.login() to auth.py via alias (disambiguation)', () => { + const calls = getRelationships(result, 'CALLS'); + const loginCall = calls.find(c => c.target === 'login' && c.source === 'main'); + expect(loginCall).toBeDefined(); + expect(loginCall!.targetFilePath).toBe('auth.py'); + }); +}); + // --------------------------------------------------------------------------- // Re-export chain: from .base import X barrel pattern via __init__.py // ---------------------------------------------------------------------------