From b5f30383c59ef691ec8df0d443c07932d68616f3 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Mon, 3 Aug 2026 15:11:35 +0000 Subject: [PATCH] fix(dart): declare inference-typed class fields so they can be receivers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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(, 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) --- .../core/ingestion/languages/dart/captures.ts | 53 +++++++++++++++++++ .../core/ingestion/languages/dart/query.ts | 21 ++++++++ 2 files changed, 74 insertions(+) diff --git a/gitnexus/src/core/ingestion/languages/dart/captures.ts b/gitnexus/src/core/ingestion/languages/dart/captures.ts index c3a09b91b..371373160 100644 --- a/gitnexus/src/core/ingestion/languages/dart/captures.ts +++ b/gitnexus/src/core/ingestion/languages/dart/captures.ts @@ -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(, 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; diff --git a/gitnexus/src/core/ingestion/languages/dart/query.ts b/gitnexus/src/core/ingestion/languages/dart/query.ts index d6646b92f..38496f2ec 100644 --- a/gitnexus/src/core/ingestion/languages/dart/query.ts +++ b/gitnexus/src/core/ingestion/languages/dart/query.ts @@ -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