diff --git a/scripts/parsers/tsx_parser.py b/scripts/parsers/tsx_parser.py old mode 100755 new mode 100644 index 6f21c35..0fd528d --- a/scripts/parsers/tsx_parser.py +++ b/scripts/parsers/tsx_parser.py @@ -66,6 +66,8 @@ def visit(node: Node, _, depth): self._process_jsx_attribute(node, source, file_path, classes, ids) elif node.type in ('jsx_opening_element', 'jsx_self_closing_element'): self._process_jsx_component(node, source, file_path, fn_declarations, edges) + elif node.type == 'jsx_expression': + self._process_jsx_expression(node, source, file_path, fn_declarations, edges) elif node.type == 'call_expression': call_info = self._parse_call(node, source, fn_declarations) if call_info: @@ -442,6 +444,88 @@ def _process_jsx_component(self, node: Node, source: bytes, "via_jsx": True }) + def _process_jsx_expression(self, node: Node, source: bytes, + file_path: str, fn_declarations: List[Dict], + edges: List): + """Emit usage edges for functions referenced inside a JSX expression + container (issue #294). + + In React, an event handler is passed by *reference* as a prop value — + ``onClick={handleClick}`` — not by call. The per-call passes only see + ``call_expression`` nodes, so a bare identifier reference produces zero + edges, leaving the handler with ``ref_count=0`` and false-flagged dead. + + This handler counts two reference shapes as usage, but ONLY when the + identifier resolves to a function declared in this file (guarded by + ``declared``) — arbitrary identifiers, DOM props, and non-function names + are never counted: + + 1. Attribute value / child reference: ``onClick={handleClick}`` — the + expression's direct child is the identifier. + 2. Callback argument: ``{items.map(renderItem)}`` — the identifier is + passed as an argument to a call. + + Double-counting is avoided by: + - skipping the ``function`` position of a ``call_expression`` (those + are already emitted by :meth:`_parse_call`), walking only its + ``arguments``; + - not descending into nested ``jsx_expression`` nodes (the outer + tree walk visits each one on its own); + - skipping ``member_expression`` (e.g. ``items.map`` / ``this.x``). + """ + declared = {d["node"]["fn"] for d in fn_declarations} + if not declared: + return + + # Resolve the enclosing function (innermost scope) as the edge source. + expr_line = self.get_line(node) + caller_id = None + best_scope_size = float('inf') + for decl in fn_declarations: + if decl["scope_start"] <= expr_line - 1 <= decl["scope_end"]: + scope_size = decl["scope_end"] - decl["scope_start"] + if scope_size < best_scope_size: + best_scope_size = scope_size + caller_id = decl["node"]["id"] + if not caller_id: + return + + seen = set() + + def emit(name: str): + if name in seen: + return + if name in self.SKIP_NAMES or name not in declared: + return + seen.add(name) + edges.append({ + "from": caller_id, + "to_fn": name, + "via_jsx_ref": True, + }) + + def walk(n: Node): + for child in n.children: + t = child.type + if t == 'jsx_expression': + # Handled by the outer tree walk — avoid re-processing. + continue + if t == 'identifier': + emit(self.get_text(child, source)) + elif t == 'call_expression': + # Function position is already counted by _parse_call; + # only inspect arguments for callback references. + args = child.child_by_field_name('arguments') + if args: + walk(args) + elif t == 'member_expression': + # e.g. items.map, this.handler — not a bare function ref. + continue + else: + walk(child) + + walk(node) + def _parse_call(self, node: Node, source: bytes, fn_declarations: List[Dict]) -> Optional[Dict]: """Parse a call expression and return an edge if it's within a known function. diff --git a/tests/test_graph_accuracy_golden.py b/tests/test_graph_accuracy_golden.py index 0284f9c..14f30d5 100644 --- a/tests/test_graph_accuracy_golden.py +++ b/tests/test_graph_accuracy_golden.py @@ -153,6 +153,29 @@ def _callers_of(backend: dict, fn: str) -> set: """ +_JSX_HANDLER_TSX = """\ +// Guards #294: a function passed as a JSX prop value (onClick={handleClick}) is +// a REFERENCE, not a call — before #294 it produced zero edges, so the handler +// got rc=0 / status=dead (false positive on the primary React use case). A +// function passed as a callback argument ({items.map(renderItem)}) must also +// count. Genuinely-unused handlers must STILL be dead (control). +function handleClick() { return 1; } +function handleSubmit() { return 2; } +function renderItem(x: number) { return x; } +function directlyCalled() { return 3; } +function unusedHandler() { return 4; } +export function App({ items }: { items: number[] }) { + directlyCalled(); + return ( +