fix(extract): add calls edges for JSX component usage

<Comp /> and <Comp>...</Comp> 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 <div> and
<motion.div> don't bind to a local div(). Namespaced tags and fragments
are ignored.

(cherry picked from commit 2828b55851)
This commit is contained in:
Sinan Gümüşkabak authored and safishamsi committed 2026-09-27 18:39:52 +01:00
1 parent cd7aa6cb78
commit 3a8db79305
3 files changed
+132 -2

No files matched your search

+3 -2
View File
@@ -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 (`<Comp />`) 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,
+20
View File
@@ -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:
# `<Comp />` / `<Comp>` renders Comp; the tag is the `name` field.
# Same rule as the JSX transform: a tag starting with a lowercase
# letter (`<div>`) 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 (`<motion.div>` is not a
# local `div`). Fragments and namespaced tags (`<svg:rect>`) 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)
+109
View File
@@ -0,0 +1,109 @@
"""JSX component usage (`<Comp />`, `<Comp>...</Comp>`) 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 (`<div>`) is an intrinsic
DOM element and must not bind to a same-named function; for a member tag the last
segment decides (`<motion.div>`).
"""
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 <span />; }\n"
"function Card(props: any) { return <div>{props.children}</div>; }\n"
"export function Screen() {\n"
" return (\n"
" <Card>\n"
" <Bubble />\n"
" </Card>\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 <span />; }\n",
"app/screen.tsx": (
'import { Bubble } from "../components/bubble";\n'
"export function Screen() {\n"
" return <Bubble />;\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 <h1 />; }\n"
"function App() { return <main><Header /></main>; }\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 <div />; }\n"
)
})
assert ("Page()", "div()") not in calls
def test_lowercase_member_tag_does_not_bind_to_same_named_function(tmp_path: Path):
# `<motion.div>` 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 <motion.div />; }\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