diff --git a/gitnexus/src/core/ingestion/languages/dart/captures.ts b/gitnexus/src/core/ingestion/languages/dart/captures.ts index 2a47dbf83..7700b2d89 100644 --- a/gitnexus/src/core/ingestion/languages/dart/captures.ts +++ b/gitnexus/src/core/ingestion/languages/dart/captures.ts @@ -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, diff --git a/gitnexus/src/core/ingestion/languages/dart/query.ts b/gitnexus/src/core/ingestion/languages/dart/query.ts index 5314b0c8b..49d88b90f 100644 --- a/gitnexus/src/core/ingestion/languages/dart/query.ts +++ b/gitnexus/src/core/ingestion/languages/dart/query.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 2d4334eef..4f864595a 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -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 diff --git a/gitnexus/test/integration/closure-binding-labels.test.ts b/gitnexus/test/integration/closure-binding-labels.test.ts index 148ce6b2e..527dbde8e 100644 --- a/gitnexus/test/integration/closure-binding-labels.test.ts +++ b/gitnexus/test/integration/closure-binding-labels.test.ts @@ -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', () => {