refactor(emit-core): EmitProvider interface + promote 5 generic helpers

G-Units 1-2 of the emit-pipeline generalization plan.

Adds:
- emit-core/emit-provider.ts — typed EmitProvider contract (6 required +
  2 optional fields). Will be consumed by the generic orchestrator in
  G-Unit 6. Documents the LanguageProvider vs EmitProvider boundary.
- emit-core/emit-free-call.ts — emitFreeCallFallback promoted as-is
  (drops the unused referenceIndex pre-seed parameter; underscore-prefixed
  to keep the signature compatible).
- emit-core/propagate-return-types.ts — propagateImportedReturnTypes +
  followChainPostFinalize. Documents the mutation contract (Invariant
  I3 + I6 from the plan): runs after finalize, before resolve, mutates
  the non-frozen Scope.typeBindings map.
- emit-core/scope-walkers.ts: + findEnclosingClassDef +
  findExportedDefByName. Both were already generic in the Python
  source.

python-scope-emit.ts shrinks 1055 → 799 lines (–256). Imports the
promoted helpers from emit-core. No behavior change.

Verification:
- REGISTRY_PRIMARY_PYTHON=0 (legacy): 191/191.
- REGISTRY_PRIMARY_PYTHON=1 (registry): 191/191.
- tsc --noEmit clean.
This commit is contained in:
Gergo Magyar 2026-04-20 12:51:07 +01:00
parent 61acc9b107
commit 791acc330e
6 changed files with 440 additions and 263 deletions

View file

@ -0,0 +1,71 @@
/**
* 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".
*
* **Free-call dedup contract (Contract Invariant I2):** free calls
* collapse to one CALLS edge per (caller, target) pair regardless of
* how many call sites the caller contains. Mirrors the legacy DAG's
* dedup semantics (what the `default-params` / `variadic` / `overload`
* fixtures expect). Member calls keep position-based dedup elsewhere.
*
* Generic; promoted from `python-scope-emit.ts` per the emit-core
* generalization plan.
*/
import type { ParsedFile, Reference, ScopeId } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../graph/types.js';
import type { ScopeResolutionIndexes } from '../model/scope-resolution-indexes.js';
import type { GraphNodeLookup } from './graph-node-lookup.js';
import { resolveCallerGraphId, resolveDefGraphId } from './graph-id.js';
import { findCallableBindingInScope } from './scope-walkers.js';
export function emitFreeCallFallback(
graph: KnowledgeGraph,
scopes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
_referenceIndex: { readonly bySourceScope: ReadonlyMap<ScopeId, readonly Reference[]> },
handledSites: Set<string>,
): number {
let emitted = 0;
const seen = new Set<string>();
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 callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup);
if (callerGraphId === undefined) continue;
const tgtGraphId = resolveDefGraphId(fnDef.filePath, fnDef, nodeLookup);
if (tgtGraphId === undefined) continue;
// Always mark the site as handled — even when the dedup-collapse
// means we don't add a new edge — so `emit-references` skips its
// potentially-wrong fallback for the same site.
handledSites.add(`${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`);
const relId = `rel:CALLS:${callerGraphId}->${tgtGraphId}`;
if (seen.has(relId)) continue;
seen.add(relId);
graph.addRelationship({
id: relId,
sourceId: callerGraphId,
targetId: tgtGraphId,
type: 'CALLS',
confidence: 0.85,
// Match legacy DAG's reason convention so consumers that
// assert `reason === 'import-resolved'` keep working.
reason: fnDef.filePath !== parsed.filePath ? 'import-resolved' : 'local-call',
});
emitted++;
}
}
return emitted;
}

View file

@ -0,0 +1,154 @@
/**
* `EmitProvider` the per-language contract consumed by the generic
* scope-resolution orchestrator (`runScopeResolution`).
*
* **Two distinct provider contracts:** the codebase has both
* `LanguageProvider` (in `language-provider.ts`) and this `EmitProvider`.
* Their lifecycles differ:
*
* - `LanguageProvider` is the **parsing-side** contract how to emit
* captures, classify scopes, interpret imports / typeBindings. ~40
* fields covering both legacy and new pipelines. Consumed by
* `ScopeExtractor`.
* - `EmitProvider` is the **emit-side** contract how the resolution
* pipeline dispatches references to graph edges. 6 required + 2
* optional fields. Consumed by `runScopeResolution`.
*
* They share three concept names (`arityCompatibility`, `mergeBindings`,
* `resolveImportTarget`) because the emit pipeline reuses a few
* finalize hooks. Per-language wiring passes the SAME function
* reference through both interfaces there is no second copy of the
* logic. Rationale for not collapsing them: lifecycles differ
* (parsing-side runs once per file at extract time, emit-side runs
* once per workspace at resolve time), and merging would create a
* god-interface that complicates future migrations.
*
* **Reference implementation:** `languages/python/emit/index.ts`
* `pythonEmitProvider` is the canonical example. Read that file when
* migrating a new language; this interface lists the 6 fields that
* implementation populates.
*
* Plan: `docs/plans/2026-04-20-001-refactor-emit-pipeline-generalization-plan.md`.
*/
import type {
BindingRef,
Callsite,
ParsedFile,
Scope,
ScopeId,
SupportedLanguages,
SymbolDefinition,
} from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../graph/types.js';
import type { GraphNodeLookup } from './graph-node-lookup.js';
/** A LinearizeStrategy receives the full ancestor map so C3-style
* algorithms (which need to merge each parent's MRO) can implement
* themselves. Python's depth-first first-seen only consumes
* `directParents` and `parentsByDefId`. */
export type LinearizeStrategy = (
classDefId: string,
directParents: readonly string[],
parentsByDefId: ReadonlyMap<string, readonly string[]>,
) => string[];
/** Result of `EmitProvider.arityCompatibility` — mirrors `RegistryProviders.arityCompatibility`. */
export type ArityVerdict = 'compatible' | 'unknown' | 'incompatible';
export interface EmitProvider {
/** Identity for telemetry + per-language flag check. */
readonly language: SupportedLanguages;
// ─── Pipeline hooks ────────────────────────────────────────────────────────
/**
* Resolve an import statement's `targetRaw` (e.g. `models.user`,
* `./helpers`) into an absolute repo-relative file path, or `null`
* for unresolvable / external modules.
*
* Called once per `ParsedImport` during `finalizeScopeModel`. The
* Python implementation wraps `resolvePythonImportTarget`.
*/
resolveImportTarget(targetRaw: string, fromFile: string): string | null;
/**
* Per-scope binding-merge precedence. The shared finalize pass
* collects bindings from multiple sources (local declarations,
* imports, namespace, wildcard, reexport) and asks the language
* how to order them.
*
* Python uses LEGB: local > import / namespace / reexport > wildcard.
*/
mergeBindings(
existing: readonly BindingRef[],
incoming: readonly BindingRef[],
scopeId: ScopeId,
): BindingRef[];
/**
* Per-language arity compatibility between a callsite and a
* candidate def. The shared `MethodRegistry.lookup` consults this
* to penalize incompatible candidates without disqualifying them
* outright. Note arg order `(callsite, def)` matches the
* `RegistryProviders` contract; some legacy provider impls use
* `(def, callsite)` and need an adapter at the wiring site.
*/
arityCompatibility(callsite: Callsite, def: SymbolDefinition): ArityVerdict;
// ─── Per-language strategies ───────────────────────────────────────────────
/**
* Compute the method-dispatch order for every Class def in the
* workspace. Python uses depth-first first-seen via
* `pythonLinearize`; future languages may use C3 (Ruby, Python's
* real MRO when we go beyond the simplified walk), single-
* inheritance only (Java), or empty-map (languages without
* inheritance).
*/
buildMro(
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
): Map<string /* DefId */, string[] /* ancestor DefIds */>;
/**
* Mutate `parsed.localDefs[i].ownerId` to point at the structural
* owner. Python's rule: methods (Function defs whose parent scope
* is Class) AND class-body fields (defs in Class scopes) are owned
* by the enclosing class. Other languages may have richer rules
* (e.g., Java inner-class qualification).
*/
populateOwners(parsed: ParsedFile): void;
/**
* Recognize a `super(...)`-style receiver text. Python returns
* `/^super\s*\(/.test(t)`. Java returns `t === 'super'`. C++ may
* also need `this` capture. Languages without inheritance return
* constant `false`.
*/
isSuperReceiver(receiverText: string): boolean;
// ─── Optional toggles ──────────────────────────────────────────────────────
/**
* Whether the orchestrator should run `propagateImportedReturnTypes`
* after finalize. Default `true`. TypeScript with explicit type
* exports may want a different propagation strategy and opt out.
*/
readonly propagatesReturnTypesAcrossImports?: boolean;
/**
* Whether the compound-receiver resolver should fall back to
* walking field types when method lookup on the receiver's class
* fails (the "Phase-9C unified fixpoint" heuristic). Default
* `true`. Strictly-typed languages should set `false` because the
* heuristic can produce edges that wouldn't survive a real type
* check.
*/
readonly fieldFallbackOnMethodLookup?: boolean;
}
// Re-export Scope so consumers don't need to dig into `gitnexus-shared`
// for the type they're already using transitively.
export type { Scope };

View file

@ -32,8 +32,13 @@ export {
findReceiverTypeBinding,
findClassBindingInScope,
findCallableBindingInScope,
findEnclosingClassDef,
findExportedDefByName,
findOwnedMember,
findExportedDef,
} from './scope-walkers.js';
export { emitFreeCallFallback } from './emit-free-call.js';
export { followChainPostFinalize, propagateImportedReturnTypes } from './propagate-return-types.js';
export { collectNamespaceTargets } from './namespace-targets.js';
export { buildPopulatedMethodDispatch } from './method-dispatch-bridge.js';
export type { ArityVerdict, EmitProvider, LinearizeStrategy } from './emit-provider.js';

View file

@ -0,0 +1,129 @@
/**
* Cross-file return-type typeBinding propagation + post-finalize
* chain re-follow.
*
* **Why this lives in emit-core:** the algorithm is language-agnostic.
* Every language with cross-file callable imports needs the same
* mirror-binding step, otherwise `u = f(); u.save()` only resolves
* when `f` is in the same file as the call.
*
* **Mutation contract (Contract Invariant I3 + I6):**
* - Mutates `Scope.typeBindings` (a plain `new Map(...)` from
* `draftToScope`, NOT frozen intentional, do not freeze).
* - MUST run AFTER `finalizeScopeModel` (so `indexes.bindings` is
* populated) but BEFORE `resolveReferenceSites` (so resolution
* sees the propagated types).
*
* Generic; promoted from `python-scope-emit.ts` per the emit-core
* generalization plan.
*/
import type { ParsedFile, Scope, ScopeId, TypeRef } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../model/scope-resolution-indexes.js';
/** 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`. */
export 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 language's return-type annotation
* capture) and mirror that binding into the importer's module scope.
*
* After propagation, re-runs the chain-follow on every scope's
* typeBindings the in-extractor pass-4 ran before propagation and
* missed any chain whose terminal lived in a foreign file.
*/
export 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);
}
}
}
}
}

View file

@ -137,6 +137,83 @@ export function findCallableBindingInScope(
return undefined;
}
/**
* Walk a scope chain upward looking for the innermost enclosing
* Class scope and return that class's def. Used by per-language
* `super` receiver branches to discover the dispatch base.
*/
export function findEnclosingClassDef(
startScope: ScopeId,
scopes: ScopeResolutionIndexes,
): SymbolDefinition | undefined {
let currentId: ScopeId | null = startScope;
const visited = new Set<ScopeId>();
while (currentId !== null) {
if (visited.has(currentId)) return undefined;
visited.add(currentId);
const scope = scopes.scopeTree.getScope(currentId);
if (scope === undefined) return undefined;
if (scope.kind === 'Class') {
const cd = scope.ownedDefs.find((d) => d.type === 'Class');
if (cd !== undefined) return cd;
}
currentId = scope.parent;
}
return undefined;
}
/**
* Find a free-function def by simple name across all parsed files,
* preferring scope-chain-visible bindings (import + finalized scope
* bindings) before falling back to a workspace-wide simple-name scan.
*
* The fallback scan is intentionally loose so per-language compound
* resolvers can find a callable target even when the binding chain
* doesn't surface it (e.g. cross-package re-exports the finalize
* pass missed). Strictly-typed languages may want to disable the
* fallback by simply not calling this helper from their compound
* resolver.
*/
export function findExportedDefByName(
name: string,
inScope: ScopeId,
scopes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
): SymbolDefinition | undefined {
let currentId: ScopeId | null = inScope;
const visited = new Set<ScopeId>();
while (currentId !== null) {
if (visited.has(currentId)) break;
visited.add(currentId);
const scope = scopes.scopeTree.getScope(currentId);
if (scope === undefined) break;
const local = scope.bindings.get(name);
if (local !== undefined) {
for (const b of local) {
if (b.def.type === 'Function' || b.def.type === 'Method') return b.def;
}
}
const finalized = scopes.bindings.get(currentId)?.get(name);
if (finalized !== undefined) {
for (const b of finalized) {
if (b.def.type === 'Function' || b.def.type === 'Method') return b.def;
}
}
currentId = scope.parent;
}
// Fallback: scan parsed files for any matching simple-name def.
for (const f of parsedFiles) {
for (const def of f.localDefs) {
if (def.type !== 'Function' && def.type !== 'Method') continue;
const qn = def.qualifiedName;
if (qn === undefined) continue;
const simple = qn.lastIndexOf('.') === -1 ? qn : qn.slice(qn.lastIndexOf('.') + 1);
if (simple === name) return def;
}
}
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`.

View file

@ -52,15 +52,16 @@ import {
buildGraphNodeLookup,
buildPopulatedMethodDispatch,
collectNamespaceTargets,
emitFreeCallFallback,
emitImportEdges,
emitReferencesViaLookup,
findCallableBindingInScope,
findClassBindingInScope,
findEnclosingClassDef,
findExportedDef,
findExportedDefByName,
findOwnedMember,
findReceiverTypeBinding,
mapReferenceKindToEdgeType,
resolveCallerGraphId,
propagateImportedReturnTypes,
resolveDefGraphId,
tryEmitEdge,
type GraphNodeLookup,
@ -599,222 +600,6 @@ 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<ScopeId, readonly Reference[]> },
handledSites: Set<string>,
): number {
let emitted = 0;
const seen = new Set<string>();
// 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;
// Free calls collapse to one CALLS edge per (caller, target)
// pair. Multiple call sites in the same caller body should not
// emit multiple edges (legacy DAG semantics — what
// `default-params` / `variadic` / `overload` tests expect).
// Member calls keep positional dedup elsewhere.
const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup);
if (callerGraphId === undefined) continue;
const tgtGraphId = resolveDefGraphId(fnDef.filePath, fnDef, nodeLookup);
if (tgtGraphId === undefined) continue;
// Always mark the site as handled — even when the dedup-collapse
// means we don't add a new edge — so `emit-references` skips its
// potentially-wrong fallback for the same site.
handledSites.add(`${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`);
const relId = `rel:CALLS:${callerGraphId}->${tgtGraphId}`;
if (seen.has(relId)) continue;
seen.add(relId);
graph.addRelationship({
id: relId,
sourceId: callerGraphId,
targetId: tgtGraphId,
type: 'CALLS',
confidence: 0.85,
// Match legacy DAG's reason convention so consumers that
// assert `reason === 'import-resolved'` keep working.
reason: fnDef.filePath !== parsed.filePath ? 'import-resolved' : 'local-call',
});
emitted++;
}
}
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. */
function findEnclosingClassDef(
startScope: ScopeId,
scopes: ScopeResolutionIndexes,
): SymbolDefinition | undefined {
let currentId: ScopeId | null = startScope;
const visited = new Set<ScopeId>();
while (currentId !== null) {
if (visited.has(currentId)) return undefined;
visited.add(currentId);
const scope = scopes.scopeTree.getScope(currentId);
if (scope === undefined) return undefined;
if (scope.kind === 'Class') {
const cd = scope.ownedDefs.find((d) => d.type === 'Class');
if (cd !== undefined) return cd;
}
currentId = scope.parent;
}
return undefined;
}
/** 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. */
@ -964,50 +749,6 @@ function matchingOpenParen(text: string): number {
return -1;
}
/** Look up a free-function def by simple name across all parsed files
* whose scope chain from `inScope` includes the binding. Used by the
* free-call branch of `resolveCompoundReceiverClass`. */
function findExportedDefByName(
name: string,
inScope: ScopeId,
scopes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
): SymbolDefinition | undefined {
// Walk the call site's scope chain looking for a binding.
let currentId: ScopeId | null = inScope;
const visited = new Set<ScopeId>();
while (currentId !== null) {
if (visited.has(currentId)) break;
visited.add(currentId);
const scope = scopes.scopeTree.getScope(currentId);
if (scope === undefined) break;
const local = scope.bindings.get(name);
if (local !== undefined) {
for (const b of local) {
if (b.def.type === 'Function' || b.def.type === 'Method') return b.def;
}
}
const finalized = scopes.bindings.get(currentId)?.get(name);
if (finalized !== undefined) {
for (const b of finalized) {
if (b.def.type === 'Function' || b.def.type === 'Method') return b.def;
}
}
currentId = scope.parent;
}
// Fallback: scan parsed files for any matching simple-name def.
for (const f of parsedFiles) {
for (const def of f.localDefs) {
if (def.type !== 'Function' && def.type !== 'Method') continue;
const qn = def.qualifiedName;
if (qn === undefined) continue;
const simple = qn.lastIndexOf('.') === -1 ? qn : qn.slice(qn.lastIndexOf('.') + 1);
if (simple === name) return def;
}
}
return undefined;
}
/**
* Populate `ownerId` on Method/Function/Field defs that live structurally
* inside a `Class` scope.