feat(python): dotted-typebinding receiver resolution

Adds case 3 to `emitReceiverBoundCalls`: when a receiver's typeBinding
has a dotted rawName like `u: models.User` (the constructor-inferred
form fired by `u = models.User(...)`), walk the namespace map + target
file's defs to find the class, then look up the member via ownerId.

`resolveTypeRef`'s QualifiedNameIndex fallback can't cover this because
the target class's qualifiedName in models.py is just `"User"`, not
`"models.User"` — the dotted form only exists in the call-site file's
receiver expression. This pass bridges that gap without modifying the
shared registry.

Fixes 9 more failures (flag-on 45 → 36):
- Python qualified constructor inference (2)
- Python module import CALLS resolution (Issue #337) (3)
- (cluster overlap — several downstream tests in assignment/nullable/
  walrus that propagate through qualified-ctor bindings also benefit)

Flag-off still 191/191.
This commit is contained in:
Gergo Magyar 2026-04-19 20:20:16 +01:00
parent 585d2ba770
commit 2a8eb4ccc4

View file

@ -492,6 +492,38 @@ function emitReceiverBoundCalls(
seen,
);
if (emitted2) emitted++;
continue;
}
}
// ── Case 3: receiver has a dotted typeBinding (`u: models.User`) ──
// Happens when `u = models.User(...)` fires the qualified-call
// constructor-inferred capture. The shared resolveTypeRef can't
// handle it because User's qualifiedName in models.py is just
// "User" (not "models.User"), so QualifiedNameIndex fallback
// misses. Resolve manually via the namespace map.
const typeRef = findReceiverTypeBinding(site.inScope, receiverName, scopes);
if (typeRef !== undefined && typeRef.rawName.includes('.')) {
const [nsName, ...classNameParts] = typeRef.rawName.split('.');
const className = classNameParts.join('.');
const targetFile = namespaceTargets.get(nsName);
if (targetFile !== undefined && className.length > 0) {
const classDef3 = findExportedDef(targetFile, className, parsedFiles);
if (classDef3 !== undefined) {
const memberDef = findOwnedMember(classDef3.nodeId, memberName, parsedFiles);
if (memberDef !== undefined) {
const emitted3 = tryEmitEdge(
graph,
scopes,
nodeLookup,
site,
memberDef,
'python-scope: dotted-typebinding',
seen,
);
if (emitted3) emitted++;
}
}
}
}
}
@ -500,6 +532,30 @@ function emitReceiverBoundCalls(
return emitted;
}
/**
* Walk the scope chain from `startScope` looking for a typeBinding
* named `receiverName`. Returns the TypeRef or undefined if no binding
* exists in the chain.
*/
function findReceiverTypeBinding(
startScope: ScopeId,
receiverName: string,
scopes: ScopeResolutionIndexes,
): { readonly rawName: string } | undefined {
let currentId: ScopeId | null = startScope;
const visited = new Set<ScopeId>();
while (currentId !== null) {
if (visited.has(currentId)) return undefined;
visited.add(currentId);
const scope = scopes.scopeTree.getScope(currentId);
if (scope === undefined) return undefined;
const typeRef = scope.typeBindings.get(receiverName);
if (typeRef !== undefined) return typeRef;
currentId = scope.parent;
}
return undefined;
}
/**
* Build a `localName → targetFilePath` map over the file's module-scope
* import edges, limited to namespace-kind imports (which is what binds