From ff376d09a8cc5834cd2826ccc03b5d2092c7161c Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Sun, 19 Apr 2026 20:51:17 +0100 Subject: [PATCH] feat(python): consult finalized bindings for receiver resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `findClassBindingInScope` now walks BOTH: 1. `scope.bindings` — pre-finalize local declarations (origin: 'local') 2. `indexes.bindings` — post-finalize cross-file imports/namespaces Without (2) we were blind to any class brought in via `from models import Dog` at the call site's file, because the scope-extractor's Pass 2 only populates local bindings and the cross-file finalize produces a separate bindings map that never lands on `scope.bindings`. Case 2 (`Dog.classify()`) now walks MRO so inherited static/class methods resolve — `Dog.classify()` where `classify` lives on `Animal`. Case 4 (simple typeBinding like `u: U` from aliased import) now uses `findClassBindingInScope` instead of the shared `resolveTypeRef`, because `resolveTypeRef`'s `ctx.scopes` only sees pre-finalize local bindings too. Fixes 4 more failures (flag-on 36 → 32): - Python method enrichment > Dog.classify static (1) - Python static/classmethod class-as-receiver (2) - Python alias import resolution (1) Flag-off still 191/191. --- .../src/core/ingestion/python-scope-emit.ts | 71 +++++++++++++++++-- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/gitnexus/src/core/ingestion/python-scope-emit.ts b/gitnexus/src/core/ingestion/python-scope-emit.ts index dfef6b261..262d12764 100644 --- a/gitnexus/src/core/ingestion/python-scope-emit.ts +++ b/gitnexus/src/core/ingestion/python-scope-emit.ts @@ -480,7 +480,14 @@ function emitReceiverBoundCalls( // ── Case 2: class-name receiver (`Dog.classify()`) ────────── const classDef = findClassBindingInScope(site.inScope, receiverName, scopes); if (classDef !== undefined) { - const memberDef = findOwnedMember(classDef.nodeId, memberName, parsedFiles); + // Walk the MRO so inherited static/class methods resolve — e.g. + // `Dog.classify()` where `classify` lives on `Animal`. + const chain = [classDef.nodeId, ...scopes.methodDispatch.mroFor(classDef.nodeId)]; + let memberDef: SymbolDefinition | undefined; + for (const ownerId of chain) { + memberDef = findOwnedMember(ownerId, memberName, parsedFiles); + if (memberDef !== undefined) break; + } if (memberDef !== undefined) { const emitted2 = tryEmitEdge( graph, @@ -522,10 +529,42 @@ function emitReceiverBoundCalls( seen, ); if (emitted3) emitted++; + continue; } } } } + + // ── Case 4: simple typeBinding (`u: U` where U is aliased import) + // Happens when `u = U()` binds to an aliased import + // (`from models import User as U`). `findClassBindingInScope` + // already walks both `scope.bindings` (pre-finalize local defs) + // and `indexes.bindings` (post-finalize imports) for class-kind + // hits, so we reuse it against the typeBinding's rawName. + if (typeRef !== undefined && !typeRef.rawName.includes('.')) { + const ownerDef = findClassBindingInScope(site.inScope, typeRef.rawName, scopes); + if (ownerDef !== undefined) { + // Walk the MRO chain so inherited methods still resolve. + const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)]; + let memberDef: SymbolDefinition | undefined; + for (const ownerId of chain) { + memberDef = findOwnedMember(ownerId, memberName, parsedFiles); + if (memberDef !== undefined) break; + } + if (memberDef !== undefined) { + const emitted4 = tryEmitEdge( + graph, + scopes, + nodeLookup, + site, + memberDef, + 'python-scope: typeref-receiver', + seen, + ); + if (emitted4) emitted++; + } + } + } } } @@ -609,8 +648,18 @@ function findExportedDef( } /** - * Look up a class-kind binding by name in the given scope's chain. Used - * to recognize `Dog.classify()` style class-as-receiver calls. + * Look up a class-kind binding by name in the given scope's chain. + * + * Walks the scope chain upward and consults TWO sources at each step: + * 1. `scope.bindings` — populated during scope-extraction Pass 2 with + * local declarations (`origin: 'local'`). Holds the class's own + * defining file visible to code in that file. + * 2. `indexes.bindings` — populated by the cross-file finalize pass + * with import/namespace/wildcard/reexport origins. Needed to see + * classes brought in via `from models import Dog` at the call + * site's file. + * + * Without (2) we'd miss every cross-file class-receiver call. */ function findClassBindingInScope( startScope: ScopeId, @@ -624,12 +673,22 @@ function findClassBindingInScope( visited.add(currentId); const scope = scopes.scopeTree.getScope(currentId); if (scope === undefined) return undefined; - const bindings = scope.bindings.get(receiverName); - if (bindings !== undefined) { - for (const b of bindings) { + + const localBindings = scope.bindings.get(receiverName); + if (localBindings !== undefined) { + for (const b of localBindings) { if (b.def.type === 'Class' || b.def.type === 'Interface') return b.def; } } + + const finalizedScopeBindings = scopes.bindings.get(currentId); + const importedBindings = finalizedScopeBindings?.get(receiverName); + if (importedBindings !== undefined) { + for (const b of importedBindings) { + if (b.def.type === 'Class' || b.def.type === 'Interface') return b.def; + } + } + currentId = scope.parent; } return undefined;