diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index ec095023d..450a18322 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -827,7 +827,15 @@ 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'); + } // 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 68607c783..ebe23534e 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -118,13 +118,14 @@ const WILDCARD_IMPORT_LANGUAGES = new Set([ 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. - * 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, @@ -576,6 +577,12 @@ export const runPipelineFromRepo = async ( stats: { filesProcessed: filesParsedSoFar, totalFiles: totalParseable, nodesCreated: graph.nodeCount }, }); }, repoPath, importCtx); + // ── Wildcard-import synthesis (Python / Ruby / C/C++ / Swift / Go) ────────────── + // Synthesize namedImportMap entries for module-qualified calls like Python's + // `models.User()`. Must run after imports are resolved (importMap is populated) + // but BEFORE call resolution so Tier 2a-named can disambiguate `module.Name()`. + // Idempotent: first-seen semantics prevents double-counting across chunks. + 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 +668,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 +722,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/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 000000000..311703852 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-module-import/app.py @@ -0,0 +1,5 @@ +import models +import auth + +u = models.User() +a = auth.Admin() 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 000000000..4036251f8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-module-import/auth.py @@ -0,0 +1,7 @@ +class User: + def check(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 000000000..96f70f4d8 --- /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 316b45610..ef2580392 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -1564,3 +1564,55 @@ 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. +// Without wildcard synthesis, Tier 2a returns candidates from both imported +// files (models.User + auth.User) → resolveCallTarget returns null → 0 CALLS. +// --------------------------------------------------------------------------- + +describe('Python module import CALLS resolution (Issue #337)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-module-import'), + () => {}, + ); + }, 60000); + + it('detects User (×2) and Admin classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes.filter(c => c === 'User').length).toBe(2); + expect(classes).toContain('Admin'); + }); + + it('resolves `import models` and `import auth` IMPORTS edges from app.py', () => { + const imports = getRelationships(result, 'IMPORTS'); + const toModels = imports.find(e => + e.sourceFilePath === 'app.py' && e.targetFilePath === 'models.py', + ); + const toAuth = imports.find(e => + e.sourceFilePath === 'app.py' && e.targetFilePath === 'auth.py', + ); + expect(toModels).toBeDefined(); + expect(toAuth).toBeDefined(); + }); + + it('resolves models.User() CALLS edge to models.py:User (not 0 edges despite name collision)', () => { + const calls = getRelationships(result, 'CALLS'); + const userCall = calls.find(c => + c.target === 'User' && c.targetFilePath === 'models.py', + ); + expect(userCall).toBeDefined(); + }); + + it('resolves auth.Admin() CALLS edge to auth.py:Admin', () => { + const calls = getRelationships(result, 'CALLS'); + const adminCall = calls.find(c => + c.target === 'Admin' && c.targetFilePath === 'auth.py', + ); + expect(adminCall).toBeDefined(); + }); +});