Initial commit
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
"""
|
||||
Component Architecture Analyzer
|
||||
|
||||
Analyzes React component design against Bulletproof React principles:
|
||||
- Component colocation (near where they're used)
|
||||
- Limited props (< 7-10)
|
||||
- Reasonable component size (< 300 LOC)
|
||||
- No nested render functions
|
||||
- Proper composition over excessive props
|
||||
- Consistent naming (kebab-case files)
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
|
||||
def analyze(codebase_path: Path, metadata: Dict) -> List[Dict]:
|
||||
"""
|
||||
Analyze component architecture for Bulletproof React compliance.
|
||||
|
||||
Args:
|
||||
codebase_path: Path to React codebase
|
||||
metadata: Project metadata from discovery phase
|
||||
|
||||
Returns:
|
||||
List of findings with severity and migration guidance
|
||||
"""
|
||||
findings = []
|
||||
|
||||
src_dir = codebase_path / 'src'
|
||||
if not src_dir.exists():
|
||||
return findings
|
||||
|
||||
# Analyze all React component files
|
||||
findings.extend(check_component_sizes(src_dir))
|
||||
findings.extend(check_component_props(src_dir))
|
||||
findings.extend(check_nested_render_functions(src_dir))
|
||||
findings.extend(check_file_naming_conventions(src_dir))
|
||||
findings.extend(check_component_colocation(src_dir))
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def check_component_sizes(src_dir: Path) -> List[Dict]:
|
||||
"""Check for overly large components."""
|
||||
findings = []
|
||||
exclude_dirs = {'node_modules', 'dist', 'build', '.next', 'coverage'}
|
||||
|
||||
large_components = []
|
||||
for component_file in src_dir.rglob('*.{tsx,jsx}'):
|
||||
if any(excluded in component_file.parts for excluded in exclude_dirs):
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(component_file, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
lines = f.readlines()
|
||||
loc = len([line for line in lines if line.strip() and not line.strip().startswith('//')])
|
||||
|
||||
if loc > 300:
|
||||
large_components.append({
|
||||
'file': str(component_file.relative_to(src_dir)),
|
||||
'lines': loc,
|
||||
'severity': 'critical' if loc > 500 else 'high' if loc > 400 else 'medium'
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
if large_components:
|
||||
# Report the worst offenders
|
||||
large_components.sort(key=lambda x: x['lines'], reverse=True)
|
||||
|
||||
for comp in large_components[:10]: # Top 10 largest
|
||||
findings.append({
|
||||
'severity': comp['severity'],
|
||||
'category': 'components',
|
||||
'title': f'Large component ({comp["lines"]} LOC)',
|
||||
'current_state': f'{comp["file"]} has {comp["lines"]} lines',
|
||||
'target_state': 'Components should be < 300 lines. Large components are hard to understand and test.',
|
||||
'migration_steps': [
|
||||
'Identify distinct responsibilities in the component',
|
||||
'Extract smaller components for each UI section',
|
||||
'Move business logic to custom hooks',
|
||||
'Extract complex rendering logic to separate components',
|
||||
'Consider splitting into multiple feature components'
|
||||
],
|
||||
'effort': 'high' if comp['lines'] > 400 else 'medium',
|
||||
'file': comp['file'],
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def check_component_props(src_dir: Path) -> List[Dict]:
|
||||
"""Check for components with excessive props."""
|
||||
findings = []
|
||||
exclude_dirs = {'node_modules', 'dist', 'build', '.next', 'coverage'}
|
||||
|
||||
components_with_many_props = []
|
||||
for component_file in src_dir.rglob('*.{tsx,jsx}'):
|
||||
if any(excluded in component_file.parts for excluded in exclude_dirs):
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(component_file, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read()
|
||||
|
||||
# Find component definitions with props
|
||||
# Pattern matches: function Component({ prop1, prop2, ... })
|
||||
# and: const Component = ({ prop1, prop2, ... }) =>
|
||||
props_pattern = re.compile(
|
||||
r'(?:function|const)\s+(\w+)\s*(?:=\s*)?\(\s*\{([^}]+)\}',
|
||||
re.MULTILINE
|
||||
)
|
||||
|
||||
matches = props_pattern.findall(content)
|
||||
for component_name, props_str in matches:
|
||||
# Count props (split by comma)
|
||||
props = [p.strip() for p in props_str.split(',') if p.strip()]
|
||||
# Filter out destructured nested props
|
||||
actual_props = [p for p in props if not p.startswith('...')]
|
||||
prop_count = len(actual_props)
|
||||
|
||||
if prop_count > 10:
|
||||
components_with_many_props.append({
|
||||
'file': str(component_file.relative_to(src_dir)),
|
||||
'component': component_name,
|
||||
'prop_count': prop_count,
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
if components_with_many_props:
|
||||
for comp in components_with_many_props:
|
||||
findings.append({
|
||||
'severity': 'critical' if comp['prop_count'] > 15 else 'high',
|
||||
'category': 'components',
|
||||
'title': f'Component with {comp["prop_count"]} props: {comp["component"]}',
|
||||
'current_state': f'{comp["file"]} has {comp["prop_count"]} props',
|
||||
'target_state': 'Components should accept < 7-10 props. Too many props indicates insufficient composition.',
|
||||
'migration_steps': [
|
||||
'Group related props into configuration objects',
|
||||
'Use composition (children prop) instead of render props',
|
||||
'Extract sub-components with their own props',
|
||||
'Consider using Context for deeply shared state',
|
||||
'Use compound component pattern for complex UIs'
|
||||
],
|
||||
'effort': 'medium',
|
||||
'file': comp['file'],
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def check_nested_render_functions(src_dir: Path) -> List[Dict]:
|
||||
"""Check for nested render functions inside components."""
|
||||
findings = []
|
||||
exclude_dirs = {'node_modules', 'dist', 'build', '.next', 'coverage'}
|
||||
|
||||
nested_render_functions = []
|
||||
for component_file in src_dir.rglob('*.{tsx,jsx}'):
|
||||
if any(excluded in component_file.parts for excluded in exclude_dirs):
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(component_file, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read()
|
||||
lines = content.split('\n')
|
||||
|
||||
# Look for patterns like: const renderSomething = () => { ... }
|
||||
# or: function renderSomething() { ... }
|
||||
nested_render_pattern = re.compile(r'(?:const|function)\s+(render\w+)\s*[=:]?\s*\([^)]*\)\s*(?:=>)?\s*\{')
|
||||
|
||||
for line_num, line in enumerate(lines, start=1):
|
||||
if nested_render_pattern.search(line):
|
||||
nested_render_functions.append({
|
||||
'file': str(component_file.relative_to(src_dir)),
|
||||
'line': line_num,
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
if nested_render_functions:
|
||||
# Group by file
|
||||
files_with_nested = {}
|
||||
for item in nested_render_functions:
|
||||
file = item['file']
|
||||
if file not in files_with_nested:
|
||||
files_with_nested[file] = []
|
||||
files_with_nested[file].append(item['line'])
|
||||
|
||||
for file, lines in files_with_nested.items():
|
||||
findings.append({
|
||||
'severity': 'medium',
|
||||
'category': 'components',
|
||||
'title': f'Nested render functions detected ({len(lines)} instances)',
|
||||
'current_state': f'{file} contains render functions inside component',
|
||||
'target_state': 'Extract nested render functions into separate components for better reusability and testing.',
|
||||
'migration_steps': [
|
||||
'Identify each render function and its dependencies',
|
||||
'Extract to separate component file',
|
||||
'Pass necessary props to new component',
|
||||
'Update tests to test new component in isolation',
|
||||
'Remove render function from parent component'
|
||||
],
|
||||
'effort': 'low',
|
||||
'file': file,
|
||||
'affected_lines': lines[:5], # Show first 5
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def check_file_naming_conventions(src_dir: Path) -> List[Dict]:
|
||||
"""Check for consistent kebab-case file naming."""
|
||||
findings = []
|
||||
exclude_dirs = {'node_modules', 'dist', 'build', '.next', 'coverage'}
|
||||
|
||||
non_kebab_files = []
|
||||
for file_path in src_dir.rglob('*.{ts,tsx,js,jsx}'):
|
||||
if any(excluded in file_path.parts for excluded in exclude_dirs):
|
||||
continue
|
||||
|
||||
filename = file_path.stem # filename without extension
|
||||
|
||||
# Check if filename is kebab-case (lowercase with hyphens)
|
||||
# Allow: kebab-case.tsx, lowercase.tsx
|
||||
# Disallow: PascalCase.tsx, camelCase.tsx, snake_case.tsx
|
||||
is_kebab_or_lowercase = re.match(r'^[a-z][a-z0-9]*(-[a-z0-9]+)*$', filename)
|
||||
|
||||
if not is_kebab_or_lowercase and filename not in ['index', 'App']: # Allow common exceptions
|
||||
non_kebab_files.append(str(file_path.relative_to(src_dir)))
|
||||
|
||||
if len(non_kebab_files) > 5: # Only report if it's a pattern (>5 files)
|
||||
findings.append({
|
||||
'severity': 'low',
|
||||
'category': 'components',
|
||||
'title': f'Inconsistent file naming ({len(non_kebab_files)} files)',
|
||||
'current_state': f'{len(non_kebab_files)} files not using kebab-case naming',
|
||||
'target_state': 'Bulletproof React recommends kebab-case for all files (e.g., user-profile.tsx)',
|
||||
'migration_steps': [
|
||||
'Rename files to kebab-case format',
|
||||
'Update all import statements',
|
||||
'Run tests to ensure nothing broke',
|
||||
'Add ESLint rule to enforce kebab-case (unicorn/filename-case)'
|
||||
],
|
||||
'effort': 'low',
|
||||
'affected_files': non_kebab_files[:10], # Show first 10
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def check_component_colocation(src_dir: Path) -> List[Dict]:
|
||||
"""Check if components are colocated near where they're used."""
|
||||
findings = []
|
||||
|
||||
components_dir = src_dir / 'components'
|
||||
if not components_dir.exists():
|
||||
return findings
|
||||
|
||||
# Find components in shared components/ that are only used once
|
||||
single_use_components = []
|
||||
for component_file in components_dir.rglob('*.{tsx,jsx}'):
|
||||
try:
|
||||
component_name = component_file.stem
|
||||
|
||||
# Search for imports of this component across codebase
|
||||
import_pattern = re.compile(rf'import.*{component_name}.*from.*[\'"]/|@/')
|
||||
usage_count = 0
|
||||
used_in_feature = None
|
||||
|
||||
for search_file in src_dir.rglob('*.{ts,tsx,js,jsx}'):
|
||||
if search_file == component_file:
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(search_file, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read()
|
||||
if import_pattern.search(content):
|
||||
usage_count += 1
|
||||
|
||||
# Check if used in a feature
|
||||
if 'features' in search_file.parts:
|
||||
features_index = search_file.parts.index('features')
|
||||
if features_index + 1 < len(search_file.parts):
|
||||
feature_name = search_file.parts[features_index + 1]
|
||||
if used_in_feature is None:
|
||||
used_in_feature = feature_name
|
||||
elif used_in_feature != feature_name:
|
||||
used_in_feature = 'multiple'
|
||||
except:
|
||||
pass
|
||||
|
||||
# If used only in one feature, it should be colocated there
|
||||
if usage_count == 1 and used_in_feature and used_in_feature != 'multiple':
|
||||
single_use_components.append({
|
||||
'file': str(component_file.relative_to(src_dir)),
|
||||
'component': component_name,
|
||||
'feature': used_in_feature,
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
if single_use_components:
|
||||
for comp in single_use_components[:5]: # Top 5
|
||||
findings.append({
|
||||
'severity': 'low',
|
||||
'category': 'components',
|
||||
'title': f'Component used in only one feature: {comp["component"]}',
|
||||
'current_state': f'{comp["file"]} is in shared components/ but only used in {comp["feature"]} feature',
|
||||
'target_state': 'Components used by only one feature should be colocated in that feature directory.',
|
||||
'migration_steps': [
|
||||
f'Move {comp["file"]} to src/features/{comp["feature"]}/components/',
|
||||
'Update import in the feature',
|
||||
'Run tests to verify',
|
||||
'Remove from shared components/'
|
||||
],
|
||||
'effort': 'low',
|
||||
'file': comp['file'],
|
||||
})
|
||||
|
||||
return findings
|
||||
Reference in New Issue
Block a user