fix(dart): declare inference-typed class fields so they can be receivers

`var b = Outer();` produced no `@declaration.property` capture at all — no
Property node, and nothing for the capture layer to hang a type binding on — so
`b.inner()` could not type its receiver while the annotated twin
`Outer b = Outer();` resolved fine (#2807).

The gap was in the query, one layer below where the binding is emitted: both
class-field patterns require a leading `(type_identifier)` or `(nullable_type)`,
i.e. a WRITTEN type. Dart puts the keyword there instead for an inferred field,
and spells it two ways — `inferred_type` for `var`, `final_builtin` for `final`
and `late final`. Covering only `var` would have left the more idiomatic Dart
style broken, so both are matched.

With the field declared, the capture layer types it from the constructor its
initializer calls, as `constructor-inferred` — the weakest source, and the
annotated branch returns before it, so an annotated field is untouched. Only a
direct construction is accepted (a bare identifier followed by a `selector`
carrying an `argument_part`, the same shape `findDirectCallValue` accepts for
locals); a literal, member call or await is left alone rather than guessed at.

Note this is the LOCAL/field split that made the gap invisible: `emitVarTypeBinding`
already handled `initialized_variable_definition`, but a class field is
`declaration(<keyword>, initialized_identifier_list(initialized_identifier))`.

`InferredField.run` now emits `Outer.inner`, matching its control. Dart's
`var r; C() { r = Outer(); }` shape stays pinned as a known gap: Dart writes the
field with no receiver prefix, so binding it means treating assignment to a bare
identifier as a field write, indistinguishable from a constructor-local.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-08-03 15:11:35 +00:00
parent a8627541cf
commit b5f30383c5
2 changed files with 74 additions and 0 deletions

View file

@ -204,6 +204,19 @@ export function emitDartScopeCaptures(
'@type-binding.name': syntheticCapture('@type-binding.name', propNode, fieldName),
'@type-binding.type': syntheticCapture('@type-binding.type', propNode, fieldType),
});
} else {
// No written type, so the field's type comes from the constructor its
// initializer calls (#2807). `constructor-inferred` is the weakest
// source, and the annotated branch above already returned, so an
// annotated field is untouched either way.
const callee = dartFieldConstructorCallee(propNode);
if (callee !== null) {
out.push({
'@type-binding.constructor': nodeToCapture('@type-binding.constructor', propNode),
'@type-binding.name': syntheticCapture('@type-binding.name', propNode, fieldName),
'@type-binding.type': syntheticCapture('@type-binding.type', propNode, callee.text),
});
}
}
continue;
}
@ -535,6 +548,46 @@ function findDirectCallValue(initVarDef: SyntaxNode): SyntaxNode | null {
return null;
}
/**
* Callee identifier of a class field initialized by a direct constructor call —
* `var b = Outer();` / `final b = Outer();` — or `null` for anything else.
*
* Dart spells a class field as `declaration(<keyword>, initialized_identifier_list(
* initialized_identifier))`, NOT the `initialized_variable_definition` that
* `emitVarTypeBinding` handles — that is the LOCAL form. So an unannotated field
* had no type binding and could not act as a call receiver (#2807), even though
* its annotated twin resolved fine.
*
* Accepts the same construction shape `findDirectCallValue` accepts for locals:
* a bare identifier followed by a `selector` carrying an `argument_part`.
* Anything else — a literal, a member call, an await — is left alone rather than
* guessed at.
*/
function dartFieldConstructorCallee(propNode: SyntaxNode): SyntaxNode | null {
const initialized = firstDescendantOfType(propNode, 'initialized_identifier');
if (initialized === null) return null;
// namedChild(0) is the field NAME; the initializer starts after it.
const value = initialized.namedChild(1);
if (value === null || value.type !== 'identifier') return null;
const next = value.nextNamedSibling;
if (next === null || next.type !== 'selector') return null;
return next.namedChild(0)?.type === 'argument_part' ? value : null;
}
/** First strict descendant of `type`, breadth-first, or `null`. */
function firstDescendantOfType(root: SyntaxNode, type: string): SyntaxNode | null {
const queue: SyntaxNode[] = [root];
while (queue.length > 0) {
const node = queue.shift()!;
if (node !== root && node.type === type) return node;
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child !== null) queue.push(child);
}
}
return null;
}
function emitVarTypeBinding(initVarDef: SyntaxNode, out: CaptureMatch[]): void {
const nameNode = initVarDef.childForFieldName('name');
if (nameNode === null) return;

View file

@ -163,6 +163,27 @@ const DART_SCOPE_QUERY = `
(initialized_identifier
. (identifier) @declaration.name))) @declaration.property
; Inference-typed fields — \`var b = Outer();\`, \`final b = Outer();\`,
; \`late final b = Outer();\`, \`static var b = Outer();\` (#2807). The two
; patterns above require a written type, so a field whose type comes from its
; initializer produced NO property declaration at all — no Property node, and
; nothing for captures.ts to hang a type binding on, so \`b.inner()\` could not
; resolve its receiver while the annotated twin resolved fine.
;
; Dart spells the keyword as \`inferred_type\` for \`var\` and \`final_builtin\`
; for \`final\` / \`late final\`; both are class fields and both are idiomatic,
; so covering only one would leave the more common Dart style broken.
(declaration
(inferred_type)
(initialized_identifier_list
(initialized_identifier
. (identifier) @declaration.name))) @declaration.property
(declaration
(final_builtin)
(initialized_identifier_list
(initialized_identifier
. (identifier) @declaration.name))) @declaration.property
; ── Declarations — closure bindings (#2693) ──────────────────────────────────
; \`var f = (x) => x;\` binds a callable. Without a declaration the binding has
; no SymbolDefinition, so callable-value-flow has nothing to attach its seed to