From 3a8db7930500eed3b7f869dd59530cb0f69f426f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sinan=20G=C3=BCm=C3=BC=C5=9Fkabak?= <256841712+sinangumuskabak-sys@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:13:39 +0300 Subject: [PATCH] fix(extract): add calls edges for JSX component usage and ... are parsed as jsx_self_closing_element / jsx_opening_element with the tag in the `name` field, and neither type was in call_types, so components that are only rendered got no incoming edge. Add both types to the JS and TSX call_types and read the callee from `name`. Tags starting with a lowercase letter are intrinsic elements and are skipped; for member tags the last part decides, so
and don't bind to a local div(). Namespaced tags and fragments are ignored. (cherry picked from commit 2828b5585125a4dedaf3981fbdb5a497453745df) --- graphify/extract.py | 5 +- graphify/extractors/engine.py | 20 +++++ tests/test_jsx_component_usage_calls.py | 109 ++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 tests/test_jsx_component_usage_calls.py diff --git a/graphify/extract.py b/graphify/extract.py index 88f8edc68..e38eb3bf5 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -1239,7 +1239,7 @@ _JS_CONFIG = LanguageConfig( class_types=frozenset({"class_declaration"}), function_types=frozenset({"function_declaration", "generator_function_declaration", "method_definition"}), import_types=frozenset({"import_statement", "export_statement"}), - call_types=frozenset({"call_expression", "new_expression"}), + call_types=frozenset({"call_expression", "new_expression", "jsx_opening_element", "jsx_self_closing_element"}), call_function_field="function", call_accessor_node_types=frozenset({"member_expression"}), call_accessor_field="property", @@ -1287,7 +1287,8 @@ _TSX_CONFIG = LanguageConfig( class_types=_TS_CONFIG.class_types, function_types=_TS_CONFIG.function_types, import_types=_TS_CONFIG.import_types, - call_types=_TS_CONFIG.call_types, + # JSX component usage (``) renders Comp; extracted like a call. + call_types=_TS_CONFIG.call_types | {"jsx_opening_element", "jsx_self_closing_element"}, call_function_field=_TS_CONFIG.call_function_field, call_accessor_node_types=_TS_CONFIG.call_accessor_node_types, call_accessor_field=_TS_CONFIG.call_accessor_field, diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index c147c376f..7470bbb29 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -16,6 +16,9 @@ def _csharp_namespace_id(dotted_name: str) -> str: digest = hashlib.sha1(dotted_name.encode("utf-8")).hexdigest()[:16] return f"csharp_namespace:{digest}" +# JSX tags that render a component (the closing tag repeats the name; not counted). +_JSX_ELEMENT_TYPES = frozenset({"jsx_opening_element", "jsx_self_closing_element"}) + REFERENCE_CONTEXTS = frozenset({ "field", "parameter_type", "return_type", "generic_arg", "attribute", "value", "type", }) @@ -6452,6 +6455,23 @@ def _extract_generic( func_node = node.child_by_field_name(config.call_function_field) if config.call_function_field else None if func_node is None and node.type == "new_expression": func_node = node.child_by_field_name("constructor") + if node.type in _JSX_ELEMENT_TYPES: + # `` / `` renders Comp; the tag is the `name` field. + # Same rule as the JSX transform: a tag starting with a lowercase + # letter (`
`) is an intrinsic element, not a symbol in scope, + # so it must not bind by name to a same-named function. For a + # member tag the last segment decides (`` is not a + # local `div`). Fragments and namespaced tags (``) are + # skipped. + func_node = node.child_by_field_name("name") + tag = func_node + if tag is not None and tag.type == "member_expression": + tag = tag.child_by_field_name("property") + if (func_node is None + or func_node.type not in ("identifier", "member_expression") + or tag is None + or _read_text(tag, source)[:1].islower()): + func_node = None if func_node: if func_node.type == "identifier": callee_name = _read_text(func_node, source) diff --git a/tests/test_jsx_component_usage_calls.py b/tests/test_jsx_component_usage_calls.py new file mode 100644 index 000000000..52cc8dad4 --- /dev/null +++ b/tests/test_jsx_component_usage_calls.py @@ -0,0 +1,109 @@ +"""JSX component usage (``, `...`) emits `calls` edges. + +A JSX element renders its component, but tree-sitter models it as +`jsx_self_closing_element` / `jsx_opening_element` with the tag under the `name` +field, not as a `call_expression`. Those node types were never in `call_types`, +so a component that is only ever rendered (the normal case in React) had no +incoming edge and looked unused. A lowercase bare tag (`
`) is an intrinsic +DOM element and must not bind to a same-named function; for a member tag the last +segment decides (``). +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _calls(tmp_path: Path, files: dict[str, str]): + for name, body in files.items(): + p = tmp_path / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body, encoding="utf-8") + r = extract([tmp_path / n for n in files], + cache_root=tmp_path / "graphify-out", parallel=False) + lbl = {n["id"]: n["label"] for n in r["nodes"]} + calls = {(lbl.get(e["source"]), lbl.get(e["target"])) for e in r["edges"] + if e["relation"] == "calls"} + return calls, r + + +def test_tsx_self_closing_and_opening_elements_emit_calls_in_file(tmp_path: Path): + calls, _ = _calls(tmp_path, { + "screen.tsx": ( + "function Bubble() { return ; }\n" + "function Card(props: any) { return
{props.children}
; }\n" + "export function Screen() {\n" + " return (\n" + " \n" + " \n" + " \n" + " );\n" + "}\n" + ) + }) + assert ("Screen()", "Bubble()") in calls + assert ("Screen()", "Card()") in calls + + +def test_tsx_component_usage_resolves_cross_file(tmp_path: Path): + calls, r = _calls(tmp_path, { + "components/bubble.tsx": "export function Bubble() { return ; }\n", + "app/screen.tsx": ( + 'import { Bubble } from "../components/bubble";\n' + "export function Screen() {\n" + " return ;\n" + "}\n" + ), + }) + assert ("Screen()", "Bubble()") in calls + cross = [ + e for e in r["edges"] + if e["relation"] == "calls" + and "screen" in e["source"] + and "bubble" in e["target"].lower() + ] + assert len(cross) == 1 + + +def test_jsx_file_component_usage_emits_calls(tmp_path: Path): + calls, _ = _calls(tmp_path, { + "app.jsx": ( + "function Header() { return

; }\n" + "function App() { return
; }\n" + ) + }) + assert ("App()", "Header()") in calls + + +def test_intrinsic_lowercase_tag_does_not_bind_to_same_named_function(tmp_path: Path): + calls, _ = _calls(tmp_path, { + "page.tsx": ( + "function div() { return null; }\n" + "export function Page() { return
; }\n" + ) + }) + assert ("Page()", "div()") not in calls + + +def test_lowercase_member_tag_does_not_bind_to_same_named_function(tmp_path: Path): + # `` is framer-motion's element, not a local `div`. + calls, _ = _calls(tmp_path, { + "anim.tsx": ( + "import { motion } from 'framer-motion';\n" + "function div() { return null; }\n" + "export function Anim() { return ; }\n" + ) + }) + assert ("Anim()", "div()") not in calls + + +def test_underscore_component_is_not_treated_as_intrinsic(tmp_path: Path): + # Only a leading lowercase letter marks an intrinsic tag; `<_Row>` is a component. + calls, _ = _calls(tmp_path, { + "table.tsx": ( + "function _Row() { return null; }\n" + "export function Table() { return <_Row />; }\n" + ) + }) + assert ("Table()", "_Row()") in calls