fix(python): route module aliases directly to moduleAliasMap in import processor

`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).
This commit is contained in:
Gergo Magyar 2026-03-23 09:36:33 +00:00
parent 9f2d1780d5
commit cb1293b718
7 changed files with 108 additions and 21 deletions

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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 });
}
}

View file

@ -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()

View file

@ -0,0 +1,3 @@
class User:
def login(self):
pass

View file

@ -0,0 +1,7 @@
class User:
def save(self):
pass
class Repo:
def persist(self):
pass

View file

@ -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
// ---------------------------------------------------------------------------