diff --git a/gitnexus/src/core/ingestion/python-scope-emit.ts b/gitnexus/src/core/ingestion/python-scope-emit.ts index a404f8c7d..889e852de 100644 --- a/gitnexus/src/core/ingestion/python-scope-emit.ts +++ b/gitnexus/src/core/ingestion/python-scope-emit.ts @@ -33,6 +33,7 @@ import type { Scope, ScopeId, SymbolDefinition, + TypeRef, WorkspaceIndex, } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../graph/types.js'; @@ -163,6 +164,15 @@ export function runPythonScopeResolution( (indexes as { methodDispatch: typeof indexes.methodDispatch }).methodDispatch = buildPopulatedMethodDispatch(mroByClassDefId); + // Propagate return-type typeBindings across imports. The shared + // finalize pass copies callable bindings (`from x import f` puts + // `f` in the importer's bindings), but typeBindings stay file-local. + // Without this step, `u = get_user(); u.save()` works only when + // get_user is in the same file as the call. Done as a post-finalize + // mutation since `Scope.typeBindings` is a plain Map (per + // `draftToScope` line 302). + propagateImportedReturnTypes(parsedFiles, indexes); + // ── Phase 3: resolve references via Registry.lookup ───────────────────── const providers: RegistryProviders = { // The Python provider's `arityCompatibility` predates the @@ -625,6 +635,117 @@ function emitFreeCallFallback( return emitted; } +/** Max chain depth for the post-finalize re-follow. */ +const RECHAIN_MAX_DEPTH = 8; + +/** Walk `ref.rawName` through the scope chain's typeBindings looking + * for a terminal class-like rawName. Mirrors the in-extractor + * `followChainedRef` but operates on post-finalize Scope objects so + * it can see imported return-types propagated by + * `propagateImportedReturnTypes`. */ +function followChainPostFinalize( + start: TypeRef, + fromScopeId: ScopeId, + scopes: ScopeResolutionIndexes, +): TypeRef { + let current = start; + const visited = new Set(); + for (let depth = 0; depth < RECHAIN_MAX_DEPTH; depth++) { + if (current.rawName.includes('.')) return current; + let scopeId: ScopeId | null = fromScopeId; + let next: TypeRef | undefined; + while (scopeId !== null) { + const scope = scopes.scopeTree.getScope(scopeId); + if (scope === undefined) break; + next = scope.typeBindings.get(current.rawName); + if (next !== undefined && next !== current) break; + next = undefined; + scopeId = scope.parent; + } + if (next === undefined) return current; + if (visited.has(next.rawName)) return current; + visited.add(next.rawName); + current = next; + } + return current; +} + +/** + * Copy return-type typeBindings across module boundaries via import + * bindings. For each module-scope import like `from x import f`, look + * up `f` in the source file's module-scope typeBindings (which carries + * `f → ReturnType` from the `@type-binding.return` capture) and mirror + * that binding into the importer's module scope. Enables + * `u = f(); u.save()` to chain through `f`'s return-type even when + * `f` lives in another file. + * + * After propagation, re-runs the chain-follow on every scope's + * typeBindings — pass-4 ran before propagation and missed any chain + * whose terminal lived in a foreign file. + * + * Mutates `Scope.typeBindings` (a plain Map per `draftToScope`). + */ +function propagateImportedReturnTypes( + parsedFiles: readonly ParsedFile[], + indexes: ScopeResolutionIndexes, +): void { + // Index module scopes by filePath for fast cross-file lookup. + const moduleScopeByFile = new Map(); + for (const parsed of parsedFiles) { + const moduleScope = parsed.scopes.find((s) => s.kind === 'Module'); + if (moduleScope !== undefined) moduleScopeByFile.set(parsed.filePath, moduleScope); + } + + for (const parsed of parsedFiles) { + const importerModule = moduleScopeByFile.get(parsed.filePath); + if (importerModule === undefined) continue; + const finalizedBindings = indexes.bindings.get(importerModule.id); + if (finalizedBindings === undefined) continue; + + for (const [localName, refs] of finalizedBindings) { + // Skip if importer already has a typeBinding for this name (e.g. + // an explicit local annotation should win over import-derived). + if (importerModule.typeBindings.has(localName)) continue; + + for (const ref of refs) { + if (ref.origin !== 'import' && ref.origin !== 'reexport') continue; + const sourceModule = moduleScopeByFile.get(ref.def.filePath); + if (sourceModule === undefined) continue; + + // The source file's typeBinding is keyed by the def's simple + // name (e.g. `get_user`), not the importer's local alias. Use + // the def's qualifiedName tail. + const qn = ref.def.qualifiedName; + if (qn === undefined) continue; + const dot = qn.lastIndexOf('.'); + const sourceName = dot === -1 ? qn : qn.slice(dot + 1); + + const sourceTypeRef = sourceModule.typeBindings.get(sourceName); + if (sourceTypeRef === undefined) continue; + + // Mirror the binding under the importer's local alias — + // mutating typeBindings is safe because draftToScope produced + // a non-frozen Map. + (importerModule.typeBindings as Map).set(localName, sourceTypeRef); + break; + } + } + } + + // Re-follow chains across every scope so chains terminating in a + // freshly-propagated import binding resolve to their terminal type. + for (const parsed of parsedFiles) { + for (const scope of parsed.scopes) { + for (const [name, ref] of scope.typeBindings) { + const resolved = followChainPostFinalize(ref, scope.id, indexes); + if (resolved !== ref) { + (scope.typeBindings as Map).set(name, resolved); + } + } + } + } +} + /** Walk a scope chain upward looking for the innermost enclosing * Class scope and return that class's def. Used by the `super()` * receiver case to discover the dispatch base. */