feat(python-scope): free-call fallback consults finalized bindings

Unit 7 — closes the cross-file free-call gap.

The shared `MethodRegistry.lookup` walks `scope.bindings` (pre-finalize
local-only) for free-call resolution. Cross-file imports land in
`indexes.bindings` (post-finalize). Without the dual-source lookup,
`from x import f; f()` resolves to "unresolved" and no CALLS edge is
emitted.

Two changes:

- `emit-core/scope-walkers.ts`: new `findCallableBindingInScope` —
  same dual-source pattern as `findClassBindingInScope`, but accepts
  Function/Method/Constructor. Promoted to emit-core because every
  language with cross-file imports needs the same lookup.
- `python-scope-emit.ts emitFreeCallFallback`: post-pass that walks
  every free-call reference site, looks up the callee with the new
  helper, and emits via `tryEmitEdge`. Pre-seeds `seen` from the
  shared resolver's emissions so we never double-count.

Verification:
- Flag-off: 191/191 (identical baseline).
- Flag-on: 22 fail / 169 pass (was 28/163; +6 tests including
  the Python overload dispatch fixtures, ancestor-directory imports,
  and same-name module-alias collision).
- tsc --noEmit clean.
This commit is contained in:
Gergo Magyar 2026-04-20 09:29:30 +01:00
parent 42e1146b76
commit eb897540f3
3 changed files with 127 additions and 1 deletions

View file

@ -31,6 +31,7 @@ export { emitImportEdges } from './emit-imports.js';
export {
findReceiverTypeBinding,
findClassBindingInScope,
findCallableBindingInScope,
findOwnedMember,
findExportedDef,
} from './scope-walkers.js';

View file

@ -91,6 +91,52 @@ export function findClassBindingInScope(
return undefined;
}
/**
* Look up a callable (Function/Method/Constructor) by name in the
* given scope's chain. Uses the dual-source pattern (scope.bindings +
* indexes.bindings) so cross-file imports are visible without it
* free calls to imported functions never resolve via the post-pass.
*
* Mirrors `findClassBindingInScope` exactly; only the accepted
* def-type predicate differs.
*/
export function findCallableBindingInScope(
startScope: ScopeId,
callableName: string,
scopes: ScopeResolutionIndexes,
): SymbolDefinition | undefined {
let currentId: ScopeId | null = startScope;
const visited = new Set<ScopeId>();
while (currentId !== null) {
if (visited.has(currentId)) return undefined;
visited.add(currentId);
const scope = scopes.scopeTree.getScope(currentId);
if (scope === undefined) return undefined;
const localBindings = scope.bindings.get(callableName);
if (localBindings !== undefined) {
for (const b of localBindings) {
if (b.def.type === 'Function' || b.def.type === 'Method' || b.def.type === 'Constructor') {
return b.def;
}
}
}
const finalizedScopeBindings = scopes.bindings.get(currentId);
const importedBindings = finalizedScopeBindings?.get(callableName);
if (importedBindings !== undefined) {
for (const b of importedBindings) {
if (b.def.type === 'Function' || b.def.type === 'Method' || b.def.type === 'Constructor') {
return b.def;
}
}
}
currentId = scope.parent;
}
return undefined;
}
/**
* Find a member of a class by simple name a def whose `ownerId`
* matches the class's nodeId and whose simple name matches `memberName`.

View file

@ -53,6 +53,7 @@ import {
collectNamespaceTargets,
emitImportEdges,
emitReferencesViaLookup,
findCallableBindingInScope,
findClassBindingInScope,
findExportedDef,
findOwnedMember,
@ -201,6 +202,20 @@ export function runPythonScopeResolution(
referenceIndex,
);
// Free-call finalized-binding fallback. The shared `MethodRegistry.lookup`
// walks `scope.bindings` for the call's name, but `scope.bindings`
// only carries pre-finalize local declarations. Cross-file imports
// land in `indexes.bindings` (post-finalize). Without consulting that
// second source, every imported `f()` call resolves to "unresolved".
// Mirror the dual-source pattern from `findClassBindingInScope`.
const freeCallExtras = emitFreeCallFallback(
graph,
indexes,
parsedFiles,
nodeLookup,
referenceIndex,
);
// IMPORTS edges: the scope-resolution path now owns Python file→file
// IMPORTS edge emission when `REGISTRY_PRIMARY_PYTHON=1`. The legacy
// `processImports` path still runs (heritage needs its `importMap`
@ -219,7 +234,7 @@ export function runPythonScopeResolution(
filesSkipped,
importsEmitted,
resolve: resolveStats,
referenceEdgesEmitted: emitted + receiverExtras,
referenceEdgesEmitted: emitted + receiverExtras + freeCallExtras,
referenceSkipped: skipped,
};
}
@ -496,6 +511,70 @@ function emitReceiverBoundCalls(
return emitted;
}
/**
* Emit CALLS edges for free-call reference sites whose target is
* imported (or otherwise visible only via post-finalize scope.bindings).
*
* The shared `MethodRegistry.lookup` only consults `scope.bindings`
* (pre-finalize / local-only) for free calls. Cross-file imports land
* in `indexes.bindings` (post-finalize). Without this fallback, every
* `from x import f; f()` resolves to "unresolved".
*
* Same dual-source pattern as `findClassBindingInScope` but accepts
* Function/Method/Constructor instead of Class. Pre-seeds `seen` from
* the shared resolver's emissions so we don't double-emit.
*/
function emitFreeCallFallback(
graph: KnowledgeGraph,
scopes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
referenceIndex: { readonly bySourceScope: ReadonlyMap<ScopeId, readonly Reference[]> },
): number {
let emitted = 0;
const seen = new Set<string>();
// Pre-seed `seen` with whatever the shared resolver + receiver-bound
// pass already emitted so we never double-count an edge that another
// path produced.
for (const refs of referenceIndex.bySourceScope.values()) {
for (const r of refs) {
const targetDef = scopes.defs.get(r.toDef);
if (targetDef === undefined) continue;
const callerGraphId = resolveCallerGraphId(r.fromScope, scopes, nodeLookup);
if (callerGraphId === undefined) continue;
const tgtGraphId = resolveDefGraphId(targetDef.filePath, targetDef, nodeLookup);
if (tgtGraphId === undefined) continue;
const kind = mapReferenceKindToEdgeType(r.kind);
if (kind === undefined) continue;
seen.add(
`${kind}:${callerGraphId}->${tgtGraphId}:${r.atRange.startLine}:${r.atRange.startCol}`,
);
}
}
for (const parsed of parsedFiles) {
for (const site of parsed.referenceSites) {
if (site.kind !== 'call') continue;
if (site.explicitReceiver !== undefined) continue;
const fnDef = findCallableBindingInScope(site.inScope, site.name, scopes);
if (fnDef === undefined) continue;
const ok = tryEmitEdge(
graph,
scopes,
nodeLookup,
site,
fnDef,
'python-scope: free-call-import',
seen,
);
if (ok) emitted++;
}
}
return emitted;
}
/** Max depth for compound-receiver chain resolution (`a().b().c().d()`).
* Practical Python rarely exceeds 3-4 hops; the cap just prevents
* pathological recursion if the receiver text turns out to be malformed. */