fix(python): a method call on the receiver is not a construction

Review finding on f4e1ead0d. `constructorCallTypeName` accepted ANY call with
an identifier or attribute callee, so `self.p = self.build()` bound `p` to the
non-type `"self.build"` — and because that shares the weakest tier with a real
construction, a later such assignment DISPLACED an earlier `self.p = Outer()`
and left the field untyped again.

Measured before the fix: `self.p = Outer()` followed by `self.p = self.rebuild()`
emitted no CALLS edge at all from a method chaining off `self.p`, and
`self.q = self.make()` bound a type name that resolves to nothing. After:
the real construction survives the reassignment, and a pure method call binds
nothing rather than something wrong.

Rejects a callee rooted at the receiver name. `models.Outer()` still binds —
only `self`-rooted callees are refused, which is exactly the method-call shape.

The matrix gains a `reassigned-from-method-call` row that fails without this
rejection; that discrimination is the only reason the row exists.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-08-03 13:51:32 +00:00
parent b6aa45d93d
commit 1aba80d942
2 changed files with 39 additions and 3 deletions

View file

@ -126,13 +126,24 @@ export function synthesizeReceiverTypeBinding(fnNode: SyntaxNode): CaptureMatch
* (subscript, await, comprehension, bare name) is left alone — that is the
* "name-only guess" this module deliberately refuses.
*/
function constructorCallTypeName(right: SyntaxNode | null): string | undefined {
function constructorCallTypeName(
right: SyntaxNode | null,
receiverName: string,
): string | undefined {
if (right === null || right.type !== 'call') return undefined;
const callee = right.childForFieldName('function');
if (callee === null) return undefined;
if (callee.type !== 'identifier' && callee.type !== 'attribute') return undefined;
const text = callee.text.trim();
return text.length > 0 ? text : undefined;
if (text.length === 0) return undefined;
// `self.p = self.build()` is a METHOD call, not a construction. Accepting it
// bound `p` to the non-type `"self.build"`, which resolves to nothing — and
// because it shares this weakest tier, a later such assignment DISPLACED an
// earlier real `self.p = Outer()`, leaving the field untyped again. Measured:
// both `self.q = self.make()` and the displacement pair emitted no CALLS edge
// at all. Rejecting a callee rooted at the receiver keeps the construction.
if (text === receiverName || text.startsWith(`${receiverName}.`)) return undefined;
return text;
}
/**
@ -217,7 +228,7 @@ export function synthesizeConstructorFieldTypeBindings(fnNode: SyntaxNode): Capt
// an explicit annotation or a parameter annotation still wins.
const constructedType =
explicitType === null && parameterType === undefined
? constructorCallTypeName(right)
? constructorCallTypeName(right, receiverName)
: undefined;
const typeName = explicitType?.text ?? parameterType ?? constructedType;
if (typeName !== undefined) {

View file

@ -142,6 +142,18 @@ class AssignedField:
def run(self, x):
return self.q.inner().compute(x)
class ReassignedField:
def __init__(self):
self.r = Outer()
self.r = self.rebuild()
def rebuild(self):
return Outer()
def run(self, x):
return self.r.inner().compute(x)
`;
// ── Ruby ─────────────────────────────────────────────────────────────────────
@ -376,6 +388,19 @@ const CASES: readonly LanguageCase[] = [
targets: [`Method:${PY_FILE}:Outer.inner#0`],
status: 'resolves',
},
// A method call is not a construction. `self.r = Outer()` followed by
// `self.r = self.rebuild()` must keep the FIRST binding: both would sit
// in the weakest tier, so accepting `self.rebuild()` as a constructor let
// the later one displace the real type and the field went untyped again —
// measured as zero CALLS edges before `constructorCallTypeName` learned to
// reject a callee rooted at the receiver. This row fails without that
// rejection, which is the only reason it exists.
{
name: 'reassigned-from-method-call',
callerId: `Method:${PY_FILE}:ReassignedField.run#1`,
targets: [`Method:${PY_FILE}:Outer.inner#0`],
status: 'resolves',
},
],
},
{