From eb897540f3ef147cef5c584f91aaafa7affcf8e8 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Mon, 20 Apr 2026 09:29:30 +0100 Subject: [PATCH] feat(python-scope): free-call fallback consults finalized bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/core/ingestion/emit-core/index.ts | 1 + .../core/ingestion/emit-core/scope-walkers.ts | 46 +++++++++++ .../src/core/ingestion/python-scope-emit.ts | 81 ++++++++++++++++++- 3 files changed, 127 insertions(+), 1 deletion(-) diff --git a/gitnexus/src/core/ingestion/emit-core/index.ts b/gitnexus/src/core/ingestion/emit-core/index.ts index 3dac359f5..d0c78c824 100644 --- a/gitnexus/src/core/ingestion/emit-core/index.ts +++ b/gitnexus/src/core/ingestion/emit-core/index.ts @@ -31,6 +31,7 @@ export { emitImportEdges } from './emit-imports.js'; export { findReceiverTypeBinding, findClassBindingInScope, + findCallableBindingInScope, findOwnedMember, findExportedDef, } from './scope-walkers.js'; diff --git a/gitnexus/src/core/ingestion/emit-core/scope-walkers.ts b/gitnexus/src/core/ingestion/emit-core/scope-walkers.ts index 80c3365e1..6f2223031 100644 --- a/gitnexus/src/core/ingestion/emit-core/scope-walkers.ts +++ b/gitnexus/src/core/ingestion/emit-core/scope-walkers.ts @@ -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(); + 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`. diff --git a/gitnexus/src/core/ingestion/python-scope-emit.ts b/gitnexus/src/core/ingestion/python-scope-emit.ts index 655bbcc07..23fd4e879 100644 --- a/gitnexus/src/core/ingestion/python-scope-emit.ts +++ b/gitnexus/src/core/ingestion/python-scope-emit.ts @@ -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 }, +): number { + let emitted = 0; + const seen = new Set(); + // 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. */