Migrated from design-system-swarm with fresh git history.
Old project history preserved in /home/overbits/apps/design-system-swarm
Core components:
- MCP Server (Python FastAPI with mcp 1.23.1)
- Claude Plugin (agents, commands, skills, strategies, hooks, core)
- DSS Backend (dss-mvp1 - token translation, Figma sync)
- Admin UI (Node.js/React)
- Server (Node.js/Express)
- Storybook integration (dss-mvp1/.storybook)
Self-contained configuration:
- All paths relative or use DSS_BASE_PATH=/home/overbits/dss
- PYTHONPATH configured for dss-mvp1 and dss-claude-plugin
- .env file with all configuration
- Claude plugin uses ${CLAUDE_PLUGIN_ROOT} for portability
Migration completed: $(date)
🤖 Clean migration with full functionality preserved
442 lines
15 KiB
Python
442 lines
15 KiB
Python
"""
|
|
React Project Analyzer
|
|
|
|
Analyzes React codebases to extract component information,
|
|
detect patterns, and identify style usage.
|
|
"""
|
|
|
|
import re
|
|
from pathlib import Path
|
|
from typing import List, Dict, Any, Optional, Set, Tuple
|
|
from dataclasses import dataclass, field
|
|
|
|
from .base import (
|
|
ComponentInfo,
|
|
Location,
|
|
StylePattern,
|
|
StylingApproach,
|
|
)
|
|
|
|
|
|
# Patterns for React component detection
|
|
FUNCTIONAL_COMPONENT = re.compile(
|
|
r'(?:export\s+)?(?:const|let|var|function)\s+([A-Z][A-Za-z0-9]*)\s*(?::\s*(?:React\.)?FC)?'
|
|
r'\s*(?:=\s*(?:\([^)]*\)|[a-zA-Z_]\w*)\s*=>|\()',
|
|
re.MULTILINE
|
|
)
|
|
|
|
CLASS_COMPONENT = re.compile(
|
|
r'class\s+([A-Z][A-Za-z0-9]*)\s+extends\s+(?:React\.)?(?:Component|PureComponent)',
|
|
re.MULTILINE
|
|
)
|
|
|
|
FORWARD_REF = re.compile(
|
|
r'(?:export\s+)?(?:const|let)\s+([A-Z][A-Za-z0-9]*)\s*=\s*(?:React\.)?forwardRef',
|
|
re.MULTILINE
|
|
)
|
|
|
|
MEMO_COMPONENT = re.compile(
|
|
r'(?:export\s+)?(?:const|let)\s+([A-Z][A-Za-z0-9]*)\s*=\s*(?:React\.)?memo\(',
|
|
re.MULTILINE
|
|
)
|
|
|
|
# Import patterns
|
|
IMPORT_PATTERN = re.compile(
|
|
r'import\s+(?:\{[^}]+\}|\*\s+as\s+\w+|\w+)\s+from\s+["\']([^"\']+)["\']',
|
|
re.MULTILINE
|
|
)
|
|
|
|
STYLE_IMPORT = re.compile(
|
|
r'import\s+(?:(\w+)\s+from\s+)?["\']([^"\']+\.(?:css|scss|sass|less|styl))["\']',
|
|
re.MULTILINE
|
|
)
|
|
|
|
# Inline style patterns
|
|
INLINE_STYLE_OBJECT = re.compile(
|
|
r'style\s*=\s*\{\s*\{([^}]+)\}\s*\}',
|
|
re.MULTILINE | re.DOTALL
|
|
)
|
|
|
|
INLINE_STYLE_VAR = re.compile(
|
|
r'style\s*=\s*\{(\w+)\}',
|
|
re.MULTILINE
|
|
)
|
|
|
|
# Props extraction
|
|
PROPS_DESTRUCTURE = re.compile(
|
|
r'\(\s*\{\s*([^}]+)\s*\}\s*(?::\s*[^)]+)?\)',
|
|
re.MULTILINE
|
|
)
|
|
|
|
PROPS_INTERFACE = re.compile(
|
|
r'interface\s+\w*Props\s*\{([^}]+)\}',
|
|
re.MULTILINE | re.DOTALL
|
|
)
|
|
|
|
PROPS_TYPE = re.compile(
|
|
r'type\s+\w*Props\s*=\s*\{([^}]+)\}',
|
|
re.MULTILINE | re.DOTALL
|
|
)
|
|
|
|
|
|
class ReactAnalyzer:
|
|
"""
|
|
Analyzes React projects for component structure and style usage.
|
|
"""
|
|
|
|
def __init__(self, root_path: str):
|
|
self.root = Path(root_path).resolve()
|
|
|
|
async def analyze(
|
|
self,
|
|
component_files: Optional[List[Path]] = None
|
|
) -> List[ComponentInfo]:
|
|
"""
|
|
Analyze React components in the project.
|
|
|
|
Args:
|
|
component_files: Optional list of files to analyze.
|
|
If None, scans the project.
|
|
|
|
Returns:
|
|
List of ComponentInfo for each detected component.
|
|
"""
|
|
if component_files is None:
|
|
component_files = self._find_component_files()
|
|
|
|
components = []
|
|
|
|
for file_path in component_files:
|
|
try:
|
|
file_components = await self._analyze_file(file_path)
|
|
components.extend(file_components)
|
|
except Exception as e:
|
|
# Log error but continue
|
|
continue
|
|
|
|
return components
|
|
|
|
def _find_component_files(self) -> List[Path]:
|
|
"""Find all potential React component files."""
|
|
skip_dirs = {'node_modules', '.git', 'dist', 'build', '.next'}
|
|
component_files = []
|
|
|
|
for ext in ['*.jsx', '*.tsx']:
|
|
for path in self.root.rglob(ext):
|
|
if not any(skip in path.parts for skip in skip_dirs):
|
|
component_files.append(path)
|
|
|
|
# Also check .js/.ts files that look like components
|
|
for ext in ['*.js', '*.ts']:
|
|
for path in self.root.rglob(ext):
|
|
if any(skip in path.parts for skip in skip_dirs):
|
|
continue
|
|
# Skip config and utility files
|
|
if any(x in path.name.lower() for x in ['config', 'util', 'helper', 'hook', 'context']):
|
|
continue
|
|
# Check if PascalCase (likely component)
|
|
if path.stem[0].isupper():
|
|
component_files.append(path)
|
|
|
|
return component_files
|
|
|
|
async def _analyze_file(self, file_path: Path) -> List[ComponentInfo]:
|
|
"""Analyze a single file for React components."""
|
|
content = file_path.read_text(encoding='utf-8', errors='ignore')
|
|
components = []
|
|
|
|
# Find all components in the file
|
|
component_matches = []
|
|
|
|
# Functional components
|
|
for match in FUNCTIONAL_COMPONENT.finditer(content):
|
|
name = match.group(1)
|
|
if self._is_valid_component_name(name):
|
|
component_matches.append((name, 'functional', match.start()))
|
|
|
|
# Class components
|
|
for match in CLASS_COMPONENT.finditer(content):
|
|
name = match.group(1)
|
|
component_matches.append((name, 'class', match.start()))
|
|
|
|
# forwardRef components
|
|
for match in FORWARD_REF.finditer(content):
|
|
name = match.group(1)
|
|
component_matches.append((name, 'forwardRef', match.start()))
|
|
|
|
# memo components
|
|
for match in MEMO_COMPONENT.finditer(content):
|
|
name = match.group(1)
|
|
component_matches.append((name, 'memo', match.start()))
|
|
|
|
# Dedupe by name (keep first occurrence)
|
|
seen_names = set()
|
|
unique_matches = []
|
|
for name, comp_type, pos in component_matches:
|
|
if name not in seen_names:
|
|
seen_names.add(name)
|
|
unique_matches.append((name, comp_type, pos))
|
|
|
|
# Extract imports (shared across all components in file)
|
|
imports = self._extract_imports(content)
|
|
style_files = self._extract_style_imports(content)
|
|
inline_styles = self._find_inline_styles(content)
|
|
|
|
# Create ComponentInfo for each
|
|
for name, comp_type, pos in unique_matches:
|
|
# Extract props for this component
|
|
props = self._extract_props(content, name)
|
|
|
|
# Find child components used
|
|
children = self._find_child_components(content, seen_names)
|
|
|
|
# Check if component has styles
|
|
has_styles = bool(style_files) or bool(inline_styles)
|
|
|
|
components.append(ComponentInfo(
|
|
name=name,
|
|
path=str(file_path.relative_to(self.root)),
|
|
type=comp_type,
|
|
props=props,
|
|
has_styles=has_styles,
|
|
style_files=style_files,
|
|
inline_style_count=len(inline_styles),
|
|
imports=imports,
|
|
exports=self._find_exports(content, name),
|
|
children=children,
|
|
line_count=content.count('\n') + 1,
|
|
))
|
|
|
|
return components
|
|
|
|
def _is_valid_component_name(self, name: str) -> bool:
|
|
"""Check if a name is a valid React component name."""
|
|
# Must be PascalCase
|
|
if not name[0].isupper():
|
|
return False
|
|
|
|
# Filter out common non-component patterns
|
|
invalid_names = {
|
|
'React', 'Component', 'PureComponent', 'Fragment',
|
|
'Suspense', 'Provider', 'Consumer', 'Context',
|
|
'Error', 'ErrorBoundary', 'Wrapper', 'Container',
|
|
'Props', 'State', 'Type', 'Interface',
|
|
}
|
|
|
|
return name not in invalid_names
|
|
|
|
def _extract_imports(self, content: str) -> List[str]:
|
|
"""Extract import paths from file."""
|
|
imports = []
|
|
for match in IMPORT_PATTERN.finditer(content):
|
|
import_path = match.group(1)
|
|
# Skip node_modules style imports for brevity
|
|
if not import_path.startswith('.') and '/' not in import_path:
|
|
continue
|
|
imports.append(import_path)
|
|
return imports
|
|
|
|
def _extract_style_imports(self, content: str) -> List[str]:
|
|
"""Extract style file imports."""
|
|
style_files = []
|
|
for match in STYLE_IMPORT.finditer(content):
|
|
style_path = match.group(2)
|
|
style_files.append(style_path)
|
|
return style_files
|
|
|
|
def _find_inline_styles(self, content: str) -> List[Location]:
|
|
"""Find inline style usage locations."""
|
|
locations = []
|
|
|
|
# style={{ ... }}
|
|
for match in INLINE_STYLE_OBJECT.finditer(content):
|
|
line = content[:match.start()].count('\n') + 1
|
|
locations.append(Location(
|
|
file_path="", # Will be set by caller
|
|
line=line,
|
|
))
|
|
|
|
return locations
|
|
|
|
def _extract_props(self, content: str, component_name: str) -> List[str]:
|
|
"""Extract props for a component."""
|
|
props = set()
|
|
|
|
# Look for destructured props
|
|
for match in PROPS_DESTRUCTURE.finditer(content):
|
|
props_str = match.group(1)
|
|
# Extract prop names from destructuring
|
|
for prop in re.findall(r'(\w+)(?:\s*[=:])?', props_str):
|
|
if prop and not prop[0].isupper(): # Skip types
|
|
props.add(prop)
|
|
|
|
# Look for Props interface/type
|
|
for pattern in [PROPS_INTERFACE, PROPS_TYPE]:
|
|
for match in pattern.finditer(content):
|
|
props_str = match.group(1)
|
|
# Extract prop names
|
|
for line in props_str.split('\n'):
|
|
prop_match = re.match(r'\s*(\w+)\s*[?:]', line)
|
|
if prop_match:
|
|
props.add(prop_match.group(1))
|
|
|
|
return list(props)
|
|
|
|
def _find_child_components(
|
|
self,
|
|
content: str,
|
|
current_components: Set[str]
|
|
) -> List[str]:
|
|
"""Find child components used in JSX."""
|
|
children = set()
|
|
|
|
# Find JSX elements that look like components (PascalCase)
|
|
jsx_pattern = re.compile(r'<([A-Z][A-Za-z0-9]*)')
|
|
for match in jsx_pattern.finditer(content):
|
|
component_name = match.group(1)
|
|
# Skip current file's components and React built-ins
|
|
if component_name not in current_components:
|
|
if component_name not in {'Fragment', 'Suspense', 'Provider'}:
|
|
children.add(component_name)
|
|
|
|
return list(children)
|
|
|
|
def _find_exports(self, content: str, component_name: str) -> List[str]:
|
|
"""Find export type for component."""
|
|
exports = []
|
|
|
|
# Default export
|
|
if re.search(rf'export\s+default\s+{component_name}\b', content):
|
|
exports.append('default')
|
|
if re.search(rf'export\s+default\s+(?:function|const)\s+{component_name}\b', content):
|
|
exports.append('default')
|
|
|
|
# Named export
|
|
if re.search(rf'export\s+(?:const|function|class)\s+{component_name}\b', content):
|
|
exports.append('named')
|
|
if re.search(r'export\s*\{[^}]*\b' + re.escape(component_name) + r'\b[^}]*\}', content):
|
|
exports.append('named')
|
|
|
|
return exports
|
|
|
|
async def find_inline_styles(self, path: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
"""
|
|
Find all inline style usage in the project.
|
|
|
|
Returns list of inline style occurrences with:
|
|
- file path
|
|
- line number
|
|
- style content
|
|
- component name (if detectable)
|
|
"""
|
|
search_path = Path(path) if path else self.root
|
|
results = []
|
|
|
|
for ext in ['*.jsx', '*.tsx', '*.js', '*.ts']:
|
|
for file_path in search_path.rglob(ext):
|
|
if any(skip in file_path.parts for skip in
|
|
{'node_modules', '.git', 'dist', 'build'}):
|
|
continue
|
|
|
|
try:
|
|
content = file_path.read_text(encoding='utf-8', errors='ignore')
|
|
|
|
# Find style={{ ... }}
|
|
for match in INLINE_STYLE_OBJECT.finditer(content):
|
|
line = content[:match.start()].count('\n') + 1
|
|
style_content = match.group(1).strip()
|
|
|
|
results.append({
|
|
'file': str(file_path.relative_to(self.root)),
|
|
'line': line,
|
|
'content': style_content[:200],
|
|
'type': 'object',
|
|
})
|
|
|
|
# Find style={variable}
|
|
for match in INLINE_STYLE_VAR.finditer(content):
|
|
line = content[:match.start()].count('\n') + 1
|
|
var_name = match.group(1)
|
|
|
|
results.append({
|
|
'file': str(file_path.relative_to(self.root)),
|
|
'line': line,
|
|
'content': f'style={{{var_name}}}',
|
|
'type': 'variable',
|
|
'variable': var_name,
|
|
})
|
|
|
|
except Exception:
|
|
continue
|
|
|
|
return results
|
|
|
|
async def get_component_tree(self) -> Dict[str, List[str]]:
|
|
"""
|
|
Build component dependency tree.
|
|
|
|
Returns dict mapping component names to their child components.
|
|
"""
|
|
components = await self.analyze()
|
|
|
|
tree = {}
|
|
for comp in components:
|
|
tree[comp.name] = comp.children
|
|
|
|
return tree
|
|
|
|
async def find_style_patterns(self) -> Dict[str, List[Dict]]:
|
|
"""
|
|
Find different styling patterns used across the project.
|
|
|
|
Returns dict with pattern types and their occurrences.
|
|
"""
|
|
patterns = {
|
|
'inline_styles': [],
|
|
'css_modules': [],
|
|
'styled_components': [],
|
|
'emotion': [],
|
|
'tailwind': [],
|
|
'css_classes': [],
|
|
}
|
|
|
|
component_files = self._find_component_files()
|
|
|
|
for file_path in component_files:
|
|
try:
|
|
content = file_path.read_text(encoding='utf-8', errors='ignore')
|
|
rel_path = str(file_path.relative_to(self.root))
|
|
|
|
# CSS Modules
|
|
if re.search(r'import\s+\w+\s+from\s+["\'].*\.module\.', content):
|
|
patterns['css_modules'].append({'file': rel_path})
|
|
|
|
# styled-components
|
|
if re.search(r'styled\.|from\s+["\']styled-components', content):
|
|
patterns['styled_components'].append({'file': rel_path})
|
|
|
|
# Emotion
|
|
if re.search(r'@emotion|css`', content):
|
|
patterns['emotion'].append({'file': rel_path})
|
|
|
|
# Tailwind (className with utility classes)
|
|
if re.search(r'className\s*=\s*["\'][^"\']*(?:flex|grid|p-\d|m-\d|bg-)', content):
|
|
patterns['tailwind'].append({'file': rel_path})
|
|
|
|
# Regular CSS classes
|
|
if re.search(r'className\s*=\s*["\'][a-zA-Z]', content):
|
|
patterns['css_classes'].append({'file': rel_path})
|
|
|
|
# Inline styles
|
|
for match in INLINE_STYLE_OBJECT.finditer(content):
|
|
line = content[:match.start()].count('\n') + 1
|
|
patterns['inline_styles'].append({
|
|
'file': rel_path,
|
|
'line': line,
|
|
})
|
|
|
|
except Exception:
|
|
continue
|
|
|
|
return patterns
|