fix(python): resolve module-qualified constructor calls (Issue #337)

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 <noreply@anthropic.com>
This commit is contained in:
Shunsuke Hayashi 2026-03-23 00:26:00 +09:00
parent 907440cf0b
commit cd1c0ff7dc
6 changed files with 95 additions and 10 deletions

View file

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

View file

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

View file

@ -0,0 +1,5 @@
import models
import auth
u = models.User()
a = auth.Admin()

View file

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

View file

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

View file

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