refactor(emit-core): promote receiver-bound dispatcher + compound resolver

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.
This commit is contained in:
Gergo Magyar 2026-04-20 12:58:29 +01:00
parent 791acc330e
commit 46fe4c0a39
4 changed files with 501 additions and 442 deletions

View file

@ -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<string, Scope>,
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;
}

View file

@ -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<string>,
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<string>();
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<string, Scope>();
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;
}

View file

@ -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';

View file

@ -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<ScopeId, readonly Reference[]> },
handledSites: Set<string>,
): 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<string>();
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<string, Scope>();
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<string, Scope>,
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,
};