mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-21 00:21:30 +00:00
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:
parent
6b4a792473
commit
38df89d03e
1 changed files with 121 additions and 0 deletions
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue