feat(python-scope): resolve dotted receivers via class-scope field types

Unit 4 partial — the dotted-receiver case (`user.address.save()`).

Class-body annotations like `class User: address: Address` already
land in the class scope's typeBindings via the existing
`@type-binding.annotation` capture. This commit consumes that signal:

- Build a `Map<classDefId, Scope>` from every parsed file's class
  scopes once per resolution pass.
- New Case 0 in `emitReceiverBoundCalls`: when the receiver's name
  contains a dot, walk the chain — resolve the head's type, then for
  each remaining segment look up that field's type in the owner
  class's scope.typeBindings, then emit the call against the final
  class with MRO walk.
- Cross-scope lookups use each TypeRef's `declaredAtScope` so an
  imported `Address` resolves in the file that owns the field
  declaration, not the file holding the call site.

Verification:
- Flag-off: 191/191 (identical baseline).
- Flag-on: 29 fail / 162 pass (was 31/160; both `Field type
  resolution` fixtures now pass — same-file and cross-file disambig).
- tsc --noEmit clean.

Remaining Unit 4 work (write ACCESSES, `self.X` for-loop iteration)
needs Unit 6's tuple/iterable destructuring before it can land —
`for u in self.users` requires the iterable typing path.
This commit is contained in:
Gergo Magyar 2026-04-20 08:51:40 +01:00
parent f9ff390456
commit c5221b98f5

View file

@ -338,6 +338,18 @@ function emitReceiverBoundCalls(
}
}
// 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);
@ -348,6 +360,52 @@ function emitReceiverBoundCalls(
const receiverName = site.explicitReceiver.name;
const memberName = site.name;
// ── Case 0: dotted receiver (`user.address.save()`) ──────────
// Walk the dotted chain via class-scope typeBindings (fields).
if (receiverName.includes('.')) {
const parts = receiverName.split('.');
const head = parts[0]!;
const headType = findReceiverTypeBinding(site.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) {
currentClass = undefined;
break;
}
currentClass = findClassBindingInScope(
fieldType.declaredAtScope,
fieldType.rawName,
scopes,
);
}
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: field-chain',
seen,
);
if (ok) emitted++;
continue;
}
}
}
// ── Case 1: namespace receiver (`import models; models.X()`) ─
const targetFile = namespaceTargets.get(receiverName);
if (targetFile !== undefined) {