mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-16 08:59:26 +00:00
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:
1 parent
cd7aa6cb78
commit
3a8db79305
3 files changed
+132
-2
No files matched your search
+3
-2
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user