From 46fe4c0a390d72b0e808c6b601c7dd0c01abce09 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Mon, 20 Apr 2026 12:58:29 +0100 Subject: [PATCH] refactor(emit-core): promote receiver-bound dispatcher + compound resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G-Unit 3 of the emit-pipeline generalization plan. - emit-core/emit-compound-receiver.ts — resolveCompoundReceiverClass + matchingOpenParen + COMPOUND_RECEIVER_MAX_DEPTH. Field-fallback is now an option (default true) so strictly-typed languages can opt out via EmitProvider.fieldFallbackOnMethodLookup. - emit-core/emit-receiver-bound.ts — the 7-case dispatcher (super, Cases 0/1/2/3/3b/4). Accepts a ReceiverBoundProviderSubset (isSuperReceiver + fieldFallbackOnMethodLookup) so partial wiring works during the rest of the migration. Documents Contract Invariants I4 (case order) and I5 (no pre-seeding). python-scope-emit.ts shrinks 799 → 384 lines. The orchestrator now calls the generic emitReceiverBoundCalls with an inline minimal provider (pythonEmitProviderInline) — full provider lands in G-Unit 6 when the orchestrator itself moves to languages/python/emit/. Verification: - REGISTRY_PRIMARY_PYTHON=0 (legacy): 191/191. - REGISTRY_PRIMARY_PYTHON=1 (registry): 191/191. - tsc --noEmit clean. --- .../emit-core/emit-compound-receiver.ts | 168 +++++++ .../emit-core/emit-receiver-bound.ts | 310 ++++++++++++ .../src/core/ingestion/emit-core/index.ts | 6 + .../src/core/ingestion/python-scope-emit.ts | 459 +----------------- 4 files changed, 501 insertions(+), 442 deletions(-) create mode 100644 gitnexus/src/core/ingestion/emit-core/emit-compound-receiver.ts create mode 100644 gitnexus/src/core/ingestion/emit-core/emit-receiver-bound.ts diff --git a/gitnexus/src/core/ingestion/emit-core/emit-compound-receiver.ts b/gitnexus/src/core/ingestion/emit-core/emit-compound-receiver.ts new file mode 100644 index 000000000..2813ae323 --- /dev/null +++ b/gitnexus/src/core/ingestion/emit-core/emit-compound-receiver.ts @@ -0,0 +1,168 @@ +/** + * Resolve a compound-receiver expression's TYPE — `user.address.save()`, + * `svc.get_user().save()`, `c.greet().save()` — to the class def of + * the value the receiver expression produces. + * + * Three shapes (parsed C-family-style): + * - bare identifier `name` — look up via typeBinding chain + * - dotted `obj.field[.field]…` — walk fields via class-scope typeBindings + * - call `expr.method()` — recurse into expr, find method's return-type + * typeBinding on its class, resolve to a class + * + * **Field-fallback heuristic** (Phase-9C "unified fixpoint"): when the + * receiver class has no `methodName`, walk its fields and try the + * lookup on each field's type. Useful for dynamically-typed languages + * (Python). Strictly-typed languages should pass + * `fieldFallbackOnMethodLookup: false` via `EmitProvider`. + * + * Generic for any C-family language (`.` member access, `()` call + * syntax). Languages with non-C-family syntax (Ruby blocks, COBOL) + * either don't trigger the call branch or skip this pass entirely. + */ + +import type { ParsedFile, ScopeId, SymbolDefinition, TypeRef } from 'gitnexus-shared'; +import type { Scope } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../model/scope-resolution-indexes.js'; +import { + findClassBindingInScope, + findExportedDefByName, + findReceiverTypeBinding, +} from './scope-walkers.js'; + +/** Max depth for compound-receiver chain resolution (`a().b().c().d()`). + * Practical code rarely exceeds 3-4 hops; the cap prevents + * pathological recursion if the receiver text is malformed. */ +const COMPOUND_RECEIVER_MAX_DEPTH = 4; + +export interface ResolveCompoundReceiverOptions { + /** When true (default), if method lookup fails on the receiver's + * class, walk its fields and try the lookup on each field's class. + * Phase-9C "unified fixpoint" — Python-shaped heuristic. */ + readonly fieldFallback?: boolean; +} + +export function resolveCompoundReceiverClass( + receiverText: string, + inScope: ScopeId, + scopes: ScopeResolutionIndexes, + parsedFiles: readonly ParsedFile[], + classScopeByDefId: ReadonlyMap, + options: ResolveCompoundReceiverOptions = {}, + depth = 0, +): SymbolDefinition | undefined { + if (depth > COMPOUND_RECEIVER_MAX_DEPTH) return undefined; + const text = receiverText.trim(); + if (text.length === 0) return undefined; + const fieldFallback = options.fieldFallback ?? true; + + // Bare identifier — resolve via typeBinding then class lookup. + if (!text.includes('.') && !text.includes('(')) { + const tb = findReceiverTypeBinding(inScope, text, scopes); + if (tb === undefined) return undefined; + return findClassBindingInScope(tb.declaredAtScope, tb.rawName, scopes); + } + + // Trailing `()` — call expression. Strip it and resolve the function + // expression's return type. We only handle the canonical `f()` / + // `obj.method()` shape; nested-arg expressions like `f(g())` are + // out of scope for V1 (depth-capped recursion catches infinite loops). + if (text.endsWith(')')) { + const openIdx = matchingOpenParen(text); + if (openIdx === -1) return undefined; + const fnExpr = text.slice(0, openIdx).trim(); + if (fnExpr.length === 0) return undefined; + + const lastDot = fnExpr.lastIndexOf('.'); + if (lastDot === -1) { + // Free call `name()`. Look up function in scope, then its + // return-type typeBinding (which lives in the function's + // enclosing scope per the language's return-type hoist rule). + const fnDef = findExportedDefByName(fnExpr, inScope, scopes, parsedFiles); + if (fnDef === undefined) return undefined; + const retType = findReceiverTypeBinding(inScope, fnExpr, scopes); + if (retType === undefined) return undefined; + return findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes); + } + + // `obj.method()` — resolve obj's class, look up method's return + // type on that class scope (or the MRO). + const objExpr = fnExpr.slice(0, lastDot); + const methodName = fnExpr.slice(lastDot + 1); + const objClass = resolveCompoundReceiverClass( + objExpr, + inScope, + scopes, + parsedFiles, + classScopeByDefId, + options, + depth + 1, + ); + if (objClass === undefined) return undefined; + + let retType: TypeRef | undefined; + const ownerChain = [objClass.nodeId, ...scopes.methodDispatch.mroFor(objClass.nodeId)]; + for (const ownerId of ownerChain) { + const cs = classScopeByDefId.get(ownerId); + const candidate = cs?.typeBindings.get(methodName); + if (candidate !== undefined) { + retType = candidate; + break; + } + } + + if (retType === undefined && fieldFallback) { + const objCs = classScopeByDefId.get(objClass.nodeId); + if (objCs !== undefined) { + for (const [, fieldType] of objCs.typeBindings) { + const fieldClass = findClassBindingInScope( + fieldType.declaredAtScope, + fieldType.rawName, + scopes, + ); + if (fieldClass === undefined) continue; + const fcs = classScopeByDefId.get(fieldClass.nodeId); + const candidate = fcs?.typeBindings.get(methodName); + if (candidate !== undefined) { + retType = candidate; + break; + } + } + } + } + + if (retType === undefined) return undefined; + return findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes); + } + + // Pure dotted access `obj.field[.field]…` — walk fields. + const parts = text.split('.'); + const head = parts[0]!; + const headType = findReceiverTypeBinding(inScope, head, scopes); + let currentClass: SymbolDefinition | undefined = headType + ? findClassBindingInScope(headType.declaredAtScope, headType.rawName, scopes) + : undefined; + for (let i = 1; i < parts.length && currentClass !== undefined; i++) { + const fieldName = parts[i]!; + const cs = classScopeByDefId.get(currentClass.nodeId); + const fieldType = cs?.typeBindings.get(fieldName); + if (fieldType === undefined) return undefined; + currentClass = findClassBindingInScope(fieldType.declaredAtScope, fieldType.rawName, scopes); + } + return currentClass; +} + +/** Find the index of the `(` that matches the trailing `)` of a + * call-expression text. Returns -1 if unbalanced. */ +export function matchingOpenParen(text: string): number { + if (!text.endsWith(')')) return -1; + let depth = 0; + for (let i = text.length - 1; i >= 0; i--) { + const ch = text[i]; + if (ch === ')') depth++; + else if (ch === '(') { + depth--; + if (depth === 0) return i; + } + } + return -1; +} diff --git a/gitnexus/src/core/ingestion/emit-core/emit-receiver-bound.ts b/gitnexus/src/core/ingestion/emit-core/emit-receiver-bound.ts new file mode 100644 index 000000000..5ff7dcdcb --- /dev/null +++ b/gitnexus/src/core/ingestion/emit-core/emit-receiver-bound.ts @@ -0,0 +1,310 @@ +/** + * Receiver-bound CALLS / ACCESSES emit pass — generic 7-case + * dispatcher consuming `EmitProvider` for the language-specific bits + * (super recognizer, field-fallback toggle). + * + * **Contract Invariant I4 — case order is load-bearing.** The cases + * are evaluated in this order; the FIRST that emits an edge wins: + * + * 1. **super branch** — `provider.isSuperReceiver(receiverName)` → + * MRO walk skipping self + * 2. **Case 0 (compound)** — receiver has `.` or `(` → compound resolver + * 3. **Case 1 (namespace)** — receiver in `namespaceTargets` → exported def + * 4. **Case 2 (class-name)** — receiver resolves to a Class binding → + * MRO walk on that class + * 5. **Case 3 (dotted typeBinding for namespace prefix)** — + * `typeRef.rawName` like `models.User` + * 6. **Case 3b (chain-typebinding)** — `typeRef.rawName` has a dot + * but not a namespace prefix → compound resolver + * 7. **Case 4 (simple typeBinding)** — `typeRef.rawName` has no dot → + * MRO walk + `findOwnedMember` + * + * Reordering or merging cases changes resolution semantics. + * + * **Contract Invariant I5 — pre-seeding `seen` is forbidden.** The + * orchestrator runs this pass FIRST (before `emitReferencesViaLookup`) + * and consumes the populated `handledSites` set. Pre-seeding `seen` + * from the shared resolver's emissions (an old optimization) actively + * suppresses correct emissions for sites the shared resolver also + * resolved to a wrong target. + */ + +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import type { Scope } from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../../graph/types.js'; +import type { ScopeResolutionIndexes } from '../model/scope-resolution-indexes.js'; +import type { EmitProvider } from './emit-provider.js'; +import type { GraphNodeLookup } from './graph-node-lookup.js'; +import { collectNamespaceTargets } from './namespace-targets.js'; +import { + findClassBindingInScope, + findEnclosingClassDef, + findExportedDef, + findOwnedMember, + findReceiverTypeBinding, +} from './scope-walkers.js'; +import { tryEmitEdge } from './emit-edge.js'; +import { resolveCompoundReceiverClass } from './emit-compound-receiver.js'; + +/** Subset of `EmitProvider` consumed by this pass. Accepting the + * subset rather than the full provider keeps tests and partial + * refactors lighter — callers only need to populate what we read. */ +export type ReceiverBoundProviderSubset = Pick< + EmitProvider, + 'isSuperReceiver' | 'fieldFallbackOnMethodLookup' +>; + +export function emitReceiverBoundCalls( + graph: KnowledgeGraph, + scopes: ScopeResolutionIndexes, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, + handledSites: Set, + provider: ReceiverBoundProviderSubset, +): number { + let emitted = 0; + // Per-pass dedup so the multiple cases don't double-emit if two of + // them resolve the same site to the same target. NEVER pre-seed + // from the reference index — see Contract Invariant I5. + const seen = new Set(); + const fieldFallback = provider.fieldFallbackOnMethodLookup ?? true; + + // Class def → Class scope map (for field-chain field-type lookup). + // The class scope's `ownedDefs` contains the Class def per pass2's + // structural-ownership rule. + const classScopeByDefId = new Map(); + for (const p of parsedFiles) { + for (const scope of p.scopes) { + if (scope.kind !== 'Class') continue; + const cd = scope.ownedDefs.find((d) => d.type === 'Class'); + if (cd !== undefined) classScopeByDefId.set(cd.nodeId, scope); + } + } + + for (const parsed of parsedFiles) { + const namespaceTargets = collectNamespaceTargets(parsed, scopes); + + for (const site of parsed.referenceSites) { + if (site.kind !== 'call' && site.kind !== 'read' && site.kind !== 'write') continue; + if (site.explicitReceiver === undefined) continue; + + const receiverName = site.explicitReceiver.name; + const memberName = site.name; + const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`; + + // ── super branch ───────────────────────────────────────────── + if (provider.isSuperReceiver(receiverName)) { + const enclosingClass = findEnclosingClassDef(site.inScope, scopes); + if (enclosingClass !== undefined) { + const ancestors = scopes.methodDispatch.mroFor(enclosingClass.nodeId); + let memberDef: SymbolDefinition | undefined; + for (const ownerId of ancestors) { + memberDef = findOwnedMember(ownerId, memberName, parsedFiles); + if (memberDef !== undefined) break; + } + if (memberDef !== undefined) { + const ok = tryEmitEdge( + graph, + scopes, + nodeLookup, + site, + memberDef, + 'scope-resolution: super-receiver', + seen, + ); + if (ok) { + emitted++; + handledSites.add(siteKey); + } + continue; + } + } + } + + // ── Case 0: compound receiver ──────────────────────────────── + if (receiverName.includes('.') || receiverName.includes('(')) { + const currentClass = resolveCompoundReceiverClass( + receiverName, + site.inScope, + scopes, + parsedFiles, + classScopeByDefId, + { fieldFallback }, + ); + if (currentClass !== undefined) { + const chain = [currentClass.nodeId, ...scopes.methodDispatch.mroFor(currentClass.nodeId)]; + let memberDef: SymbolDefinition | undefined; + for (const ownerId of chain) { + memberDef = findOwnedMember(ownerId, memberName, parsedFiles); + if (memberDef !== undefined) break; + } + if (memberDef !== undefined) { + const ok = tryEmitEdge( + graph, + scopes, + nodeLookup, + site, + memberDef, + 'scope-resolution: chain-receiver', + seen, + ); + if (ok) { + emitted++; + handledSites.add(siteKey); + } + continue; + } + } + } + + // ── Case 1: namespace receiver ─────────────────────────────── + const targetFile = namespaceTargets.get(receiverName); + if (targetFile !== undefined) { + const memberDef = findExportedDef(targetFile, memberName, parsedFiles); + if (memberDef !== undefined) { + const ok = tryEmitEdge( + graph, + scopes, + nodeLookup, + site, + memberDef, + 'scope-resolution: namespace-receiver', + seen, + ); + if (ok) { + emitted++; + handledSites.add(siteKey); + } + continue; + } + } + + // ── Case 2: class-name receiver ────────────────────────────── + const classDef = findClassBindingInScope(site.inScope, receiverName, scopes); + if (classDef !== undefined) { + const chain = [classDef.nodeId, ...scopes.methodDispatch.mroFor(classDef.nodeId)]; + let memberDef: SymbolDefinition | undefined; + for (const ownerId of chain) { + memberDef = findOwnedMember(ownerId, memberName, parsedFiles); + if (memberDef !== undefined) break; + } + if (memberDef !== undefined) { + const ok = tryEmitEdge( + graph, + scopes, + nodeLookup, + site, + memberDef, + 'scope-resolution: class-receiver', + seen, + ); + if (ok) { + emitted++; + handledSites.add(siteKey); + } + continue; + } + } + + // ── Case 3: dotted typeBinding (`u: models.User`) ──────────── + const typeRef = findReceiverTypeBinding(site.inScope, receiverName, scopes); + if (typeRef !== undefined && typeRef.rawName.includes('.')) { + const [nsName, ...classNameParts] = typeRef.rawName.split('.'); + const className = classNameParts.join('.'); + const targetFile3 = namespaceTargets.get(nsName); + if (targetFile3 !== undefined && className.length > 0) { + const classDef3 = findExportedDef(targetFile3, className, parsedFiles); + if (classDef3 !== undefined) { + const memberDef = findOwnedMember(classDef3.nodeId, memberName, parsedFiles); + if (memberDef !== undefined) { + const ok = tryEmitEdge( + graph, + scopes, + nodeLookup, + site, + memberDef, + 'scope-resolution: dotted-typebinding', + seen, + ); + if (ok) { + emitted++; + handledSites.add(siteKey); + } + continue; + } + } + } + } + + // ── Case 3b: chain-typebinding (`city → user.get_city`) ────── + if ( + typeRef !== undefined && + typeRef.rawName.includes('.') && + !typeRef.rawName.includes('(') && + !namespaceTargets.has(typeRef.rawName.split('.')[0]!) + ) { + const ownerDef = resolveCompoundReceiverClass( + typeRef.rawName + '()', + typeRef.declaredAtScope, + scopes, + parsedFiles, + classScopeByDefId, + { fieldFallback }, + ); + if (ownerDef !== undefined) { + const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)]; + let memberDef: SymbolDefinition | undefined; + for (const ownerId of chain) { + memberDef = findOwnedMember(ownerId, memberName, parsedFiles); + if (memberDef !== undefined) break; + } + if (memberDef !== undefined) { + const ok = tryEmitEdge( + graph, + scopes, + nodeLookup, + site, + memberDef, + 'scope-resolution: chain-typebinding', + seen, + ); + if (ok) { + emitted++; + handledSites.add(siteKey); + } + continue; + } + } + } + + // ── Case 4: simple typeBinding (`u: U`) ────────────────────── + if (typeRef !== undefined && !typeRef.rawName.includes('.')) { + const ownerDef = findClassBindingInScope(site.inScope, typeRef.rawName, scopes); + if (ownerDef !== undefined) { + const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)]; + let memberDef: SymbolDefinition | undefined; + for (const ownerId of chain) { + memberDef = findOwnedMember(ownerId, memberName, parsedFiles); + if (memberDef !== undefined) break; + } + if (memberDef !== undefined) { + // For read/write ACCESSES, mirror the legacy DAG's reason + // convention so consumers asserting `reason === 'write'` + // keep working. + const reason = + site.kind === 'write' || site.kind === 'read' + ? site.kind + : 'scope-resolution: typeref-receiver'; + const ok = tryEmitEdge(graph, scopes, nodeLookup, site, memberDef, reason, seen); + if (ok) { + emitted++; + handledSites.add(siteKey); + } + } + } + } + } + } + + return emitted; +} diff --git a/gitnexus/src/core/ingestion/emit-core/index.ts b/gitnexus/src/core/ingestion/emit-core/index.ts index 301d298b2..638600dfd 100644 --- a/gitnexus/src/core/ingestion/emit-core/index.ts +++ b/gitnexus/src/core/ingestion/emit-core/index.ts @@ -38,6 +38,12 @@ export { findExportedDef, } from './scope-walkers.js'; export { emitFreeCallFallback } from './emit-free-call.js'; +export { emitReceiverBoundCalls } from './emit-receiver-bound.js'; +export { + matchingOpenParen, + resolveCompoundReceiverClass, + type ResolveCompoundReceiverOptions, +} from './emit-compound-receiver.js'; export { followChainPostFinalize, propagateImportedReturnTypes } from './propagate-return-types.js'; export { collectNamespaceTargets } from './namespace-targets.js'; export { buildPopulatedMethodDispatch } from './method-dispatch-bridge.js'; diff --git a/gitnexus/src/core/ingestion/python-scope-emit.ts b/gitnexus/src/core/ingestion/python-scope-emit.ts index 7c0d57b67..ecfd696fd 100644 --- a/gitnexus/src/core/ingestion/python-scope-emit.ts +++ b/gitnexus/src/core/ingestion/python-scope-emit.ts @@ -28,18 +28,14 @@ import type { ParsedFile, - Reference, RegistryProviders, Scope, ScopeId, - SymbolDefinition, - TypeRef, WorkspaceIndex, } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../graph/types.js'; import { extractParsedFile } from './scope-extractor-bridge.js'; import { finalizeScopeModel } from './finalize-orchestrator.js'; -import type { ScopeResolutionIndexes } from './model/scope-resolution-indexes.js'; import { resolveReferenceSites, type ResolveStats } from './resolve-references.js'; import { pythonProvider } from './languages/python.js'; import { @@ -51,21 +47,16 @@ import { import { buildGraphNodeLookup, buildPopulatedMethodDispatch, - collectNamespaceTargets, emitFreeCallFallback, emitImportEdges, + emitReceiverBoundCalls, emitReferencesViaLookup, - findClassBindingInScope, - findEnclosingClassDef, - findExportedDef, - findExportedDefByName, - findOwnedMember, - findReceiverTypeBinding, propagateImportedReturnTypes, resolveDefGraphId, - tryEmitEdge, + type EmitProvider, type GraphNodeLookup, } from './emit-core/index.js'; +import { SupportedLanguages } from 'gitnexus-shared'; // ─── Public API ───────────────────────────────────────────────────────────── @@ -200,8 +191,8 @@ export function runPythonScopeResolution( indexes, parsedFiles, nodeLookup, - referenceIndex, handledSites, + pythonEmitProviderInline, ); const freeCallExtras = emitFreeCallFallback( graph, @@ -320,435 +311,6 @@ function buildPythonMro( return mroByDefId; } -/** - * Emit CALLS / ACCESSES edges for dotted references whose receiver is a - * namespace-import binding (`import models; models.User()`) or a class - * name in the call scope (`Dog.classify("dog")`). - * - * The shared `MethodRegistry.lookup` only walks `scope.typeBindings` - * when resolving an explicit receiver. It never consults `scope.bindings` - * for namespace/class-kind entries, nor does it follow an - * `ImportEdge.targetModuleScope` for cross-module lookups. Rather than - * widen the shared contract, this Python-specific pass closes the gap - * with a direct receiver → target walk. - */ -function emitReceiverBoundCalls( - graph: KnowledgeGraph, - scopes: ScopeResolutionIndexes, - parsedFiles: readonly ParsedFile[], - nodeLookup: GraphNodeLookup, - referenceIndex: { readonly bySourceScope: ReadonlyMap }, - handledSites: Set, -): number { - let emitted = 0; - // `seen` is the per-pass dedup so the multiple Cases below don't - // double-emit if two of them resolve the same site to the same - // target. Pre-seeding from the shared resolver was historically - // useful when emit-references ran FIRST, but now the order is - // reversed (emit-references comes after this pass and uses - // `handledSites` to skip what we processed). Keeping the pre-seed - // would suppress legitimate emissions for sites the shared resolver - // happened to also resolve. - const seen = new Set(); - void referenceIndex; // kept in signature for parity with future passes - - // Class def → Class scope map (for field-chain field-type lookup). - // The class scope's `ownedDefs` contains the Class def per pass2's - // structural-ownership rule. - const classScopeByDefId = new Map(); - for (const p of parsedFiles) { - for (const scope of p.scopes) { - if (scope.kind !== 'Class') continue; - const cd = scope.ownedDefs.find((d) => d.type === 'Class'); - if (cd !== undefined) classScopeByDefId.set(cd.nodeId, scope); - } - } - - for (const parsed of parsedFiles) { - const namespaceTargets = collectNamespaceTargets(parsed, scopes); - - for (const site of parsed.referenceSites) { - if (site.kind !== 'call' && site.kind !== 'read' && site.kind !== 'write') continue; - if (site.explicitReceiver === undefined) continue; - - const receiverName = site.explicitReceiver.name; - const memberName = site.name; - const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`; - - // ── super() — resolve to the enclosing class's PARENT (first MRO entry). - // Python's `super()` inside a method dispatches up the MRO chain. - if (/^super\s*\(/.test(receiverName)) { - const enclosingClass = findEnclosingClassDef(site.inScope, scopes); - if (enclosingClass !== undefined) { - const ancestors = scopes.methodDispatch.mroFor(enclosingClass.nodeId); - let memberDef: SymbolDefinition | undefined; - for (const ownerId of ancestors) { - memberDef = findOwnedMember(ownerId, memberName, parsedFiles); - if (memberDef !== undefined) break; - } - if (memberDef !== undefined) { - const ok = tryEmitEdge( - graph, - scopes, - nodeLookup, - site, - memberDef, - 'python-scope: super-receiver', - seen, - ); - if (ok) { - emitted++; - handledSites.add(siteKey); - } - continue; - } - } - } - - // ── Case 0: compound receiver (`user.address.save()` or - // `svc.get_user().save()`) — walk the dotted/call chain, - // resolving each segment to a class via field types or - // method return types. - if (receiverName.includes('.') || receiverName.includes('(')) { - const currentClass = resolveCompoundReceiverClass( - receiverName, - site.inScope, - scopes, - parsedFiles, - classScopeByDefId, - ); - if (currentClass !== undefined) { - const chain = [currentClass.nodeId, ...scopes.methodDispatch.mroFor(currentClass.nodeId)]; - let memberDef: SymbolDefinition | undefined; - for (const ownerId of chain) { - memberDef = findOwnedMember(ownerId, memberName, parsedFiles); - if (memberDef !== undefined) break; - } - if (memberDef !== undefined) { - const ok = tryEmitEdge( - graph, - scopes, - nodeLookup, - site, - memberDef, - 'python-scope: chain-receiver', - seen, - ); - if (ok) { - emitted++; - handledSites.add(siteKey); - } - continue; - } - } - } - - // ── Case 1: namespace receiver (`import models; models.X()`) ─ - const targetFile = namespaceTargets.get(receiverName); - if (targetFile !== undefined) { - const memberDef = findExportedDef(targetFile, memberName, parsedFiles); - if (memberDef !== undefined) { - const ok = tryEmitEdge( - graph, - scopes, - nodeLookup, - site, - memberDef, - 'python-scope: namespace-receiver', - seen, - ); - if (ok) { - emitted++; - handledSites.add(siteKey); - } - continue; - } - } - - // ── Case 2: class-name receiver (`Dog.classify()`) ────────── - const classDef = findClassBindingInScope(site.inScope, receiverName, scopes); - if (classDef !== undefined) { - // Walk the MRO so inherited static/class methods resolve. - const chain = [classDef.nodeId, ...scopes.methodDispatch.mroFor(classDef.nodeId)]; - let memberDef: SymbolDefinition | undefined; - for (const ownerId of chain) { - memberDef = findOwnedMember(ownerId, memberName, parsedFiles); - if (memberDef !== undefined) break; - } - if (memberDef !== undefined) { - const ok = tryEmitEdge( - graph, - scopes, - nodeLookup, - site, - memberDef, - 'python-scope: class-receiver', - seen, - ); - if (ok) { - emitted++; - handledSites.add(siteKey); - } - continue; - } - } - - // ── Case 3: receiver has a dotted typeBinding (`u: models.User`) ── - const typeRef = findReceiverTypeBinding(site.inScope, receiverName, scopes); - if (typeRef !== undefined && typeRef.rawName.includes('.')) { - const [nsName, ...classNameParts] = typeRef.rawName.split('.'); - const className = classNameParts.join('.'); - const targetFile3 = namespaceTargets.get(nsName); - if (targetFile3 !== undefined && className.length > 0) { - const classDef3 = findExportedDef(targetFile3, className, parsedFiles); - if (classDef3 !== undefined) { - const memberDef = findOwnedMember(classDef3.nodeId, memberName, parsedFiles); - if (memberDef !== undefined) { - const ok = tryEmitEdge( - graph, - scopes, - nodeLookup, - site, - memberDef, - 'python-scope: dotted-typebinding', - seen, - ); - if (ok) { - emitted++; - handledSites.add(siteKey); - } - continue; - } - } - } - } - - // ── Case 3b: receiver's typeBinding is a method-call chain - // (`city → user.get_city`). The constructor-inferred capture for - // `city = user.get_city()` stores the attribute text without - // parens. Treat as a call-shape and run through the compound - // resolver, which now also field-walks when the method isn't - // owned by the receiver's class. - if ( - typeRef !== undefined && - typeRef.rawName.includes('.') && - !typeRef.rawName.includes('(') && - !namespaceTargets.has(typeRef.rawName.split('.')[0]!) - ) { - const ownerDef = resolveCompoundReceiverClass( - typeRef.rawName + '()', - typeRef.declaredAtScope, - scopes, - parsedFiles, - classScopeByDefId, - ); - if (ownerDef !== undefined) { - const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)]; - let memberDef: SymbolDefinition | undefined; - for (const ownerId of chain) { - memberDef = findOwnedMember(ownerId, memberName, parsedFiles); - if (memberDef !== undefined) break; - } - if (memberDef !== undefined) { - const ok = tryEmitEdge( - graph, - scopes, - nodeLookup, - site, - memberDef, - 'python-scope: chain-typebinding', - seen, - ); - if (ok) { - emitted++; - handledSites.add(siteKey); - } - continue; - } - } - } - - // ── Case 4: simple typeBinding (`u: U` where U is aliased import) - if (typeRef !== undefined && !typeRef.rawName.includes('.')) { - const ownerDef = findClassBindingInScope(site.inScope, typeRef.rawName, scopes); - if (ownerDef !== undefined) { - const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)]; - let memberDef: SymbolDefinition | undefined; - for (const ownerId of chain) { - memberDef = findOwnedMember(ownerId, memberName, parsedFiles); - if (memberDef !== undefined) break; - } - if (memberDef !== undefined) { - // For read/write ACCESSES, mirror the legacy DAG's reason - // convention (just the kind word) so consumers asserting - // `reason === 'write'` keep working. - const reason = - site.kind === 'write' || site.kind === 'read' - ? site.kind - : 'python-scope: typeref-receiver'; - const ok = tryEmitEdge(graph, scopes, nodeLookup, site, memberDef, reason, seen); - if (ok) { - emitted++; - handledSites.add(siteKey); - } - } - } - } - } - } - - return emitted; -} - -/** 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. */ -const COMPOUND_RECEIVER_MAX_DEPTH = 4; - -/** - * Resolve a compound-receiver expression's TYPE (the class def of the - * value it produces). Handles three shapes: - * - bare identifier `name` — look up via typeBinding chain - * - dotted `obj.field[.field]…` — walk fields via class-scope typeBindings - * - call `expr.method()` — recurse into expr, find method's return-type - * typeBinding on its class, resolve to a class - * - * Returns the class `SymbolDefinition` or undefined if the chain dead-ends. - * Depth-capped at COMPOUND_RECEIVER_MAX_DEPTH. - */ -function resolveCompoundReceiverClass( - receiverText: string, - inScope: ScopeId, - scopes: ScopeResolutionIndexes, - parsedFiles: readonly ParsedFile[], - classScopeByDefId: ReadonlyMap, - depth = 0, -): SymbolDefinition | undefined { - if (depth > COMPOUND_RECEIVER_MAX_DEPTH) return undefined; - const text = receiverText.trim(); - if (text.length === 0) return undefined; - - // Bare identifier — resolve via typeBinding then class lookup. - if (!text.includes('.') && !text.includes('(')) { - const tb = findReceiverTypeBinding(inScope, text, scopes); - if (tb === undefined) return undefined; - return findClassBindingInScope(tb.declaredAtScope, tb.rawName, scopes); - } - - // Trailing `()` — call expression. Strip it and resolve the function - // expression's return type. We only handle the canonical `f()` / - // `obj.method()` shape; nested-arg expressions like `f(g())` are - // out of scope for V1 (depth-capped recursion catches infinite loops). - if (text.endsWith(')')) { - // Find the matching `(` by walking from end with a depth counter - // so nested parens in args don't fool us. - const openIdx = matchingOpenParen(text); - if (openIdx === -1) return undefined; - const fnExpr = text.slice(0, openIdx).trim(); - if (fnExpr.length === 0) return undefined; - - // Split into receiver and method name on the LAST dot. - const lastDot = fnExpr.lastIndexOf('.'); - if (lastDot === -1) { - // Free call `name()`. Look up function in scope, then its - // return-type typeBinding (which lives in the function's - // enclosing scope per Pass 4 hoist). - const fnDef = findExportedDefByName(fnExpr, inScope, scopes, parsedFiles); - if (fnDef === undefined) return undefined; - // The return-type binding key == the function's simple name in - // the scope where the function is bound. Walk for it. - const retType = findReceiverTypeBinding(inScope, fnExpr, scopes); - if (retType === undefined) return undefined; - return findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes); - } - // `obj.method()` — resolve obj's class, look up method, then its - // return-type typeBinding on that class scope. - const objExpr = fnExpr.slice(0, lastDot); - const methodName = fnExpr.slice(lastDot + 1); - const objClass = resolveCompoundReceiverClass( - objExpr, - inScope, - scopes, - parsedFiles, - classScopeByDefId, - depth + 1, - ); - if (objClass === undefined) return undefined; - // Walk the MRO so methods inherited from ancestors resolve. A class - // owning the method itself doesn't always exist — `class C(B)` may - // inherit `greet` from B (or B's parent A), and the return-type - // typeBinding lives on the SCOPE OF THE CLASS THAT OWNS THE METHOD. - let retType: TypeRef | undefined; - const ownerChain = [objClass.nodeId, ...scopes.methodDispatch.mroFor(objClass.nodeId)]; - for (const ownerId of ownerChain) { - const cs = classScopeByDefId.get(ownerId); - const candidate = cs?.typeBindings.get(methodName); - if (candidate !== undefined) { - retType = candidate; - break; - } - } - // Field-fallback: if the receiver class has no `methodName` itself, - // walk its fields and try the same lookup on each field's type. - // Matches the "unified fixpoint" behavior tested by the - // method-chain fixture where `user.get_city()` reaches - // `Address.get_city` through User's `address: Address` field. - if (retType === undefined) { - const objCs = classScopeByDefId.get(objClass.nodeId); - if (objCs !== undefined) { - for (const [, fieldType] of objCs.typeBindings) { - const fieldClass = findClassBindingInScope( - fieldType.declaredAtScope, - fieldType.rawName, - scopes, - ); - if (fieldClass === undefined) continue; - const fcs = classScopeByDefId.get(fieldClass.nodeId); - const candidate = fcs?.typeBindings.get(methodName); - if (candidate !== undefined) { - retType = candidate; - break; - } - } - } - } - if (retType === undefined) return undefined; - return findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes); - } - - // Pure dotted access `obj.field[.field]…` — walk fields. - const parts = text.split('.'); - const head = parts[0]!; - const headType = findReceiverTypeBinding(inScope, head, scopes); - let currentClass: SymbolDefinition | undefined = headType - ? findClassBindingInScope(headType.declaredAtScope, headType.rawName, scopes) - : undefined; - for (let i = 1; i < parts.length && currentClass !== undefined; i++) { - const fieldName = parts[i]!; - const cs = classScopeByDefId.get(currentClass.nodeId); - const fieldType = cs?.typeBindings.get(fieldName); - if (fieldType === undefined) return undefined; - currentClass = findClassBindingInScope(fieldType.declaredAtScope, fieldType.rawName, scopes); - } - return currentClass; -} - -/** Find the index of the `(` that matches the trailing `)` of a - * call-expression text. Returns -1 if unbalanced. */ -function matchingOpenParen(text: string): number { - if (!text.endsWith(')')) return -1; - let depth = 0; - for (let i = text.length - 1; i >= 0; i--) { - const ch = text[i]; - if (ch === ')') depth++; - else if (ch === '(') { - depth--; - if (depth === 0) return i; - } - } - return -1; -} - /** * Populate `ownerId` on Method/Function/Field defs that live structurally * inside a `Class` scope. @@ -794,3 +356,16 @@ function populateMethodOwnerIds(parsed: ParsedFile): void { } } } + +/** Minimal `EmitProvider` carrying only the hooks the receiver-bound + * pass currently consults. The full provider (with mergeBindings, + * resolveImportTarget, arityCompatibility, buildMro, populateOwners) + * lands in G-Unit 6 when it moves to `languages/python/emit/`. */ +const pythonEmitProviderInline: Pick< + EmitProvider, + 'language' | 'isSuperReceiver' | 'fieldFallbackOnMethodLookup' +> = { + language: SupportedLanguages.Python, + isSuperReceiver: (text) => /^super\s*\(/.test(text), + fieldFallbackOnMethodLookup: true, +};