diff --git a/gitnexus/src/core/ingestion/emit-core/emit-free-call.ts b/gitnexus/src/core/ingestion/emit-core/emit-free-call.ts new file mode 100644 index 000000000..e56f5602a --- /dev/null +++ b/gitnexus/src/core/ingestion/emit-core/emit-free-call.ts @@ -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 }, + handledSites: Set, +): number { + let emitted = 0; + const seen = new Set(); + + 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; +} diff --git a/gitnexus/src/core/ingestion/emit-core/emit-provider.ts b/gitnexus/src/core/ingestion/emit-core/emit-provider.ts new file mode 100644 index 000000000..a1580795e --- /dev/null +++ b/gitnexus/src/core/ingestion/emit-core/emit-provider.ts @@ -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[]; + +/** 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; + + /** + * 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 }; diff --git a/gitnexus/src/core/ingestion/emit-core/index.ts b/gitnexus/src/core/ingestion/emit-core/index.ts index d0c78c824..301d298b2 100644 --- a/gitnexus/src/core/ingestion/emit-core/index.ts +++ b/gitnexus/src/core/ingestion/emit-core/index.ts @@ -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'; diff --git a/gitnexus/src/core/ingestion/emit-core/propagate-return-types.ts b/gitnexus/src/core/ingestion/emit-core/propagate-return-types.ts new file mode 100644 index 000000000..45b0c7df7 --- /dev/null +++ b/gitnexus/src/core/ingestion/emit-core/propagate-return-types.ts @@ -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(); + 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(); + 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); + } + } + } + } +} diff --git a/gitnexus/src/core/ingestion/emit-core/scope-walkers.ts b/gitnexus/src/core/ingestion/emit-core/scope-walkers.ts index 6f2223031..0ab7a15a5 100644 --- a/gitnexus/src/core/ingestion/emit-core/scope-walkers.ts +++ b/gitnexus/src/core/ingestion/emit-core/scope-walkers.ts @@ -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(); + 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(); + 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`. diff --git a/gitnexus/src/core/ingestion/python-scope-emit.ts b/gitnexus/src/core/ingestion/python-scope-emit.ts index b10887d47..7c0d57b67 100644 --- a/gitnexus/src/core/ingestion/python-scope-emit.ts +++ b/gitnexus/src/core/ingestion/python-scope-emit.ts @@ -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 }, - handledSites: Set, -): number { - let emitted = 0; - const seen = new Set(); - // 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(); - 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. */ -function findEnclosingClassDef( - startScope: ScopeId, - scopes: ScopeResolutionIndexes, -): SymbolDefinition | undefined { - let currentId: ScopeId | null = startScope; - const visited = new Set(); - 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(); - 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.