From 1aba80d94217fcab8edb2c9c3a308d8fa6cf03b8 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Mon, 3 Aug 2026 13:51:32 +0000 Subject: [PATCH] fix(python): a method call on the receiver is not a construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../languages/python/receiver-binding.ts | 17 ++++++++++--- .../inferred-field-receiver-matrix.test.ts | 25 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts index 486cf97d9..1d49ae412 100644 --- a/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts +++ b/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts @@ -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) { diff --git a/gitnexus/test/integration/resolvers/inferred-field-receiver-matrix.test.ts b/gitnexus/test/integration/resolvers/inferred-field-receiver-matrix.test.ts index a497636e0..60f4523e0 100644 --- a/gitnexus/test/integration/resolvers/inferred-field-receiver-matrix.test.ts +++ b/gitnexus/test/integration/resolvers/inferred-field-receiver-matrix.test.ts @@ -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', + }, ], }, {