From 90c72e49f8e4e75154ee2ef073e01b91beb4f562 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Mon, 3 Aug 2026 16:01:08 +0000 Subject: [PATCH] fix(dart): type a field from a constructor assigned to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- .../core/ingestion/languages/dart/captures.ts | 99 +++++++++++++++++++ .../ingestion/languages/dart/simple-hooks.ts | 15 +++ 2 files changed, 114 insertions(+) diff --git a/gitnexus/src/core/ingestion/languages/dart/captures.ts b/gitnexus/src/core/ingestion/languages/dart/captures.ts index 371373160..9c87f1b9a 100644 --- a/gitnexus/src/core/ingestion/languages/dart/captures.ts +++ b/gitnexus/src/core/ingestion/languages/dart/captures.ts @@ -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(); + 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(); + 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; diff --git a/gitnexus/src/core/ingestion/languages/dart/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/dart/simple-hooks.ts index bb7d838c2..3b8e484ce 100644 --- a/gitnexus/src/core/ingestion/languages/dart/simple-hooks.ts +++ b/gitnexus/src/core/ingestion/languages/dart/simple-hooks.ts @@ -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 ||