feat(python-scope): propagate return-type bindings across imports

Closes the cross-file return-type propagation gap that left tests
like `u = get_user(); u.save()` (where get_user lives in another
file) with `u` typed as the function name instead of its return type.

The shared finalize pass copies callable bindings (`from x import f`
puts `f` in the importer's bindings) but typeBindings stay file-local
because they live on `Scope.typeBindings`, not on the index. Mutate
post-finalize:

- For each module-scope import binding (`origin: 'import'` or
  `'reexport'`), look up the source file's module-scope typeBinding
  for the def's simple name. If present (return-annotation source),
  mirror it under the importer's local alias. Skip when the importer
  already has its own typeBinding for the name (explicit local always
  wins).
- After propagation, re-run a chain-follow on every scope's
  typeBindings — pass-4 ran before propagation and missed any chain
  whose terminal lived in a foreign file. Same algorithm as
  `followChainedRef` in scope-extractor, but operates on the
  finalized scopes so propagated entries are visible.

Mutating `Scope.typeBindings` is safe — `draftToScope` constructs a
plain `new Map(...)`, not a frozen one.

Verification:
- Flag-off: 191/191 (identical baseline).
- Flag-on: 16 fail / 175 pass (was 20/171; +4 — both cross-file
  return-type tests, plus two related propagation cases).
- tsc --noEmit clean.
This commit is contained in:
Gergo Magyar 2026-04-20 09:59:27 +01:00
parent 6b4a792473
commit 38df89d03e

View file

@ -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<string>();
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<string, Scope>();
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<string, TypeRef>).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<string, TypeRef>).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. */