diff --git a/src/ast-analysis/rules/b2.ts b/src/ast-analysis/rules/b2.ts index d85445c9e..864ed2870 100644 --- a/src/ast-analysis/rules/b2.ts +++ b/src/ast-analysis/rules/b2.ts @@ -570,6 +570,69 @@ function extractDartParamName(node: TreeSitterNode): string[] | null { return null; } +/** Walk backward past any unnamed (anonymous token) siblings to the nearest named one. */ +function prevNamedSibling(node: TreeSitterNode): TreeSitterNode | null { + let p = node.previousSibling; + while (p && !p.isNamed) p = p.previousSibling; + return p; +} + +/** + * Resolve a Dart call's callee name and argument-list node (issue #2357). + * + * tree-sitter-dart has no `call_expression` node type at all — confirmed via + * node-types.json — AND, contrary to this fix's first attempt, the grammar's + * own `postfix_expression` node (which node-types.json lists as the + * conceptual wrapper) never actually appears in a parsed tree either: + * tree-sitter elides/inlines it, so a call is a FLAT SEQUENCE OF SIBLINGS + * under whatever encloses it (`return_statement`, `expression_statement`, + * `initialized_variable_definition`, etc.) — confirmed empirically by + * dumping real parse trees for `return helper(x);`, `helper(x);`, and + * `obj.method(x);`. `helper(x)` is `[identifier "helper", selector "(x)"]`; + * `obj.method(x)` is `[identifier "obj", selector ".method", selector "(x)"]` + * — TWO sibling selectors, not one node containing both. `selector` itself + * IS a real, distinctly-dispatchable node (unlike postfix_expression), so + * `node` here is the CALL selector (the one wrapping `argument_part`) — + * `callNode: 'selector'` dispatches this function once per selector the + * generic walk visits; a non-call selector (bare `.prop` access, `[index]`) + * returns null via the argument_part check below. The callee is resolved by + * walking BACKWARD to this selector's preceding sibling: another selector + * (a `.property` access — the method-call case) or a bare `identifier` (the + * base — the bare-call case). + * + * Only resolves the OUTERMOST call in a chain (`a.b().c()` resolves `.c()` + * only) — there is no separate node for each intermediate call in Dart's + * flat sibling chain, matching this issue's own scope (a single call, not + * chained calls). + */ +function resolveDartCallParts( + node: TreeSitterNode, +): { callee: string; argsNode: TreeSitterNode } | null { + const argumentPart = node.namedChildren.find((c) => c.type === 'argument_part'); + if (!argumentPart) return null; + const argsNode = argumentPart.namedChildren.find((c) => c.type === 'arguments'); + if (!argsNode) return null; + + const prev = prevNamedSibling(node); + if (!prev) return null; + + let calleeNode: TreeSitterNode | null = null; + if (prev.type === 'selector') { + // obj.method(x): the preceding selector wraps the property identifier. + const propWrapper = prev.namedChildren[0]; + calleeNode = + propWrapper?.type === 'unconditional_assignable_selector' + ? (propWrapper.namedChildren[0] ?? null) + : null; + } else if (prev.type === 'identifier') { + // helper(x): the preceding identifier IS the callee. + calleeNode = prev; + } + if (calleeNode?.type !== 'identifier') return null; + + return { callee: calleeNode.text, argsNode }; +} + export const dataflowDart: DataflowRulesConfig = makeDataflowRules({ functionNodes: new Set(['function_signature', 'method_signature']), // tree-sitter-dart puts a function's body in a SIBLING node, not a child @@ -595,6 +658,14 @@ export const dataflowDart: DataflowRulesConfig = makeDataflowRules({ getParamListNode: getDartParamListNode, extractParamName: extractDartParamName, + // local_variable_declaration's ONLY child is initialized_variable_definition, + // which has real `name`/`value` fields (confirmed via node-types.json) — + // this was left entirely unconfigured (issue #2357), so `var sum = ...;` + // never produced an assignment entry regardless of the sibling-body fix. + varDeclaratorNode: 'initialized_variable_definition', + varNameField: 'name', + varValueField: 'value', + returnNode: 'return_statement', // Arrow/`=>`-style implicit return (issue #2356): tree-sitter-dart's // function_body node's ONLY child is either a `block` (`{ ... }`) or one @@ -603,9 +674,27 @@ export const dataflowDart: DataflowRulesConfig = makeDataflowRules({ // already matches Dart's own block type name. implicitReturnBodyNode: 'function_body', - callNode: 'call_expression', - // tree-sitter-dart does not have standard named fields for calls; - // the extractor uses `selector` nodes instead. Leave defaults. + // tree-sitter-dart has no call_expression node type at all, and its + // conceptual postfix_expression wrapper never actually materializes in a + // parsed tree either — a call is a flat sequence of siblings (see + // resolveDartCallParts's doc comment, issue #2357). `selector` is the one + // node in that sequence that's reliably, distinctly dispatchable — it + // wraps `argument_part` exactly when this specific selector is a call. + callNode: 'selector', + resolveCallParts: resolveDartCallParts, + // A call-sourced var declarator's `value` field resolves to the CALLEE's + // own base expression (an `identifier`), not the call itself — the call's + // argument-list selector is a SIBLING, found by walking forward through + // this same chain of selectors (findCallSelector, dataflow-visitor.ts). + // Without this, `var x = helper(y);` never registers an assignment entry + // even though the standalone call `helper(y)` is correctly tracked above. + callChainSiblingType: 'selector', + // `arguments`'s own children wrap each argument in an `argument` node + // (confirmed via node-types.json — same wrapper name PHP's config already + // unwraps), not a bare expression directly — without this, unwrapArg never + // finds the identifier inside and argFlows stays empty regardless of the + // callNode/resolveCallParts fix above. + argumentWrapperType: 'argument', }); // ─── Groovy ─────────────────────────────────────────────────────────────────── diff --git a/src/ast-analysis/shared.ts b/src/ast-analysis/shared.ts index 4661e93ea..e59d8c53b 100644 --- a/src/ast-analysis/shared.ts +++ b/src/ast-analysis/shared.ts @@ -141,6 +141,10 @@ export const DATAFLOW_DEFAULTS: DataflowRulesConfig = { // Arrow/`=>`-style implicit-return body (issue #2356) implicitReturnBodyNode: null, blockBodyNode: 'block', + + // Fieldless call structure override (issue #2357) + resolveCallParts: null, + callChainSiblingType: null, }; export function makeDataflowRules(overrides: Partial): DataflowRulesConfig { diff --git a/src/ast-analysis/visitors/dataflow-visitor.ts b/src/ast-analysis/visitors/dataflow-visitor.ts index e6df47172..67b955282 100644 --- a/src/ast-analysis/visitors/dataflow-visitor.ts +++ b/src/ast-analysis/visitors/dataflow-visitor.ts @@ -207,6 +207,65 @@ function recordSimpleAssignment( scope.locals.set(varName, { type: 'call_return', callee }); } +/** + * Resolve a call node's callee name and argument-list node, using the + * language's `resolveCallParts` override when set (issue #2357 — grammars + * like tree-sitter-dart's `postfix_expression` have no field-based + * `callFunctionField`/`callArgsField` structure to read from a single + * self-contained node), falling back to the default field-based lookup + * (`resolveCalleeName` + `childForFieldName(callArgsField)`) otherwise. + */ +function resolveCallExprParts( + node: TreeSitterNode, + rules: AnyRules, +): { callee: string; argsNode: TreeSitterNode } | null { + if (rules.resolveCallParts) return rules.resolveCallParts(node); + const callee = resolveCalleeName(node, rules); + const argsNode = node.childForFieldName(rules.callArgsField); + if (!callee || !argsNode) return null; + return { callee, argsNode }; +} + +/** + * Find the node `resolveCallExprParts` should be given for a value expression + * that MIGHT be call-sourced. For most languages `node` (already + * field-resolved to e.g. a `call_expression`) already IS the call node — + * `isCallNode(node.type)` returns it unchanged, exactly reproducing every + * existing caller's prior `isCall(node, isCallNode) ? node : null` check + * with zero behavior change. + * + * For a grammar whose call-node dispatch target is a SIBLING of the value + * expression rather than the value expression itself (tree-sitter-dart's + * `selector`, resolved backward from the callee — see `resolveDartCallParts`, + * issue #2357), `node` is instead the callee's own base expression (e.g. the + * bare `identifier` a var's `value` field resolves to for `var x = + * helper(y);`). This walks FORWARD through `node`'s sibling chain — a call's + * argument-list selector is always last in tree-sitter-dart's flat sibling + * sequence — to find the trailing call-node sibling, the symmetric + * counterpart of `resolveDartCallParts`'s backward walk. Returns null when + * no call-node sibling follows (a non-call value expression). + */ +function findCallSelector( + node: TreeSitterNode, + rules: AnyRules, + isCallNode: (t: string) => boolean, +): TreeSitterNode | null { + if (isCallNode(node.type)) return node; + if (!rules.callChainSiblingType) return null; + let sib: TreeSitterNode | null = node.nextSibling; + let last: TreeSitterNode | null = null; + while (sib) { + if (!sib.isNamed) { + sib = sib.nextSibling; + continue; + } + if (sib.type !== rules.callChainSiblingType) break; + last = sib; + sib = sib.nextSibling; + } + return last && isCallNode(last.type) ? last : null; +} + function handleVarDeclarator( node: TreeSitterNode, rules: AnyRules, @@ -222,11 +281,12 @@ function handleVarDeclarator( if (!nameNode || !valueNode || !scope) return; const unwrapped = unwrapAwait(valueNode, rules); - const callExpr = isCall(unwrapped, isCallNode) ? unwrapped : null; + const callExpr = findCallSelector(unwrapped, rules, isCallNode); if (!callExpr) return; - const callee = resolveCalleeName(callExpr, rules); - if (!callee || !scope.funcName) return; + const parts = resolveCallExprParts(callExpr, rules); + if (!parts || !scope.funcName) return; + const { callee } = parts; const isDestructured = (rules.objectDestructType && nameNode.type === rules.objectDestructType) || @@ -315,10 +375,10 @@ function handleCallExpr( scopeStack: ScopeEntry[], argFlows: DataflowArgFlow[], ): void { - const callee = resolveCalleeName(node, rules); - const argsNode = node.childForFieldName(rules.callArgsField); + const parts = resolveCallExprParts(node, rules); const scope = currentScope(scopeStack); - if (!callee || !argsNode || !scope?.funcName) return; + if (!parts || !scope?.funcName) return; + const { callee, argsNode } = parts; let argIndex = 0; for (const arg of argsNode.namedChildren) { diff --git a/src/types.ts b/src/types.ts index 36787caf4..2459d66ce 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1396,6 +1396,36 @@ export interface DataflowRulesConfig { implicitReturnBodyNode: string | null; /** The "real" statement-block wrapper type `implicitReturnBodyNode` is compared against. */ blockBodyNode: string; + /** + * Override for grammars where a call has no field-based (`callFunctionField`/ + * `callArgsField`) structure to read from a single self-contained node — + * e.g. tree-sitter-dart, which has no `call_expression` node type, AND + * whose grammar-documented `postfix_expression` wrapper never actually + * appears in a parsed tree either: a call is a FLAT SEQUENCE OF SIBLINGS + * (a base expression followed by a chain of `selector` siblings, one of + * which wraps an `argument_part` when it's a call — issue #2357). When + * set, this replaces the default `resolveCalleeName` + + * `childForFieldName(callArgsField)` lookup everywhere a call's + * callee/argument-list is resolved. Returns `null` when `node` isn't + * actually a call (e.g. Dart's `selector` also represents non-call member + * access and `x++`/`x--`). + */ + resolveCallParts: + | ((node: TreeSitterNode) => { callee: string; argsNode: TreeSitterNode } | null) + | null; + /** + * The sibling node type `resolveCallParts`-style grammars chain calls + * through (Dart: `'selector'`). Lets `findCallSelector` (dataflow-visitor.ts) + * walk FORWARD from a call's base expression — resolved via the normal + * `varValueField`/`callFunctionField` lookup, landing on the base rather + * than the call itself in these grammars — to the trailing call-node + * sibling, so a call-sourced variable assignment (`var x = helper(y);`) + * is recognized the same way it already is for languages whose call node + * IS the value node directly (issue #2357). `null` for those languages: + * `findCallSelector` short-circuits via `isCallNode(node.type)` before + * ever consulting this field. + */ + callChainSiblingType: string | null; } /** Language rule module: exports from each language rule file. */ diff --git a/tests/parsers/dataflow-dart.test.ts b/tests/parsers/dataflow-dart.test.ts index d5f678101..b3f5941a8 100644 --- a/tests/parsers/dataflow-dart.test.ts +++ b/tests/parsers/dataflow-dart.test.ts @@ -144,4 +144,66 @@ describe('extractDataflow — Dart', () => { expect(multiplyReturns).toHaveLength(1); }); }); + + // Issue #2357: tree-sitter-dart has no call_expression node type at all, + // and its documented postfix_expression wrapper never actually appears in + // a parsed tree either — a call is a flat sequence of siblings (a base + // expression followed by a chain of `selector` siblings, one of which + // wraps an argument_part when it's a call). callNode/resolveCallParts and + // the argument-wrapper config were entirely unset, so argFlows (and any + // call-derived dataflow) was always empty regardless of the sibling-body + // and return fixes above. + describe('calls', () => { + it('tracks a bare call argument flow (helper(x) as a return expression)', () => { + const data = parseAndExtract('int square(int x) {\n return helper(x);\n}\n'); + expect(data!.argFlows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ callerFunc: 'square', calleeName: 'helper', argName: 'x' }), + ]), + ); + }); + + it('tracks a bare statement-level call argument flow', () => { + const data = parseAndExtract('int square(int x) {\n helper(x);\n return x;\n}\n'); + expect(data!.argFlows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ callerFunc: 'square', calleeName: 'helper', argName: 'x' }), + ]), + ); + }); + + it('tracks a method-call argument flow, resolving the callee via the preceding property selector', () => { + const data = parseAndExtract( + 'class Obj {\n int method(int x) => x;\n}\nint square(Obj obj, int x) {\n return obj.method(x);\n}\n', + ); + expect(data!.argFlows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ callerFunc: 'square', calleeName: 'method', argName: 'x' }), + ]), + ); + }); + + it('does not misidentify a non-call postfix expression (x++) as a call', () => { + const data = parseAndExtract('int inc(int x) {\n x++;\n return x;\n}\n'); + expect(data!.argFlows).toEqual([]); + }); + }); + + describe('assignments', () => { + it('tracks a call-sourced variable assignment (var x = helper(y))', () => { + const data = parseAndExtract('int square(int y) {\n var x = helper(y);\n return x;\n}\n'); + expect(data!.assignments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ varName: 'x', callerFunc: 'square', sourceCallName: 'helper' }), + ]), + ); + }); + + it('does not record a plain (non-call-sourced) variable declaration as an assignment', () => { + const data = parseAndExtract( + 'int add(int a, int b) {\n var sum = a + b;\n return sum;\n}\n', + ); + expect(data!.assignments).toEqual([]); + }); + }); });