diff --git a/code_review_graph/parser.py b/code_review_graph/parser.py index f38b84c..a350457 100644 --- a/code_review_graph/parser.py +++ b/code_review_graph/parser.py @@ -295,6 +295,7 @@ class CodeParser: def __init__(self) -> None: self._parsers: dict[str, object] = {} self._module_file_cache: dict[str, Optional[str]] = {} + self._export_symbol_cache: dict[str, Optional[str]] = {} self._tsconfig_resolver = TsconfigResolver() def _get_parser(self, language: str): # type: ignore[arg-type] @@ -972,6 +973,17 @@ def _extract_from_tree( ): continue + # --- JSX component invocations --- + if ( + language in ("javascript", "typescript", "tsx") + and node_type in ("jsx_opening_element", "jsx_self_closing_element") + ): + self._extract_jsx_component_call( + child, language, file_path, edges, + enclosing_class, enclosing_func, + import_map, defined_names, + ) + # --- Solidity-specific constructs --- if language == "solidity" and self._extract_solidity_constructs( child, node_type, source, file_path, nodes, edges, @@ -1761,6 +1773,70 @@ def _extract_calls( return False + def _extract_jsx_component_call( + self, + child, + language: str, + file_path: str, + edges: list[EdgeInfo], + enclosing_class: Optional[str], + enclosing_func: Optional[str], + import_map: Optional[dict[str, str]], + defined_names: Optional[set[str]], + ) -> None: + """Emit a synthetic CALLS edge for JSX component usage. + + React-style component invocations use JSX rather than ``call_expression``. + Treat uppercase component tags such as ```` as call-like + edges so caller/impact queries can cross the JSX boundary. Intrinsic DOM + tags (``
``) are ignored. + """ + if not enclosing_func: + return + + target = self._resolve_jsx_component_target( + child, language, file_path, import_map or {}, defined_names or set(), + ) + if not target: + return + + caller = self._qualify(enclosing_func, file_path, enclosing_class) + edges.append(EdgeInfo( + kind="CALLS", + source=caller, + target=target, + file_path=file_path, + line=child.start_point[0] + 1, + )) + + def _resolve_jsx_component_target( + self, + node, + language: str, + file_path: str, + import_map: dict[str, str], + defined_names: set[str], + ) -> Optional[str]: + """Resolve a JSX component element to a call target.""" + component_ref = self._get_jsx_component_reference(node) + if component_ref is None: + return None + + base_name, component_name = component_ref + if base_name is None: + return self._resolve_call_target( + component_name, file_path, language, import_map, defined_names, + ) + + if base_name in import_map: + resolved = self._resolve_imported_symbol( + component_name, import_map[base_name], file_path, language, + ) + if resolved: + return resolved + + return component_name + def _extract_solidity_constructs( self, child, @@ -1954,6 +2030,14 @@ def _collect_file_scope( if inner.type in func_types or inner.type in class_types: target = inner break + elif ( + language in ("javascript", "typescript", "tsx") + and node_type == "export_statement" + ): + for inner in child.children: + if inner.type in func_types or inner.type in class_types: + target = inner + break target_type = target.type @@ -1975,6 +2059,13 @@ def _collect_file_scope( "class" if target_type in class_types else "function") if name: defined_names.add(name) + continue + + if ( + language in ("javascript", "typescript", "tsx") + and node_type == "export_statement" + ): + self._collect_js_exported_local_names(child, defined_names) # Collect import mappings: imported_name → module_path if node_type in import_types: @@ -1982,6 +2073,21 @@ def _collect_file_scope( return import_map, defined_names + def _collect_js_exported_local_names( + self, node, defined_names: set[str], + ) -> None: + """Collect locally exported JS/TS names from export statements.""" + for child in node.children: + if child.type in ("lexical_declaration", "variable_declaration"): + for sub in child.children: + if sub.type == "variable_declarator": + for part in sub.children: + if part.type == "identifier": + defined_names.add( + part.text.decode("utf-8", errors="replace"), + ) + break + def _collect_import_names( self, node, language: str, source: bytes, import_map: dict[str, str], ) -> None: @@ -2030,6 +2136,11 @@ def _collect_js_import_names( if child.type == "identifier": # Default import import_map[child.text.decode("utf-8", errors="replace")] = module + elif child.type == "namespace_import": + for sub in child.children: + if sub.type == "identifier": + import_map[sub.text.decode("utf-8", errors="replace")] = module + break elif child.type == "named_imports": for spec in child.children: if spec.type == "import_specifier": @@ -2131,13 +2242,135 @@ def _resolve_call_target( if call_name in defined_names: return self._qualify(call_name, file_path, None) if call_name in import_map: - resolved = self._resolve_module_to_file( - import_map[call_name], file_path, language, + resolved = self._resolve_imported_symbol( + call_name, import_map[call_name], file_path, language, ) if resolved: - return self._qualify(call_name, resolved, None) + return resolved return call_name + def _resolve_imported_symbol( + self, + symbol_name: str, + module: str, + file_path: str, + language: str, + ) -> Optional[str]: + """Resolve an imported symbol to its defining qualified name when possible.""" + resolved = self._resolve_module_to_file(module, file_path, language) + if not resolved: + return None + + export_target = self._resolve_exported_symbol(resolved, symbol_name) + if export_target: + return export_target + return self._qualify(symbol_name, resolved, None) + + def _resolve_exported_symbol( + self, + module_file: str, + symbol_name: str, + seen: Optional[set[tuple[str, str]]] = None, + ) -> Optional[str]: + """Resolve a JS/TS symbol through common re-export/barrel patterns.""" + cache_key = f"{module_file}::{symbol_name}" + if cache_key in self._export_symbol_cache: + return self._export_symbol_cache[cache_key] + + key = (module_file, symbol_name) + if seen is None: + seen = set() + if key in seen: + return None + seen.add(key) + + path = Path(module_file) + language = self.detect_language(path) + if language not in ("javascript", "typescript", "tsx", "vue"): + return None + + try: + source = path.read_bytes() + except (OSError, PermissionError): + return None + + parser = self._get_parser(language) + if not parser: + return None + + tree = parser.parse(source) + + # Direct local definition/export in the module file. + import_map, defined_names = self._collect_file_scope( + tree.root_node, language, source, + ) + if symbol_name in defined_names: + result = self._qualify(symbol_name, module_file, None) + self._export_symbol_cache[cache_key] = result + return result + + for child in tree.root_node.children: + if child.type != "export_statement": + continue + + export_clause = None + target_module = None + has_star_export = False + + for sub in child.children: + if sub.type == "export_clause": + export_clause = sub + elif sub.type == "string": + target_module = sub.text.decode("utf-8", errors="replace").strip("'\"") + elif sub.type == "*": + has_star_export = True + + # Re-exported names: export { Foo as Bar } from './x' + if export_clause is not None: + for spec in export_clause.children: + if spec.type != "export_specifier": + continue + names = [ + part.text.decode("utf-8", errors="replace") + for part in spec.children + if part.type in ("identifier", "property_identifier") + ] + if not names: + continue + exported_name = names[-1] + original_name = names[0] + if exported_name != symbol_name: + continue + if target_module: + resolved_module = self._resolve_module_to_file( + target_module, module_file, language, + ) + if resolved_module: + result = self._resolve_exported_symbol( + resolved_module, original_name, seen, + ) or self._qualify(original_name, resolved_module, None) + self._export_symbol_cache[cache_key] = result + return result + result = self._qualify(original_name, module_file, None) + self._export_symbol_cache[cache_key] = result + return result + + # Star re-export: export * from './x' + if has_star_export and target_module: + resolved_module = self._resolve_module_to_file( + target_module, module_file, language, + ) + if resolved_module: + result = self._resolve_exported_symbol( + resolved_module, symbol_name, seen, + ) + if result: + self._export_symbol_cache[cache_key] = result + return result + + self._export_symbol_cache[cache_key] = None + return None + def _qualify(self, name: str, file_path: str, enclosing_class: Optional[str]) -> str: """Create a qualified name: file_path::ClassName.name or file_path::name.""" if enclosing_class: @@ -2512,6 +2745,55 @@ def _get_call_name(self, node, language: str, source: bytes) -> Optional[str]: return None + def _get_jsx_component_reference(self, node) -> Optional[tuple[Optional[str], str]]: + """Extract ``(base_name, component_name)`` for a JSX element. + + ``base_name`` is set for member-style elements such as + ```` and ``None`` for plain component tags such as + ````. + """ + for child in node.children: + if child.type == "identifier": + name = child.text.decode("utf-8", errors="replace") + if self._looks_like_component_name(name): + return (None, name) + return None + if child.type == "member_expression": + base_name = self._get_member_expression_root_name(child) + component_name = None + for sub in reversed(child.children): + if sub.type in ("identifier", "property_identifier"): + component_name = sub.text.decode("utf-8", errors="replace") + break + if component_name and self._looks_like_component_name(component_name): + return (base_name, component_name) + for sub in reversed(child.children): + if sub.type in ("identifier", "property_identifier"): + name = sub.text.decode("utf-8", errors="replace") + if self._looks_like_component_name(name): + return (None, name) + return None + text = child.text.decode("utf-8", errors="replace") + tail = text.split(".")[-1] + if self._looks_like_component_name(tail): + return (None, tail) + return None + return None + + def _get_member_expression_root_name(self, node) -> Optional[str]: + """Return the leftmost identifier for a nested member expression.""" + for child in node.children: + if child.type == "identifier": + return child.text.decode("utf-8", errors="replace") + if child.type == "member_expression": + return self._get_member_expression_root_name(child) + return None + + @staticmethod + def _looks_like_component_name(name: str) -> bool: + """Return True for JSX names that look like user components.""" + return bool(name) and name[0].isupper() + # Modifier suffixes used in JS/TS test runners _TEST_MODIFIER_SUFFIXES = frozenset({ "only", "skip", "each", "todo", "concurrent", "failing", diff --git a/tests/fixtures/MarkdownMsg.tsx b/tests/fixtures/MarkdownMsg.tsx new file mode 100644 index 0000000..af8dc02 --- /dev/null +++ b/tests/fixtures/MarkdownMsg.tsx @@ -0,0 +1,3 @@ +export function MarkdownMsg() { + return
; +} diff --git a/tests/test_parser.py b/tests/test_parser.py index 955e0a1..c4cd78f 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,5 +1,6 @@ """Tests for the Tree-sitter parser module.""" +import tempfile from pathlib import Path from code_review_graph.parser import CodeParser @@ -441,6 +442,286 @@ def test_non_test_file_describe_not_special(self): finally: tmp_path.unlink(missing_ok=True) + # --- JSX component CALLS tests --- + + def test_tsx_jsx_component_invocation_creates_call_edge(self): + source = ( + b"import MarkdownMsg from './MarkdownMsg';\n\n" + b"export function BookWorkspace() {\n" + b" return
;\n" + b"}\n" + ) + path = FIXTURES / "BookWorkspace.tsx" + + _, edges = self.parser.parse_bytes(path, source) + + calls = [e for e in edges if e.kind == "CALLS"] + expected_target = f"{str((FIXTURES / 'MarkdownMsg.tsx').resolve())}::MarkdownMsg" + jsx_calls = [ + e for e in calls + if e.source == f"{path}::BookWorkspace" and e.target == expected_target + ] + assert len(jsx_calls) == 1 + + def test_tsx_intrinsic_dom_elements_do_not_create_call_edges(self): + source = ( + b"export function BookWorkspace() {\n" + b" return
;\n" + b"}\n" + ) + path = FIXTURES / "BookWorkspace.tsx" + + _, edges = self.parser.parse_bytes(path, source) + + calls = [e for e in edges if e.kind == "CALLS"] + assert calls == [] + + def test_tsx_member_component_invocation_creates_unqualified_call_edge(self): + source = ( + b"export function BookWorkspace() {\n" + b" return ;\n" + b"}\n" + ) + path = FIXTURES / "BookWorkspace.tsx" + + _, edges = self.parser.parse_bytes(path, source) + + calls = [e for e in edges if e.kind == "CALLS"] + jsx_calls = [ + e for e in calls + if e.source == f"{path}::BookWorkspace" and e.target == "MarkdownMsg" + ] + assert len(jsx_calls) == 1 + + def test_tsx_namespace_import_component_invocation_resolves_to_module_file(self): + source = ( + b"import * as UI from './MarkdownMsg';\n\n" + b"export function BookWorkspace() {\n" + b" return ;\n" + b"}\n" + ) + path = FIXTURES / "BookWorkspace.tsx" + + _, edges = self.parser.parse_bytes(path, source) + + calls = [e for e in edges if e.kind == "CALLS"] + expected_target = f"{str((FIXTURES / 'MarkdownMsg.tsx').resolve())}::MarkdownMsg" + jsx_calls = [ + e for e in calls + if e.source == f"{path}::BookWorkspace" and e.target == expected_target + ] + assert len(jsx_calls) == 1 + + def test_tsx_nested_member_component_invocation_resolves_namespace_root(self): + source = ( + b"import * as UI from './MarkdownMsg';\n\n" + b"export function BookWorkspace() {\n" + b" return ;\n" + b"}\n" + ) + path = FIXTURES / "BookWorkspace.tsx" + + _, edges = self.parser.parse_bytes(path, source) + + calls = [e for e in edges if e.kind == "CALLS"] + expected_target = f"{str((FIXTURES / 'MarkdownMsg.tsx').resolve())}::MarkdownMsg" + jsx_calls = [ + e for e in calls + if e.source == f"{path}::BookWorkspace" and e.target == expected_target + ] + assert len(jsx_calls) == 1 + + def test_tsx_barrel_reexport_resolves_component_to_origin_file(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + (root / "components").mkdir() + (root / "components" / "MarkdownMsg.tsx").write_text( + "export function MarkdownMsg() { return
; }\n", + encoding="utf-8", + ) + (root / "components" / "index.ts").write_text( + "export { MarkdownMsg } from './MarkdownMsg';\n", + encoding="utf-8", + ) + consumer = root / "BookWorkspace.tsx" + source = ( + b"import { MarkdownMsg } from './components';\n\n" + b"export function BookWorkspace() {\n" + b" return ;\n" + b"}\n" + ) + + _, edges = self.parser.parse_bytes(consumer, source) + + calls = [e for e in edges if e.kind == "CALLS"] + expected_target = ( + f"{str((root / 'components' / 'MarkdownMsg.tsx').resolve())}" + "::MarkdownMsg" + ) + jsx_calls = [ + e for e in calls + if e.source == f"{consumer}::BookWorkspace" and e.target == expected_target + ] + assert len(jsx_calls) == 1 + + def test_tsx_barrel_aliased_reexport_resolves_component_to_origin_file(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + (root / "components").mkdir() + (root / "components" / "MarkdownMsg.tsx").write_text( + "export function MarkdownMsg() { return
; }\n", + encoding="utf-8", + ) + (root / "components" / "index.ts").write_text( + "export { MarkdownMsg as Msg } from './MarkdownMsg';\n", + encoding="utf-8", + ) + consumer = root / "BookWorkspace.tsx" + source = ( + b"import { Msg } from './components';\n\n" + b"export function BookWorkspace() {\n" + b" return ;\n" + b"}\n" + ) + + _, edges = self.parser.parse_bytes(consumer, source) + + calls = [e for e in edges if e.kind == "CALLS"] + expected_target = ( + f"{str((root / 'components' / 'MarkdownMsg.tsx').resolve())}" + "::MarkdownMsg" + ) + jsx_calls = [ + e for e in calls + if e.source == f"{consumer}::BookWorkspace" and e.target == expected_target + ] + assert len(jsx_calls) == 1 + + def test_tsx_barrel_star_reexport_resolves_component_to_origin_file(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + (root / "components").mkdir() + (root / "components" / "MarkdownMsg.tsx").write_text( + "export function MarkdownMsg() { return
; }\n", + encoding="utf-8", + ) + (root / "components" / "index.ts").write_text( + "export * from './MarkdownMsg';\n", + encoding="utf-8", + ) + consumer = root / "BookWorkspace.tsx" + source = ( + b"import { MarkdownMsg } from './components';\n\n" + b"export function BookWorkspace() {\n" + b" return ;\n" + b"}\n" + ) + + _, edges = self.parser.parse_bytes(consumer, source) + + calls = [e for e in edges if e.kind == "CALLS"] + expected_target = ( + f"{str((root / 'components' / 'MarkdownMsg.tsx').resolve())}" + "::MarkdownMsg" + ) + jsx_calls = [ + e for e in calls + if e.source == f"{consumer}::BookWorkspace" and e.target == expected_target + ] + assert len(jsx_calls) == 1 + + def test_grimoire_style_jsx_fixture_tracks_all_component_call_sites(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + components = root / "components" + components.mkdir() + (components / "MarkdownMsg.jsx").write_text( + "export function MarkdownMsg({ text }) { return
{text}
; }\n", + encoding="utf-8", + ) + (components / "index.js").write_text( + "export { MarkdownMsg } from './MarkdownMsg';\n", + encoding="utf-8", + ) + consumer = root / "BookWorkspace.jsx" + consumer.write_text( + "import { MarkdownMsg } from './components';\n\n" + "export function BookDashboard() {\n" + " return (\n" + " <>\n" + " \n" + " \n" + " \n" + " \n" + " );\n" + "}\n\n" + "export function AIPanel() {\n" + " return (\n" + " <>\n" + " \n" + " \n" + " \n" + " );\n" + "}\n", + encoding="utf-8", + ) + + _, edges = self.parser.parse_file(consumer) + + expected_target = ( + f"{str((components / 'MarkdownMsg.jsx').resolve())}::MarkdownMsg" + ) + jsx_calls = [ + e for e in edges + if e.kind == "CALLS" and e.target == expected_target + ] + by_source = {} + for edge in jsx_calls: + by_source[edge.source] = by_source.get(edge.source, 0) + 1 + assert by_source == { + f"{consumer}::BookDashboard": 3, + f"{consumer}::AIPanel": 2, + } + + def test_nested_barrel_chain_resolves_component_to_origin_file(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + messages = root / "components" / "messages" + messages.mkdir(parents=True) + (messages / "MarkdownMsg.jsx").write_text( + "export function MarkdownMsg({ text }) { return
{text}
; }\n", + encoding="utf-8", + ) + (messages / "index.js").write_text( + "export { MarkdownMsg } from './MarkdownMsg';\n", + encoding="utf-8", + ) + (root / "components" / "index.js").write_text( + "export { MarkdownMsg as Msg } from './messages';\n", + encoding="utf-8", + ) + consumer = root / "BookWorkspace.jsx" + consumer.write_text( + "import { Msg } from './components';\n\n" + "export function BookDashboard() {\n" + " return ;\n" + "}\n", + encoding="utf-8", + ) + + _, edges = self.parser.parse_file(consumer) + + expected_target = ( + f"{str((messages / 'MarkdownMsg.jsx').resolve())}::MarkdownMsg" + ) + jsx_calls = [ + e for e in edges + if e.kind == "CALLS" + and e.source == f"{consumer}::BookDashboard" + and e.target == expected_target + ] + assert len(jsx_calls) == 1 + def test_junit_annotation_marks_test(self): """Java @Test annotation should mark functions as tests.""" nodes, _ = self.parser.parse_bytes(