Merge pull request #463 from ShunsukeHayashi/fix/python-calls-zero-337

fix(python): resolve module-qualified constructor calls — 0 CALLS edges (Issue #337)
This commit is contained in:
Gergő Magyar 2026-03-23 08:59:21 +00:00 • committed by GitHub
commit 1d8eaf7bf3
7 changed files with 296 additions and 33 deletions

View file

@ -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<ExtractedCall, 'calledName' | 'argCount' | 'callForm' | 'receiverTypeName'>,
call: Pick<ExtractedCall, 'calledName' | 'argCount' | 'callForm' | 'receiverTypeName' | 'receiverName'>,
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

View file

@ -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<typeof createKnowledgeGraph>,
ctx: ReturnType<typeof createResolutionContext>,
@ -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<string>) => {
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 ──────────────────────

View file

@ -39,6 +39,9 @@ export const TIER_CONFIDENCE: Record<ResolutionTier, number> = {
export type ImportMap = Map<string, Set<string>>;
export type PackageMap = Map<string, Set<string>>;
export type NamedImportMap = Map<string, Map<string, NamedImportBinding>>;
/** 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<string, Map<string, string>>;
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,

View file

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

View file

@ -0,0 +1,7 @@
class User:
def verify(self):
pass
class Admin:
def login(self):
pass

View file

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

View file

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