fix(python): infer an instance field's type from the constructor it calls

`self.outer = Outer()` in `__init__` bound nothing, so `self.outer.inner()`
had no receiver type and the fold declined the whole chain — the Python half
of #2807. An annotated field (`self.outer: Outer = ...`) or one assigned from
an annotated parameter already worked.

`synthesizeConstructorFieldTypeBindings` deliberately refused to infer "from
arbitrary unannotated RHS expressions ... not a name-only guess". A CALL is not
that: Python has no `new`, so a call to a plain (or dotted) name is the only
syntactic construction form there is, and it is the same positive evidence
every other language reads from `= new X()`. A bare name, subscript, await or
comprehension is still refused.

Adds it as a THIRD and weakest tier. The existing explicit/parameter boolean
becomes a rank, so precedence is now explicit annotation > parameter annotation
> construction, and a later same-tier assignment still wins (the last write in
`__init__` is the live one). `interpretPythonTypeBinding` maps the new marker to
`constructor-inferred` (strength 1) — checked before the parameter branch, which
would otherwise have read the absent parameter marker as `annotation` and
promoted a guess to the strongest tier.

The Class-scope hoist needed no change: `@type-binding.instance-field` already
carries it in `pythonBindingScopeFor`.

Measured: `AssignedField.run` now emits `Outer.inner`, exact parity with the
annotated-field and local-const rows.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-08-03 13:02:44 +00:00
parent 18a48c7313
commit 6c99f5488c
2 changed files with 85 additions and 19 deletions

View file

@ -120,8 +120,16 @@ export function interpretPythonTypeBinding(captures: CaptureMatch): ParsedTypeBi
// `Registry.lookup` Step 2 treats them identically.
else if (captures['@type-binding.cls'] !== undefined) source = 'self';
else if (captures['@type-binding.instance-field'] !== undefined) {
source =
captures['@type-binding.parameter'] !== undefined ? 'parameter-annotation' : 'annotation';
// Three tiers, weakest last: `self.x = Outer()` is inferred from a
// CONSTRUCTOR CALL (#2807) and must stay below both an explicit field
// annotation and a parameter annotation, so a class that both annotates
// the field and constructs it keeps the annotation. Checked before the
// parameter branch because the constructor form carries no
// `@type-binding.parameter` marker and would otherwise read as 'annotation'.
if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
else
source =
captures['@type-binding.parameter'] !== undefined ? 'parameter-annotation' : 'annotation';
} else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation';
else if (captures['@type-binding.alias'] !== undefined) source = 'assignment-inferred';

View file

@ -114,17 +114,40 @@ export function synthesizeReceiverTypeBinding(fnNode: SyntaxNode): CaptureMatch
};
}
/**
* The class name of a direct constructor call — `Outer()` or `pkg.Outer()` —
* or `undefined` for anything else. Python has no `new`, so a call is the only
* syntactic construction form, and a call to a plain name is the shape every
* other language spells `= new X()`.
*
* A dotted callee keeps its full text: `resolveTypeRef` sends dotted names
* through `QualifiedNameIndex`, exactly as the module-level
* `u = models.User()` pattern in `query.ts` already relies on. Any other RHS
* (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 {
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;
}
/**
* Synthesize class-scope field bindings for the common Python constructor
* injection pattern:
* injection patterns:
*
* def __init__(self, service: Service):
* self.service = service
* self.service = service # from the PARAMETER's annotation
* self.cache: Cache = build() # from the FIELD's own annotation
* self.outer = Outer() # from the CONSTRUCTOR called (#2807)
*
* An explicit field annotation (`self.service: Service = ...`) is also
* accepted and takes precedence over a parameter annotation. Deliberately do
* not infer from arbitrary unannotated RHS expressions: the receiver resolver
* needs a declared type, not a name-only guess.
* The three tiers rank in that order — an explicit field annotation beats a
* parameter annotation, which beats a construction. Anything else is still
* refused: the receiver resolver needs positive evidence, not a name-only
* guess from an arbitrary RHS.
*/
export function synthesizeConstructorFieldTypeBindings(fnNode: SyntaxNode): CaptureMatch[] {
if (fnNode.childForFieldName('name')?.text !== '__init__') return [];
@ -148,7 +171,14 @@ export function synthesizeConstructorFieldTypeBindings(fnNode: SyntaxNode): Capt
if (name !== null && annotation !== null) parameterTypes.set(name, annotation.text);
}
type Candidate = { readonly match: CaptureMatch; readonly explicit: boolean };
// Three evidence tiers for one field, strongest first. A later assignment of
// the SAME tier still wins (last write in `__init__` is the live one), but a
// weaker one never displaces a stronger: `self.x: Outer = make()` keeps its
// annotation even if a later branch does `self.x = Other()`.
const TIER_EXPLICIT = 2;
const TIER_PARAMETER = 1;
const TIER_CONSTRUCTOR = 0;
type Candidate = { readonly match: CaptureMatch; readonly tier: number };
const candidates = new Map<string, Candidate>();
const stack: SyntaxNode[] = [body];
@ -178,13 +208,30 @@ export function synthesizeConstructorFieldTypeBindings(fnNode: SyntaxNode): Capt
const explicitType = node.childForFieldName('type');
const parameterType =
right?.type === 'identifier' ? parameterTypes.get(right.text) : undefined;
const typeName = explicitType?.text ?? parameterType;
// `self.x = Outer()` — the type comes from the CONSTRUCTOR being
// called (#2807). This is not the "arbitrary unannotated RHS" the
// header warns against: a call to a name that resolves to a class is
// the same positive evidence every other language reads from
// `= new X()`, and without it an unannotated instance field could
// never act as a call receiver at all. Weakest of the three tiers, so
// an explicit annotation or a parameter annotation still wins.
const constructedType =
explicitType === null && parameterType === undefined
? constructorCallTypeName(right)
: undefined;
const typeName = explicitType?.text ?? parameterType ?? constructedType;
if (typeName !== undefined) {
const explicit = explicitType !== null;
const inferredFromConstructor = constructedType !== undefined;
const tier = explicit
? TIER_EXPLICIT
: inferredFromConstructor
? TIER_CONSTRUCTOR
: TIER_PARAMETER;
const existing = candidates.get(field.text);
if (existing === undefined || explicit || !existing.explicit) {
if (existing === undefined || tier >= existing.tier) {
candidates.set(field.text, {
explicit,
tier,
match: {
'@type-binding.name': syntheticCapture('@type-binding.name', field, field.text),
'@type-binding.type': syntheticCapture(
@ -192,15 +239,26 @@ export function synthesizeConstructorFieldTypeBindings(fnNode: SyntaxNode): Capt
explicitType ?? right ?? field,
typeName,
),
// The marker that tells `interpretPythonTypeBinding` which
// tier this is; an explicit annotation carries neither and
// reads as `annotation`.
...(explicit
? {}
: {
'@type-binding.parameter': syntheticCapture(
'@type-binding.parameter',
right ?? field,
'1',
),
}),
: inferredFromConstructor
? {
'@type-binding.constructor': syntheticCapture(
'@type-binding.constructor',
right ?? field,
'1',
),
}
: {
'@type-binding.parameter': syntheticCapture(
'@type-binding.parameter',
right ?? field,
'1',
),
}),
'@type-binding.instance-field': syntheticCapture(
'@type-binding.instance-field',
node,