Last active
December 26, 2025 12:28
-
-
Save code-yeongyu/17a2ea81b5960c99452c5950a0aa164a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env -S uv run --script | |
| # /// script | |
| # requires-python = ">=3.11" | |
| # dependencies = [] | |
| # /// | |
| """ | |
| React Native anti-pattern detector hook. | |
| Env vars: | |
| - RN_HOOK_SHOW_INFO=1: Show info-level warnings | |
| - RN_HOOK_DISABLED=1: Disable hook | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import re | |
| import sys | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Any | |
| @dataclass | |
| class PatternMatch: | |
| key: str | |
| description: str | |
| suggestion: str | |
| severity: str | |
| @dataclass | |
| class CompiledPattern: | |
| key: str | |
| regex: re.Pattern[str] | |
| description: str | |
| suggestion: str | |
| severity: str | |
| KEYBOARD_AVOIDING_VIEW_WRONG_IMPORT = re.compile( | |
| r"import\s+\{[^}]*KeyboardAvoidingView[^}]*\}\s+from\s+['\"]react-native['\"]", | |
| re.MULTILINE | re.IGNORECASE, | |
| ) | |
| KEYBOARD_OFFSET_ZERO = re.compile( | |
| r"keyboardVerticalOffset\s*=\s*\{?\s*0\s*\}?", | |
| re.MULTILINE | re.IGNORECASE, | |
| ) | |
| PLATFORM_CONDITIONAL_KEYBOARD = re.compile( | |
| r"Platform\.OS\s*===?\s*['\"]ios['\"]\s*\?\s*['\"]padding['\"]", | |
| re.MULTILINE | re.IGNORECASE, | |
| ) | |
| FLATLIST_MISSING_KEYEXTRACTOR = re.compile( | |
| r"<FlatList(?![^>]*keyExtractor)[^>]*>", | |
| re.MULTILINE | re.IGNORECASE, | |
| ) | |
| FLATLIST_MISSING_GETITEMLAYOUT = re.compile( | |
| r"<FlatList(?![^>]*getItemLayout)[^>]*>", | |
| re.MULTILINE | re.IGNORECASE, | |
| ) | |
| INLINE_STYLE_OBJECT = re.compile( | |
| r"style=\{\{[^}]+\}\}", | |
| re.MULTILINE | re.IGNORECASE, | |
| ) | |
| ANONYMOUS_FUNCTION_PROP = re.compile( | |
| r"on\w+=\{\s*\([^)]*\)\s*=>\s*", | |
| re.MULTILINE | re.IGNORECASE, | |
| ) | |
| IMAGE_WITHOUT_DIMENSIONS = re.compile( | |
| r"<Image[^>]*source=\{[^}]+\}(?![^>]*(width|height|style=))[^>]*/>", | |
| re.MULTILINE | re.IGNORECASE, | |
| ) | |
| COMPILED_PATTERNS: list[CompiledPattern] = [ | |
| CompiledPattern( | |
| key="keyboard-avoiding-view-wrong-import", | |
| regex=KEYBOARD_AVOIDING_VIEW_WRONG_IMPORT, | |
| description="KeyboardAvoidingView imported from 'react-native'", | |
| suggestion="Use 'react-native-keyboard-controller' instead", | |
| severity="error", | |
| ), | |
| CompiledPattern( | |
| key="keyboard-offset-zero", | |
| regex=KEYBOARD_OFFSET_ZERO, | |
| description="keyboardVerticalOffset set to 0", | |
| suggestion="Should be header height + insets.top (e.g., 44 + insets.top)", | |
| severity="warning", | |
| ), | |
| CompiledPattern( | |
| key="platform-conditional-keyboard", | |
| regex=PLATFORM_CONDITIONAL_KEYBOARD, | |
| description="Platform-conditional keyboard behavior", | |
| suggestion="Unnecessary with react-native-keyboard-controller. Use behavior='padding' directly.", | |
| severity="warning", | |
| ), | |
| CompiledPattern( | |
| key="flatlist-missing-keyextractor", | |
| regex=FLATLIST_MISSING_KEYEXTRACTOR, | |
| description="FlatList without keyExtractor", | |
| suggestion="Always provide keyExtractor: keyExtractor={(item) => item.id}", | |
| severity="warning", | |
| ), | |
| CompiledPattern( | |
| key="flatlist-missing-getitemlayout", | |
| regex=FLATLIST_MISSING_GETITEMLAYOUT, | |
| description="FlatList without getItemLayout (performance)", | |
| suggestion="For fixed-height items, add getItemLayout for instant scroll-to-index", | |
| severity="info", | |
| ), | |
| CompiledPattern( | |
| key="inline-style-object", | |
| regex=INLINE_STYLE_OBJECT, | |
| description="Inline style object detected", | |
| suggestion="Move to StyleSheet.create() or useMemo() to prevent re-renders", | |
| severity="info", | |
| ), | |
| CompiledPattern( | |
| key="anonymous-function-prop", | |
| regex=ANONYMOUS_FUNCTION_PROP, | |
| description="Anonymous arrow function in prop", | |
| suggestion="Consider useCallback() to prevent child re-renders", | |
| severity="info", | |
| ), | |
| CompiledPattern( | |
| key="image-without-dimensions", | |
| regex=IMAGE_WITHOUT_DIMENSIONS, | |
| description="Image without explicit dimensions", | |
| suggestion="Always specify width/height for network images", | |
| severity="info", | |
| ), | |
| ] | |
| LAYOUT_CHECKS: dict[str, tuple[str, str, str]] = { | |
| "missing-safe-area-provider": ( | |
| "_layout.tsx: SafeAreaProvider 없음", | |
| "<SafeAreaProvider>로 앱 감싸기 (from react-native-safe-area-context)", | |
| "error", | |
| ), | |
| "missing-keyboard-provider": ( | |
| "_layout.tsx: KeyboardProvider 없음 (react-native-keyboard-controller)", | |
| "<KeyboardProvider statusBarTranslucent navigationBarTranslucent>로 앱 감싸기", | |
| "error", | |
| ), | |
| "keyboard-provider-incomplete": ( | |
| "_layout.tsx: KeyboardProvider에 props 누락", | |
| "<KeyboardProvider statusBarTranslucent navigationBarTranslucent>", | |
| "warning", | |
| ), | |
| } | |
| def check_file(file_path: str, content: str | None = None) -> list[PatternMatch]: | |
| path = Path(file_path) | |
| if path.suffix not in (".tsx", ".jsx", ".ts", ".js"): | |
| return [] | |
| if "node_modules" in str(path): | |
| return [] | |
| if content is None: | |
| try: | |
| content = path.read_text() | |
| except Exception: | |
| return [] | |
| matches: list[PatternMatch] = [] | |
| seen_keys: set[str] = set() | |
| filename = path.name | |
| for pattern in COMPILED_PATTERNS: | |
| if pattern.key in seen_keys: | |
| continue | |
| if pattern.regex.search(content): | |
| seen_keys.add(pattern.key) | |
| matches.append( | |
| PatternMatch( | |
| key=pattern.key, | |
| description=pattern.description, | |
| suggestion=pattern.suggestion, | |
| severity=pattern.severity, | |
| ) | |
| ) | |
| if "_layout" in filename: | |
| if "SafeAreaProvider" not in content: | |
| key = "missing-safe-area-provider" | |
| if key not in seen_keys: | |
| seen_keys.add(key) | |
| desc, sugg, sev = LAYOUT_CHECKS[key] | |
| matches.append(PatternMatch(key=key, description=desc, suggestion=sugg, severity=sev)) | |
| has_keyboard_provider = "KeyboardProvider" in content | |
| has_keyboard_controller_import = "react-native-keyboard-controller" in content | |
| if not has_keyboard_provider or not has_keyboard_controller_import: | |
| key = "missing-keyboard-provider" | |
| if key not in seen_keys: | |
| seen_keys.add(key) | |
| desc, sugg, sev = LAYOUT_CHECKS[key] | |
| matches.append(PatternMatch(key=key, description=desc, suggestion=sugg, severity=sev)) | |
| elif has_keyboard_provider and "statusBarTranslucent" not in content: | |
| key = "keyboard-provider-incomplete" | |
| if key not in seen_keys: | |
| seen_keys.add(key) | |
| desc, sugg, sev = LAYOUT_CHECKS[key] | |
| matches.append(PatternMatch(key=key, description=desc, suggestion=sugg, severity=sev)) | |
| return matches | |
| def format_matches(matches: list[PatternMatch], file_path: str, show_info: bool = False) -> str: | |
| if not matches: | |
| return "" | |
| if show_info: | |
| filtered = matches | |
| else: | |
| filtered = [m for m in matches if m.severity in ("error", "warning")] | |
| if not filtered: | |
| return "" | |
| severity_order = {"error": 0, "warning": 1, "info": 2} | |
| filtered.sort(key=lambda m: severity_order.get(m.severity, 3)) | |
| lines = [ | |
| f"\n🔍 React Native Pattern Check: {Path(file_path).name}", | |
| "=" * 50, | |
| ] | |
| severity_emoji = {"error": "🚨", "warning": "⚠️", "info": "ℹ️"} | |
| for match in filtered: | |
| emoji = severity_emoji.get(match.severity, "•") | |
| lines.append(f"\n{emoji} [{match.severity.upper()}] {match.description}") | |
| if match.suggestion: | |
| lines.append(f" → {match.suggestion}") | |
| lines.append("\n" + "=" * 50) | |
| return "\n".join(lines) | |
| def is_rn_project(file_path: Path) -> bool: | |
| check_dir = file_path.parent | |
| for _ in range(10): | |
| for marker in ["metro.config.js", "app.json", "expo-env.d.ts"]: | |
| if (check_dir / marker).exists(): | |
| return True | |
| pkg_json = check_dir / "package.json" | |
| if pkg_json.exists(): | |
| try: | |
| if "react-native" in pkg_json.read_text(): | |
| return True | |
| except Exception: | |
| pass | |
| if check_dir.parent == check_dir: | |
| break | |
| check_dir = check_dir.parent | |
| return False | |
| def main() -> None: | |
| if os.environ.get("RN_HOOK_DISABLED", "").strip() == "1": | |
| sys.exit(0) | |
| show_info = os.environ.get("RN_HOOK_SHOW_INFO", "").strip() == "1" | |
| try: | |
| input_raw = sys.stdin.read() | |
| if not input_raw.strip(): | |
| sys.exit(0) | |
| data: dict[str, Any] = json.loads(input_raw) | |
| except (json.JSONDecodeError, KeyError, TypeError): | |
| sys.exit(0) | |
| tool_name = data.get("tool_name", "") | |
| if tool_name not in ("Write", "Edit", "MultiEdit", "Read"): | |
| sys.exit(0) | |
| tool_input = data.get("tool_input", {}) | |
| if not isinstance(tool_input, dict): | |
| sys.exit(0) | |
| file_path = tool_input.get("file_path") or tool_input.get("filePath", "") | |
| if not file_path: | |
| sys.exit(0) | |
| path = Path(file_path) | |
| if path.suffix not in (".tsx", ".jsx", ".ts", ".js"): | |
| sys.exit(0) | |
| if not is_rn_project(path): | |
| sys.exit(0) | |
| content = None | |
| tool_response = data.get("tool_response", {}) | |
| if isinstance(tool_response, dict): | |
| content = tool_response.get("content") | |
| matches = check_file(file_path, content) | |
| if matches: | |
| has_significant = any(m.severity in ("error", "warning") for m in matches) | |
| if has_significant or (show_info and matches): | |
| output = format_matches(matches, file_path, show_info) | |
| if output: | |
| print(output, file=sys.stderr) | |
| sys.exit(2) | |
| sys.exit(0) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment