diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index ec095023d0..dba077a0c4 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, @@ -827,7 +828,29 @@ const resolveCallTarget = ( const tiered = ctx.resolve(call.calledName, currentFile); if (!tiered) return null; - const filteredCandidates = filterCallableCandidates(tiered.candidates, call.argCount, call.callForm); + let filteredCandidates = filterCallableCandidates(tiered.candidates, call.argCount, call.callForm); + + // Module-qualified constructor pattern: e.g. Python `import models; models.User()`. + // The attribute access gives callForm='member', but the callee may be a Class — a valid + // constructor target. Re-try with constructor-form filtering so that `module.ClassName()` + // emits a CALLS edge to the class node. + if (filteredCandidates.length === 0 && call.callForm === 'member') { + 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 diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 68607c7832..f29d951484 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -111,7 +111,12 @@ 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, @@ -120,11 +125,15 @@ const WILDCARD_IMPORT_LANGUAGES = new Set([ SupportedLanguages.Swift, ]); +/** Languages that require synthesizeWildcardImportBindings to run before call resolution. + * Superset of WILDCARD_IMPORT_LANGUAGES — includes Python for moduleAliasMap building. */ +const SYNTHESIS_LANGUAGES = new Set([...WILDCARD_IMPORT_LANGUAGES, SupportedLanguages.Python]); + /** Synthesize namedImportMap entries for languages with whole-module imports. - * These languages (Go, Ruby, C/C++, Swift) import all exported symbols from a file, - * not specific named symbols. After parsing, we know which symbols each file exports - * (via graph isExported), so we can expand ImportMap edges into per-symbol bindings - * that Phase 14 can use for cross-file type propagation. */ + * These languages (Go, Ruby, C/C++, Swift, Python) import all exported symbols from a + * file, not specific named symbols. After parsing, we know which symbols each file + * exports (via graph isExported), so we can expand ImportMap edges into per-symbol + * bindings that Phase 14 can use for cross-file type propagation. */ function synthesizeWildcardImportBindings( graph: ReturnType, ctx: ReturnType, @@ -199,11 +208,36 @@ function synthesizeWildcardImportBindings( synthesizeForFile(filePath, importedFiles); } - // Process files from graph IMPORTS edges (Go package imports) + // Process files from graph IMPORTS edges (Go and other wildcard-import languages) for (const [filePath, importedFiles] of graphImports) { 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; } @@ -531,6 +565,13 @@ export const runPipelineFromRepo = async ( // are already registered). This trades ~5% cross-chunk resolution accuracy for // 200-400MB less memory — critical for Linux-kernel-scale repos. const sequentialChunkPaths: string[][] = []; + // Pre-compute which chunks need synthesis — O(1) lookup per chunk. + const chunkNeedsSynthesis = chunks.map(paths => + paths.some(p => { + const lang = getLanguageFromFilename(p); + return lang != null && SYNTHESIS_LANGUAGES.has(lang); + }), + ); // Phase 14: Collect exported type bindings for cross-file propagation const exportedTypeMap: ExportedTypeMap = new Map(); // Accumulate file-scope TypeEnv bindings from workers (closes worker/sequential quality gap) @@ -576,6 +617,11 @@ export const runPipelineFromRepo = async ( stats: { filesProcessed: filesParsedSoFar, totalFiles: totalParseable, nodesCreated: graph.nodeCount }, }); }, repoPath, importCtx); + // ── Wildcard-import synthesis (Ruby / C/C++ / Swift / Go) + Python module aliases ─ + // Synthesize namedImportMap entries for wildcard-import languages and build + // moduleAliasMap for Python namespace imports. Must run after imports are resolved + // (importMap is populated) but BEFORE call resolution. + if (chunkNeedsSynthesis[chunkIdx]) synthesizeWildcardImportBindings(graph, ctx); // Phase 14 E1: Seed cross-file receiver types from ExportedTypeMap // before call resolution — eliminates re-parse for single-hop imported receivers. // NOTE: In the worker path, exportedTypeMap is empty during chunk processing @@ -661,6 +707,9 @@ export const runPipelineFromRepo = async ( } // Sequential fallback chunks: re-read source for call/heritage resolution + // Synthesize wildcard import bindings once after ALL imports are processed, + // before any call resolution — same rationale as the worker-path inline synthesis. + if (sequentialChunkPaths.length > 0) synthesizeWildcardImportBindings(graph, ctx); for (const chunkPaths of sequentialChunkPaths) { const chunkContents = await readFileContents(repoPath, chunkPaths); const chunkFiles = chunkPaths @@ -712,13 +761,13 @@ export const runPipelineFromRepo = async ( } } - // ── Phase 14 pre-pass: Synthesize namedImportMap for whole-module-import languages ── - // Go, Ruby, C/C++, Swift import all exported symbols from a file. - // Expand ImportMap edges into per-symbol namedImportMap entries so Phase 14 can - // propagate types cross-file for these languages. + // ── Phase 14 pre-pass: Final synthesis pass for whole-module-import languages ── + // Per-chunk synthesis (above) already ran incrementally. This final pass ensures + // any remaining files whose imports were not covered inline are also synthesized, + // and that Phase 14 type propagation has complete namedImportMap data. const synthesized = synthesizeWildcardImportBindings(graph, ctx); if (isDev && synthesized > 0) { - console.log(`🔗 Synthesized ${synthesized} wildcard import bindings (Go/Ruby/C++/Swift)`); + console.log(`🔗 Synthesized ${synthesized} additional wildcard import bindings (Go/Ruby/C++/Swift/Python)`); } // ── Phase 14: Cross-file binding propagation ────────────────────── diff --git a/gitnexus/src/core/ingestion/resolution-context.ts b/gitnexus/src/core/ingestion/resolution-context.ts index 7184bd9549..0adf5a7319 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 new file mode 100644 index 0000000000..42f878a42f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-module-import/app.py @@ -0,0 +1,14 @@ +import models +import auth + +u = models.User() +u.save() + +a = auth.Admin() +a.login() + +# Same-name cross-module disambiguation: both models and auth export User. +# moduleAliasMap maps receiverName='auth' → auth.py, enabling resolveCallTarget +# to narrow candidates to the correct file. +v = auth.User() +v.verify() diff --git a/gitnexus/test/fixtures/lang-resolution/python-module-import/auth.py b/gitnexus/test/fixtures/lang-resolution/python-module-import/auth.py new file mode 100644 index 0000000000..9791d0419c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-module-import/auth.py @@ -0,0 +1,7 @@ +class User: + def verify(self): + pass + +class Admin: + def login(self): + pass diff --git a/gitnexus/test/fixtures/lang-resolution/python-module-import/models.py b/gitnexus/test/fixtures/lang-resolution/python-module-import/models.py new file mode 100644 index 0000000000..96f70f4d8e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-module-import/models.py @@ -0,0 +1,3 @@ +class User: + def save(self): + pass diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index 316b456104..b7d680d75f 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -1006,25 +1006,7 @@ describe('Python match/case as-pattern type binding', () => { expect(saveFns.length).toBe(2); }); - it('DEBUG: shows pipeline result details', () => { - const calls = getRelationships(result, 'CALLS'); - console.log('ALL CALLS:', JSON.stringify(calls.map(c => ({ source: c.source, target: c.target, targetFilePath: c.targetFilePath })))); - // Check all relationships - const allRels: string[] = []; - result.graph.iterRelationships && [...result.graph.iterRelationships()].forEach(r => { - const src = result.graph.getNode(r.sourceId); - const tgt = result.graph.getNode(r.targetId); - allRels.push(r.type + ': ' + src?.properties.name + ' -> ' + tgt?.properties.name); - }); - console.log('ALL RELATIONSHIPS:', allRels.join(', ')); - expect(true).toBe(true); - }); - - // Skip: call extraction issue, NOT a type-env limitation. - // Type-env binding works correctly (unit test passes). The root cause is likely - // in call-processor's findEnclosingFunction scope resolution within match_statement - // blocks, not the tree-sitter query patterns (which descend recursively by default). - it.skip('resolves u.save() to User#save via match/case as-pattern binding', () => { + it('resolves u.save() to User#save via match/case as-pattern binding', () => { const calls = getRelationships(result, 'CALLS'); const userSave = calls.find(c => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('user.py'), @@ -1032,7 +1014,7 @@ describe('Python match/case as-pattern type binding', () => { expect(userSave).toBeDefined(); }); - it.skip('does NOT resolve u.save() to Repo#save (negative disambiguation)', () => { + it('does NOT resolve u.save() to Repo#save (negative disambiguation)', () => { const calls = getRelationships(result, 'CALLS'); const wrongSave = calls.find(c => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('repo.py'), @@ -1564,3 +1546,180 @@ describe('Python cross-file binding propagation', () => { expect(getNameEdge).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// Module import: `import models; models.User()` should produce CALLS edges +// even when multiple imported modules export a class with the same name. +// Python's `import models` is a namespace import — moduleAliasMap maps the +// module alias to its source file, enabling resolveCallTarget to disambiguate +// `models.User()` from `auth.User()` when both modules export `User`. +// --------------------------------------------------------------------------- + +describe('Python module import CALLS resolution (Issue #337)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-module-import'), + () => {}, + ); + }, 60000); + + // ── Node detection ────────────────────────────────────────────────── + + it('detects exactly 3 Class nodes: User (×2) and Admin (×1)', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes.length).toBe(3); + expect(classes.filter(c => c === 'User').length).toBe(2); + expect(classes.filter(c => c === 'Admin').length).toBe(1); + }); + + it('detects exactly 3 Function nodes: save, verify, login', () => { + const fns = getNodesByLabel(result, 'Function'); + expect(fns.length).toBe(3); + expect(fns).toContain('save'); + expect(fns).toContain('verify'); + expect(fns).toContain('login'); + }); + + // ── IMPORTS edges ─────────────────────────────────────────────────── + + it('emits exactly 2 IMPORTS edges from app.py', () => { + const imports = getRelationships(result, 'IMPORTS'); + const appImports = imports.filter(e => e.sourceFilePath === 'app.py'); + expect(appImports.length).toBe(2); + }); + + it('resolves `import models` IMPORTS edge: app.py → models.py', () => { + const imports = getRelationships(result, 'IMPORTS'); + const toModels = imports.find(e => + e.sourceFilePath === 'app.py' && e.targetFilePath === 'models.py', + ); + expect(toModels).toBeDefined(); + }); + + it('resolves `import auth` IMPORTS edge: app.py → auth.py', () => { + const imports = getRelationships(result, 'IMPORTS'); + const toAuth = imports.find(e => + e.sourceFilePath === 'app.py' && e.targetFilePath === 'auth.py', + ); + expect(toAuth).toBeDefined(); + }); + + it('no IMPORTS edge from models.py or auth.py (they import nothing)', () => { + const imports = getRelationships(result, 'IMPORTS'); + const fromModels = imports.filter(e => e.sourceFilePath === 'models.py'); + const fromAuth = imports.filter(e => e.sourceFilePath === 'auth.py'); + expect(fromModels.length).toBe(0); + expect(fromAuth.length).toBe(0); + }); + + // ── CALLS edges: key regression test (Issue #337) ─────────────────── + + it('resolves models.User() CALLS edge from app.py to models.py:User', () => { + const calls = getRelationships(result, 'CALLS'); + const userCall = calls.find(c => + c.target === 'User' && c.targetFilePath === 'models.py' && c.sourceFilePath === 'app.py', + ); + expect(userCall).toBeDefined(); + }); + + it('resolves auth.Admin() CALLS edge from app.py to auth.py:Admin', () => { + const calls = getRelationships(result, 'CALLS'); + const adminCall = calls.find(c => + c.target === 'Admin' && c.targetFilePath === 'auth.py' && c.sourceFilePath === 'app.py', + ); + expect(adminCall).toBeDefined(); + }); + + it('resolves u.save() method call from app.py to models.py:save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => + c.target === 'save' && c.targetFilePath === 'models.py' && c.sourceFilePath === 'app.py', + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves a.login() method call from app.py to auth.py:login', () => { + const calls = getRelationships(result, 'CALLS'); + const loginCall = calls.find(c => + c.target === 'login' && c.targetFilePath === 'auth.py' && c.sourceFilePath === 'app.py', + ); + expect(loginCall).toBeDefined(); + }); + + // ── Negative tests ────────────────────────────────────────────────── + + it('no CALLS edges originate from models.py or auth.py (they have no callers)', () => { + const calls = getRelationships(result, 'CALLS'); + const fromModels = calls.filter(c => c.sourceFilePath === 'models.py'); + const fromAuth = calls.filter(c => c.sourceFilePath === 'auth.py'); + expect(fromModels.length).toBe(0); + expect(fromAuth.length).toBe(0); + }); + + it('Admin() does NOT resolve to models.py (Admin only exists in auth.py)', () => { + const calls = getRelationships(result, 'CALLS'); + const wrongAdmin = calls.find(c => + c.target === 'Admin' && c.targetFilePath === 'models.py', + ); + expect(wrongAdmin).toBeUndefined(); + }); + + it('no EXTENDS edges (no inheritance in this fixture)', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(0); + }); + + // ── Same-name cross-module disambiguation ─────────────────────────── + + it('resolves auth.User() CALLS edge to auth.py:User (not models.py:User)', () => { + // Both models.py and auth.py export User. moduleAliasMap maps + // receiverName='auth' → auth.py for correct disambiguation. + const calls = getRelationships(result, 'CALLS'); + const authUserCall = calls.find(c => + c.target === 'User' && c.targetFilePath === 'auth.py' && c.sourceFilePath === 'app.py', + ); + expect(authUserCall).toBeDefined(); + }); + + it('models.User() and auth.User() resolve to DIFFERENT files', () => { + const calls = getRelationships(result, 'CALLS'); + const userCalls = calls.filter(c => + c.target === 'User' && c.sourceFilePath === 'app.py', + ); + expect(userCalls.length).toBe(2); + const targetFiles = new Set(userCalls.map(c => c.targetFilePath)); + expect(targetFiles.size).toBe(2); + expect(targetFiles).toContain('models.py'); + expect(targetFiles).toContain('auth.py'); + }); + + it('v.verify() resolves to auth.py:verify (via auth.User() constructor inference)', () => { + const calls = getRelationships(result, 'CALLS'); + const verifyCall = calls.find(c => + c.target === 'verify' && c.targetFilePath === 'auth.py' && c.sourceFilePath === 'app.py', + ); + expect(verifyCall).toBeDefined(); + }); + + // ── HAS_METHOD edges ──────────────────────────────────────────────── + + it('emits HAS_METHOD edges linking methods to their classes', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + // models.py: User → save + const modelsUserSave = hasMethod.find(e => + e.source === 'User' && e.target === 'save' && e.sourceFilePath === 'models.py', + ); + expect(modelsUserSave).toBeDefined(); + // auth.py: User → verify, Admin → login + const authUserVerify = hasMethod.find(e => + e.source === 'User' && e.target === 'verify' && e.sourceFilePath === 'auth.py', + ); + const authAdminLogin = hasMethod.find(e => + e.source === 'Admin' && e.target === 'login' && e.sourceFilePath === 'auth.py', + ); + expect(authUserVerify).toBeDefined(); + expect(authAdminLogin).toBeDefined(); + }); +});