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