fix(dart): type a field from a constructor assigned to it

`var r; C() { r = Outer(); }` bound nothing, so `r.inner()` had no receiver
type — the last inference-typed field shape still failing after the initializer
form was fixed (#2807).

Dart is the one language here that writes a field with NO receiver prefix, so
`r = Outer()` inside a constructor is syntactically identical to assigning a
constructor-local. That ambiguity is why this was initially left pinned — but
the field set IS knowable: the class body declares `var r`, which the
initializer fix already turned into a property declaration. So a bare name binds
exactly when Dart itself resolves it to the field: the enclosing class declares
it AND the enclosing body declares no local of that name. A `this.`-prefixed
write is unambiguous and needs neither test.

The shadowing case is asserted, not assumed: with a body-local `var s = Outer()`
in scope, the field stays unbound while the local still resolves on its own.

Binds `constructor-inferred` (weakest source, so an annotation still wins), and
only for a direct construction — an identifier followed by a `selector` carrying
an `argument_part`, the same shape accepted for locals. The narrow
`@type-binding.dart-field` marker drives the Class-scope hoist in
`dartBindingScopeFor`; gating on it rather than on `@type-binding.constructor`
at large is what keeps genuine locals in their own scope.

All three shapes now match their control: bare `r = Outer()`, `this.s = …`, and
a non-constructor `setUp()` assignment.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-08-03 16:01:08 +00:00
parent 2d34690857
commit 90c72e49f8
2 changed files with 114 additions and 0 deletions

View file

@ -247,6 +247,7 @@ export function emitDartScopeCaptures(
}
if (node.type === 'class_definition') {
emitHeritage(node, out);
emitDartFieldAssignmentBindings(node, out);
return;
}
if (node.type === 'extension_declaration') {
@ -603,6 +604,104 @@ function emitVarTypeBinding(initVarDef: SyntaxNode, out: CaptureMatch[]): void {
// ─── Heritage ───────────────────────────────────────────────────────────────
/**
* Type an inference-typed field from a constructor call ASSIGNED to it —
* `var r; C() { r = Outer(); }` and `this.r = Outer();` (#2807).
*
* Dart is the one language here that writes a field with NO receiver prefix, so
* `r = Outer()` is syntactically identical to assigning a constructor-local. The
* discriminator is the class's own declared field set: a bare name binds only
* when the enclosing class declares it AND the enclosing function body does not
* declare a local of the same name, which is exactly when Dart itself resolves
* `r` to the field. A `this.`-prefixed write is unambiguous and needs neither
* test.
*
* Emitted as `constructor-inferred`, the weakest source, so a field that also
* carries an annotation keeps it. The narrow `@type-binding.dart-field` marker
* rides the name node for `dartBindingScopeFor` to hoist on — the binding has to
* land on the Class scope, since the assignment sits inside a constructor's own
* Function scope where `typeOfMemberOnClass` never looks.
*/
function emitDartFieldAssignmentBindings(classNode: SyntaxNode, out: CaptureMatch[]): void {
const body = classNode.namedChildren.find((c) => c !== null && c.type === 'class_body');
if (body === undefined || body === null) return;
// Field names this class declares, from `declaration(... initialized_identifier)`.
const fields = new Set<string>();
for (const member of body.namedChildren) {
if (member === null || member.type !== 'declaration') continue;
const list = member.namedChildren.find((c) => c?.type === 'initialized_identifier_list');
if (list === undefined || list === null) continue;
for (const init of list.namedChildren) {
if (init === null || init.type !== 'initialized_identifier') continue;
const nameNode = init.namedChild(0);
if (nameNode !== null && nameNode.type === 'identifier') fields.add(nameNode.text);
}
}
if (fields.size === 0) return;
for (const member of body.namedChildren) {
if (member === null || member.type !== 'function_body') continue;
// Locals declared anywhere in this body shadow the field for the whole
// body — Dart hoists a local's name over the enclosing scope, so a
// conservative body-wide check is the right granularity here.
const locals = new Set<string>();
walkNamedTree(member, (n) => {
if (n.type !== 'initialized_variable_definition') return;
const nameNode = n.childForFieldName('name');
if (nameNode !== null) locals.add(nameNode.text);
});
walkNamedTree(member, (node) => {
if (node.type !== 'assignment_expression') return;
const target = node.namedChild(0);
if (target === null || target.type !== 'assignable_expression') return;
const first = target.namedChild(0);
if (first === null) return;
let fieldNameNode: SyntaxNode | null = null;
if (first.type === 'identifier' && target.namedChildCount === 1) {
// Bare `r = …`: a field only when declared here and not shadowed.
if (!fields.has(first.text) || locals.has(first.text)) return;
fieldNameNode = first;
} else if (first.type === 'this') {
const selector = target.namedChild(1);
if (selector === null || selector.type !== 'unconditional_assignable_selector') return;
const nameNode = selector.namedChild(0);
if (nameNode === null || nameNode.type !== 'identifier') return;
fieldNameNode = nameNode;
} else {
return;
}
if (fieldNameNode === null) return;
// RHS must be a direct construction: `Outer()` is an identifier followed
// by a `selector` carrying an `argument_part`. Anything else is left
// alone rather than guessed at.
const callee = node.namedChild(1);
if (callee === null || callee.type !== 'identifier') return;
const selector = node.namedChild(2);
if (selector === null || selector.type !== 'selector') return;
if (selector.namedChild(0)?.type !== 'argument_part') return;
out.push({
'@type-binding.constructor': nodeToCapture('@type-binding.constructor', node),
'@type-binding.dart-field': syntheticCapture(
'@type-binding.dart-field',
fieldNameNode,
'1',
),
'@type-binding.name': syntheticCapture(
'@type-binding.name',
fieldNameNode,
fieldNameNode.text,
),
'@type-binding.type': syntheticCapture('@type-binding.type', callee, callee.text),
});
});
}
}
function emitHeritage(classNode: SyntaxNode, out: CaptureMatch[]): void {
const nameNode = classNode.childForFieldName('name');
if (nameNode === null) return;

View file

@ -41,6 +41,21 @@ export function dartBindingScopeFor(
return null;
}
// (1b) A field typed from a constructor assigned to it (`r = Outer();` in a
// constructor body) must live on the CLASS scope — the assignment sits inside
// the constructor's own Function scope, where `typeOfMemberOnClass` never
// looks (#2807). Gated on the dedicated marker, never on
// `@type-binding.constructor` at large, which also fires for genuine locals.
if (decl['@type-binding.dart-field'] !== undefined) {
let cur: Scope | undefined = innermost;
while (cur !== undefined) {
if (cur.kind === 'Class') return cur.id;
if (cur.parent === null) break;
cur = tree.getScope(cur.parent);
}
return null;
}
// (2) Function/method/constructor names are visible in the enclosing scope.
if (
decl['@declaration.function'] !== undefined ||