From e7977fdf44841662733c8245a3377910dd3c160c Mon Sep 17 00:00:00 2001 From: Shunsuke Hayashi Date: Mon, 23 Mar 2026 00:26:00 +0900 Subject: [PATCH 1/4] fix(python): resolve module-qualified constructor calls (Issue #337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python repos were producing 0 CALLS edges for module-qualified constructor calls like `models.User()` where `import models` is a bare module import. Root causes: 1. `SupportedLanguages.Python` was absent from `WILDCARD_IMPORT_LANGUAGES`, so `synthesizeWildcardImportBindings` never ran for Python files — bare module imports never received per-symbol namedImportMap bindings. 2. Synthesis only ran in the Phase 14 pre-pass, after all chunks had already been call-resolved. When `models.User()` was processed in Phase 3+4, `namedImportMap` was empty for Python → Tier 2a-named fell through to Tier 2a which found both `models.py:User` and `auth.py:User` (ambiguous). 3. `filterCallableCandidates` with `callForm='member'` excluded `Class` nodes (only `CALLABLE_SYMBOL_TYPES` = Function/Method/Constructor/…). With 2 ambiguous Class candidates both were dropped, producing 0 CALLS edges. Fixes: - Add `SupportedLanguages.Python` to `WILDCARD_IMPORT_LANGUAGES` so that `import models` expands to per-symbol namedImportMap entries (first-seen semantics: `User→models.py:User`, `Admin→auth.py:Admin`). - Call `synthesizeWildcardImportBindings` inline in the chunk loop, after `processImportsFromExtracted` but BEFORE `processCallsFromExtracted`. This ensures Tier 2a-named can disambiguate `module.ClassName()` at initial call-resolution time. The Phase 14 pre-pass remains as a final safety net. - Add a fallback in `resolveCallTarget`: if `callForm='member'` yields 0 filtered candidates, retry with `callForm='constructor'`. This handles the case where a module-qualified class instantiation (e.g. `models.User()`) is syntactically an attribute-access call but semantically a constructor call. The fallback only triggers for 0-candidate member calls, so it cannot over-eagerly promote normal member calls. Tests: add `python-module-import` fixture (models.py/auth.py/app.py) with 4 regression tests covering IMPORTS edges, name-collision disambiguation for `models.User()`, and `auth.Admin()`. Co-Authored-By: Claude Sonnet 4.6 --- gitnexus/src/core/ingestion/call-processor.ts | 10 +++- gitnexus/src/core/ingestion/pipeline.ts | 28 ++++++---- .../python-module-import/app.py | 5 ++ .../python-module-import/auth.py | 7 +++ .../python-module-import/models.py | 3 ++ .../test/integration/resolvers/python.test.ts | 52 +++++++++++++++++++ 6 files changed, 95 insertions(+), 10 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/python-module-import/app.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-module-import/auth.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-module-import/models.py 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(); + }); +}); From 70c67b3c9a9cac9982add0c3434a16dc259ff288 Mon Sep 17 00:00:00 2001 From: Shunsuke Hayashi Date: Mon, 23 Mar 2026 16:33:06 +0900 Subject: [PATCH 2/4] 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(); + }); }); From 49f8602d4df7a627d59ac13125fe6ab4e857e33e Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Mon, 23 Mar 2026 08:25:46 +0000 Subject: [PATCH 3/4] test(python): strengthen module-import tests, un-skip match/case, add perf guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite Issue #337 test suite: 5 tests → 19 tests with exact node/edge counts, sourceFilePath guards, negative tests, method call disambiguation (u.save(), a.login(), v.verify()), HAS_METHOD verification, and cross-module collision assertions - Un-skip 2 match/case as-pattern tests (they pass now) and remove leftover DEBUG test - Add per-chunk language guard for synthesizeWildcardImportBindings — skips full graph traversal for TS/JS-only chunks, avoiding O(chunks × graph_size) - Rename fixture method check → verify to avoid BUILT_IN_NAMES noise filter - Expand fixture with method calls on constructor-inferred receivers - Fix stale comment referencing only "Go package imports" --- gitnexus/src/core/ingestion/pipeline.ts | 19 +- .../python-module-import/app.py | 10 +- .../python-module-import/auth.py | 2 +- .../test/integration/resolvers/python.test.ts | 165 ++++++++++++++---- 4 files changed, 153 insertions(+), 43 deletions(-) diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 09041f223..324b123b7 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -204,7 +204,7 @@ 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); } @@ -606,12 +606,17 @@ 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); + // ── 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. + // Guard: skip for chunks with only non-wildcard/non-Python files to avoid + // O(chunks × graph_size) repeated traversals on large TypeScript/JS repos. + const chunkHasWildcardOrPython = chunkFiles.some(f => { + const lang = getLanguageFromFilename(f.path); + return lang && (WILDCARD_IMPORT_LANGUAGES.has(lang) || lang === SupportedLanguages.Python); + }); + if (chunkHasWildcardOrPython) 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 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 58b11fe9b..42f878a42 100644 --- a/gitnexus/test/fixtures/lang-resolution/python-module-import/app.py +++ b/gitnexus/test/fixtures/lang-resolution/python-module-import/app.py @@ -2,5 +2,13 @@ import models import auth u = models.User() +u.save() + a = auth.Admin() -au = auth.User() +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 index 4036251f8..9791d0419 100644 --- a/gitnexus/test/fixtures/lang-resolution/python-module-import/auth.py +++ b/gitnexus/test/fixtures/lang-resolution/python-module-import/auth.py @@ -1,5 +1,5 @@ class User: - def check(self): + def verify(self): pass class Admin: diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index 478f350ac..b7d680d75 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'), @@ -1568,8 +1550,9 @@ describe('Python cross-file binding propagation', () => { // --------------------------------------------------------------------------- // 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. +// 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)', () => { @@ -1582,47 +1565,161 @@ describe('Python module import CALLS resolution (Issue #337)', () => { ); }, 60000); - it('detects User (×2) and Admin classes', () => { + // ── 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).toContain('Admin'); + expect(classes.filter(c => c === 'Admin').length).toBe(1); }); - it('resolves `import models` and `import auth` IMPORTS edges from app.py', () => { + 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(toModels).toBeDefined(); expect(toAuth).toBeDefined(); }); - it('resolves models.User() CALLS edge to models.py:User (not 0 edges despite name collision)', () => { + 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.target === 'User' && c.targetFilePath === 'models.py' && c.sourceFilePath === 'app.py', ); expect(userCall).toBeDefined(); }); - it('resolves auth.Admin() CALLS edge to auth.py:Admin', () => { + 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.target === 'Admin' && c.targetFilePath === 'auth.py' && c.sourceFilePath === 'app.py', ); 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. + 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.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(); + }); }); From 1da0e9d39ed4b5a010efe78ace3a974343334d84 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Mon, 23 Mar 2026 08:47:21 +0000 Subject: [PATCH 4/4] refactor: use boolean[] for O(1) per-chunk synthesis guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Set + flatMap with a simple boolean array indexed by chunk — cleaner data structure for sequential integer keys. --- gitnexus/src/core/ingestion/pipeline.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 324b123b7..f29d95148 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -125,6 +125,10 @@ 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, Python) import all exported symbols from a * file, not specific named symbols. After parsing, we know which symbols each file @@ -561,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) @@ -610,13 +621,7 @@ export const runPipelineFromRepo = async ( // 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. - // Guard: skip for chunks with only non-wildcard/non-Python files to avoid - // O(chunks × graph_size) repeated traversals on large TypeScript/JS repos. - const chunkHasWildcardOrPython = chunkFiles.some(f => { - const lang = getLanguageFromFilename(f.path); - return lang && (WILDCARD_IMPORT_LANGUAGES.has(lang) || lang === SupportedLanguages.Python); - }); - if (chunkHasWildcardOrPython) synthesizeWildcardImportBindings(graph, ctx); + 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