fix(dart): resolve calls through a closure-valued binding (#2693)

Dart needed more than the shared gate fix: neither of its closure-binding
forms could resolve, for two different reasons, and the plan's one-line
diagnosis turned out to be incomplete.

TOP-LEVEL `var f = (x) => x;`
  A graph Function node already existed (#2687), but no `@declaration.*`
  matched the binding, so scope resolution had no SymbolDefinition to attach
  a flow seed to. Adding the declaration exposed a second problem: Dart's
  `initialized_identifier` is FIELDLESS, so the shared field-based assignment
  fallback (`left`/`name`/`value`/…) decomposed nothing and the binding still
  emitted no flow captures at all. Kotlin's fieldless `assignment` node hit
  exactly this and took the same remedy — a provider `extractAssignment`.

FUNCTION-LOCAL `void m() { var f = (x) => x; }`
  Locals parse as `initialized_variable_definition`, which the top-level
  graph-node rules are deliberately anchored under (program) to avoid, so a
  local closure had no graph node at all — nothing for the widened
  `buildGraphTargetIndex` gate to admit.

Both new rules are restricted to a `function_expression` value. Declaring
every Dart variable would mint defs and nodes repo-wide for no resolution
benefit; ordinary locals stay unindexed exactly as before. The top-level
declaration reuses the (program) anchor the graph-node query already relies
on, so class-body fields — which share `initialized_identifier_list` and are
already `@declaration.property` — are never matched twice.

Also drops the now-false note in tree-sitter-queries.ts claiming `f()` does
not resolve for Dart. That node is now the evidence that makes it resolve.
This commit is contained in:
Gergo Magyar 2026-07-25 16:56:30 +00:00
parent cacc99bc8e
commit 18757c048d
4 changed files with 70 additions and 5 deletions

View file

@ -58,9 +58,25 @@ const DART_CALLABLE_CAPTURE_OPTIONS = {
callNodeTypes: new Set(['selector']),
parameterListNodeTypes: new Set(['formal_parameter_list', 'arguments']),
parameterNodeTypes: new Set(['formal_parameter']),
bindingNodeTypes: new Set(['initialized_variable_definition']),
// `initialized_identifier` covers TOP-LEVEL bindings, which Dart parses as a
// loose initialized_identifier_list under program rather than wrapping them
// in the local form. Without it a top-level `var f = (x) => x;` emitted no
// flow captures at all, so `f()` never resolved (#2693).
bindingNodeTypes: new Set(['initialized_variable_definition', 'initialized_identifier']),
assignmentNodeTypes: new Set(['assignment_expression']),
identifierNodeTypes: new Set(['identifier', 'type_identifier']),
// `initialized_identifier` is FIELDLESS, so the shared field-based fallback
// (`left`/`name`/`value`/…) decomposes nothing and a top-level binding
// produced no flow facts at all — the same shape as Kotlin's fieldless
// `assignment` node. Positional: first named child is the bound name, last is
// the initializer. `initialized_variable_definition` carries real `name:` /
// `value:` fields, so it is left to the shared path by returning undefined.
extractAssignment: (node: SyntaxNode) => {
if (node.type !== 'initialized_identifier') return undefined;
const named = node.namedChildren.filter((child): child is SyntaxNode => child !== null);
if (named.length < 2) return undefined;
return { destination: named[0]!, source: named[named.length - 1]! };
},
lexicalFunctionOwner: (node: SyntaxNode) => dartLexicalFunctionOwner(node),
isCallNode: (node: SyntaxNode) => node.namedChild(0)?.type === 'argument_part',
extractCallCallee: (node: SyntaxNode) => dartCallableCallee(node) ?? undefined,

View file

@ -126,6 +126,25 @@ const DART_SCOPE_QUERY = `
(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
; and \`f()\` stays unresolved even though the graph emits a Function node for it.
;
; Restricted to a function_expression value on purpose: declaring every Dart
; variable would mint defs repo-wide for no resolution benefit. The top-level
; rule is anchored under (program) — the same disambiguation the graph-node
; query uses — so class-body fields, which reuse initialized_identifier_list
; and are already @declaration.property, are never matched twice.
(program
(initialized_identifier_list
(initialized_identifier
(identifier) @declaration.name
(function_expression))) @declaration.variable)
(initialized_variable_definition
name: (identifier) @declaration.name
value: (function_expression)) @declaration.variable
; ── Imports / re-exports ─────────────────────────────────────────────────────
(import_or_export
(library_import

View file

@ -1719,15 +1719,25 @@ export const DART_QUERIES = `
(initialized_identifier
(identifier) @name)) @definition.variable)
; Closure bindings: \`var f = (x) => x;\` binds a CALLABLE, so it emits Function
; rather than Variable, matching TS/JS. This aligns the LABEL only — call
; resolution runs off the scope-resolution query, which still models the binding
; as a value, so \`f()\` does not resolve here yet. Overlap with the pattern
; above is collapsed by the parse-worker dedup (#2687).
; rather than Variable, matching TS/JS. Overlap with the pattern above is
; collapsed by the parse-worker dedup (#2687). Since #2693 this node is also
; what makes \`f()\` resolve: the scope-resolution query declares the binding as
; a value, and callable-value-flow admits it as a call target precisely because
; the node it resolves to is a Function.
(program
(initialized_identifier_list
(initialized_identifier
(identifier) @name
(function_expression))) @definition.function)
; ── Function-local closure bindings (#2693) ─────────────────────────────────
; \`void m() { var f = (x) => x; }\` — locals parse as initialized_variable_
; definition, which the top-level rules above never reach, so a local closure
; had no graph node at all and \`f()\` could not resolve. Restricted to a
; function_expression value: ordinary locals stay unindexed, as before.
(initialized_variable_definition
name: (identifier) @name
value: (function_expression)) @definition.function
(program
(static_final_declaration_list
(static_final_declaration

View file

@ -215,6 +215,26 @@ describeIfWorkerBuilt('calls to a closure binding resolve to its Function node',
expect(targets).toContain('Function:App.swift:handler');
});
it('Dart: a top-level closure binding resolves', async () => {
// The top-level form parses as `initialized_identifier`; the function-local
// form as `initialized_variable_definition`. Only the latter was in Dart's
// `bindingNodeTypes`, so the top-level binding emitted no flow captures.
const targets = await callTargetsFor(
'app.dart',
'var handler = (int x) => x;\n\nint caller() {\n return handler(1);\n}\n',
);
expect(targets).toContain('Function:app.dart:handler');
});
it('Dart: a function-local closure binding resolves', async () => {
const targets = await callTargetsFor(
'local.dart',
'int caller() {\n var handler = (int x) => x;\n return handler(1);\n}\n',
);
expect(targets).toContain('Function:local.dart:handler');
});
});
describeIfWorkerBuilt('a non-callable value binding stays edge-free', () => {