Initial commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Bulletproof React Analyzers
|
||||
|
||||
Specialized analyzers for different aspects of Bulletproof React compliance.
|
||||
"""
|
||||
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
API Layer Analyzer
|
||||
|
||||
Analyzes API organization against Bulletproof React patterns:
|
||||
- Centralized API client
|
||||
- Type-safe request declarations
|
||||
- Colocated in features/
|
||||
- Data fetching hooks
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
import re
|
||||
|
||||
|
||||
def analyze(codebase_path: Path, metadata: Dict) -> List[Dict]:
|
||||
"""Analyze API layer architecture."""
|
||||
findings = []
|
||||
src_dir = codebase_path / 'src'
|
||||
|
||||
if not src_dir.exists():
|
||||
return findings
|
||||
|
||||
# Check for centralized API client
|
||||
has_api_config = (src_dir / 'lib').exists() or any(src_dir.rglob('**/api-client.*'))
|
||||
if not has_api_config:
|
||||
findings.append({
|
||||
'severity': 'medium',
|
||||
'category': 'api',
|
||||
'title': 'No centralized API client detected',
|
||||
'current_state': 'No api-client configuration found in src/lib/',
|
||||
'target_state': 'Create single configured API client instance',
|
||||
'migration_steps': [
|
||||
'Create src/lib/api-client.ts with axios or fetch wrapper',
|
||||
'Configure base URL, headers, interceptors',
|
||||
'Export configured client',
|
||||
'Use in all API calls'
|
||||
],
|
||||
'effort': 'low',
|
||||
})
|
||||
|
||||
# Check for scattered fetch calls
|
||||
scattered_fetches = []
|
||||
for file in src_dir.rglob('*.{ts,tsx,js,jsx}'):
|
||||
if 'test' in str(file) or 'spec' in str(file):
|
||||
continue
|
||||
try:
|
||||
with open(file, 'r') as f:
|
||||
content = f.read()
|
||||
if re.search(r'\bfetch\s*\(', content) and 'api' not in str(file).lower():
|
||||
scattered_fetches.append(str(file.relative_to(src_dir)))
|
||||
except:
|
||||
pass
|
||||
|
||||
if len(scattered_fetches) > 3:
|
||||
findings.append({
|
||||
'severity': 'high',
|
||||
'category': 'api',
|
||||
'title': f'Scattered fetch calls in {len(scattered_fetches)} files',
|
||||
'current_state': 'fetch() calls throughout components',
|
||||
'target_state': 'Centralize API calls in feature api/ directories',
|
||||
'migration_steps': [
|
||||
'Create api/ directory in each feature',
|
||||
'Move API calls to dedicated functions',
|
||||
'Create custom hooks wrapping API calls',
|
||||
'Use React Query or SWR for data fetching'
|
||||
],
|
||||
'effort': 'high',
|
||||
'affected_files': scattered_fetches[:5],
|
||||
})
|
||||
|
||||
return findings
|
||||
@@ -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
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Error Handling Analyzer
|
||||
|
||||
Analyzes error handling patterns:
|
||||
- Error boundaries present
|
||||
- API error interceptors
|
||||
- Error tracking (Sentry)
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
import re
|
||||
|
||||
|
||||
def analyze(codebase_path: Path, metadata: Dict) -> List[Dict]:
|
||||
"""Analyze error handling patterns."""
|
||||
findings = []
|
||||
src_dir = codebase_path / 'src'
|
||||
tech_stack = metadata.get('tech_stack', {})
|
||||
|
||||
if not src_dir.exists():
|
||||
return findings
|
||||
|
||||
# Check for error boundaries
|
||||
error_boundaries = list(src_dir.rglob('**/error-boundary.*')) + \
|
||||
list(src_dir.rglob('**/ErrorBoundary.*'))
|
||||
|
||||
if not error_boundaries:
|
||||
findings.append({
|
||||
'severity': 'high',
|
||||
'category': 'errors',
|
||||
'title': 'No error boundaries detected',
|
||||
'current_state': 'No ErrorBoundary components found',
|
||||
'target_state': 'Implement multiple error boundaries at strategic locations',
|
||||
'migration_steps': [
|
||||
'Create ErrorBoundary component with componentDidCatch',
|
||||
'Wrap route components with ErrorBoundary',
|
||||
'Add feature-level error boundaries',
|
||||
'Display user-friendly error messages'
|
||||
],
|
||||
'effort': 'low',
|
||||
})
|
||||
|
||||
# Check for error tracking
|
||||
if not tech_stack.get('sentry'):
|
||||
findings.append({
|
||||
'severity': 'medium',
|
||||
'category': 'errors',
|
||||
'title': 'No error tracking service detected',
|
||||
'current_state': 'No Sentry or similar error tracking',
|
||||
'target_state': 'Use Sentry for production error monitoring',
|
||||
'migration_steps': [
|
||||
'Sign up for Sentry',
|
||||
'Install @sentry/react',
|
||||
'Configure Sentry.init() in app entry',
|
||||
'Add user context and tags',
|
||||
'Set up error alerts'
|
||||
],
|
||||
'effort': 'low',
|
||||
})
|
||||
|
||||
return findings
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Performance Patterns Analyzer
|
||||
|
||||
Analyzes React performance optimizations:
|
||||
- Code splitting at routes
|
||||
- Memoization patterns
|
||||
- Image optimization
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
import re
|
||||
|
||||
|
||||
def analyze(codebase_path: Path, metadata: Dict) -> List[Dict]:
|
||||
"""Analyze performance patterns."""
|
||||
findings = []
|
||||
src_dir = codebase_path / 'src'
|
||||
|
||||
if not src_dir.exists():
|
||||
return findings
|
||||
|
||||
# Check for lazy loading
|
||||
has_lazy_loading = False
|
||||
for file in src_dir.rglob('*.{ts,tsx,js,jsx}'):
|
||||
try:
|
||||
with open(file, 'r') as f:
|
||||
content = f.read()
|
||||
if 'React.lazy' in content or 'lazy(' in content:
|
||||
has_lazy_loading = True
|
||||
break
|
||||
except:
|
||||
pass
|
||||
|
||||
if not has_lazy_loading:
|
||||
findings.append({
|
||||
'severity': 'medium',
|
||||
'category': 'performance',
|
||||
'title': 'No code splitting detected',
|
||||
'current_state': 'No React.lazy() usage found',
|
||||
'target_state': 'Use code splitting for routes and large components',
|
||||
'migration_steps': [
|
||||
'Wrap route components with React.lazy()',
|
||||
'Add Suspense boundaries with loading states',
|
||||
'Split large features into separate chunks',
|
||||
'Analyze bundle size with build tools'
|
||||
],
|
||||
'effort': 'low',
|
||||
})
|
||||
|
||||
# Check for large images
|
||||
assets_dir = codebase_path / 'public' / 'assets'
|
||||
if assets_dir.exists():
|
||||
large_images = []
|
||||
for img in assets_dir.rglob('*.{jpg,jpeg,png,gif}'):
|
||||
size_mb = img.stat().st_size / (1024 * 1024)
|
||||
if size_mb > 0.5: # Larger than 500KB
|
||||
large_images.append((str(img.name), size_mb))
|
||||
|
||||
if large_images:
|
||||
findings.append({
|
||||
'severity': 'medium',
|
||||
'category': 'performance',
|
||||
'title': f'{len(large_images)} large images detected',
|
||||
'current_state': f'Images larger than 500KB',
|
||||
'target_state': 'Optimize images with modern formats and lazy loading',
|
||||
'migration_steps': [
|
||||
'Convert to WebP format',
|
||||
'Add lazy loading with loading="lazy"',
|
||||
'Use srcset for responsive images',
|
||||
'Compress images with tools like sharp'
|
||||
],
|
||||
'effort': 'low',
|
||||
})
|
||||
|
||||
return findings
|
||||
@@ -0,0 +1,369 @@
|
||||
"""
|
||||
Project Structure Analyzer
|
||||
|
||||
Analyzes React project structure against Bulletproof React patterns:
|
||||
- Feature-based organization (src/features/)
|
||||
- Unidirectional dependencies (shared → features → app)
|
||||
- No cross-feature imports
|
||||
- Proper folder hierarchy
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Set
|
||||
|
||||
|
||||
def analyze(codebase_path: Path, metadata: Dict) -> List[Dict]:
|
||||
"""
|
||||
Analyze project structure 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():
|
||||
findings.append({
|
||||
'severity': 'critical',
|
||||
'category': 'structure',
|
||||
'title': 'Missing src/ directory',
|
||||
'current_state': 'No src/ directory found',
|
||||
'target_state': 'All source code should be in src/ directory',
|
||||
'migration_steps': [
|
||||
'Create src/ directory',
|
||||
'Move all source files to src/',
|
||||
'Update import paths',
|
||||
'Update build configuration'
|
||||
],
|
||||
'effort': 'medium',
|
||||
})
|
||||
return findings
|
||||
|
||||
# Check for Bulletproof structure
|
||||
findings.extend(check_bulletproof_structure(src_dir))
|
||||
|
||||
# Check for cross-feature imports
|
||||
findings.extend(check_cross_feature_imports(src_dir))
|
||||
|
||||
# Analyze features/ organization
|
||||
findings.extend(analyze_features_directory(src_dir))
|
||||
|
||||
# Check shared code organization
|
||||
findings.extend(check_shared_code_organization(src_dir))
|
||||
|
||||
# Check for architectural violations
|
||||
findings.extend(check_architectural_violations(src_dir))
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def check_bulletproof_structure(src_dir: Path) -> List[Dict]:
|
||||
"""Check for presence of Bulletproof React folder structure."""
|
||||
findings = []
|
||||
|
||||
# Required top-level directories for Bulletproof React
|
||||
bulletproof_dirs = {
|
||||
'app': 'Application layer (routes, app.tsx, provider.tsx, router.tsx)',
|
||||
'features': 'Feature modules (80%+ of code should be here)',
|
||||
}
|
||||
|
||||
# Recommended directories
|
||||
recommended_dirs = {
|
||||
'components': 'Shared components used across multiple features',
|
||||
'hooks': 'Shared custom hooks',
|
||||
'lib': 'Third-party library configurations',
|
||||
'utils': 'Shared utility functions',
|
||||
'types': 'Shared TypeScript types',
|
||||
}
|
||||
|
||||
# Check required directories
|
||||
for dir_name, description in bulletproof_dirs.items():
|
||||
dir_path = src_dir / dir_name
|
||||
if not dir_path.exists():
|
||||
findings.append({
|
||||
'severity': 'critical' if dir_name == 'features' else 'high',
|
||||
'category': 'structure',
|
||||
'title': f'Missing {dir_name}/ directory',
|
||||
'current_state': f'No {dir_name}/ directory found',
|
||||
'target_state': f'{dir_name}/ directory should exist: {description}',
|
||||
'migration_steps': [
|
||||
f'Create src/{dir_name}/ directory',
|
||||
f'Organize code according to Bulletproof React {dir_name} pattern',
|
||||
'Update imports to use new structure'
|
||||
],
|
||||
'effort': 'high' if dir_name == 'features' else 'medium',
|
||||
})
|
||||
|
||||
# Check recommended directories (lower severity)
|
||||
missing_recommended = []
|
||||
for dir_name, description in recommended_dirs.items():
|
||||
dir_path = src_dir / dir_name
|
||||
if not dir_path.exists():
|
||||
missing_recommended.append(f'{dir_name}/ ({description})')
|
||||
|
||||
if missing_recommended:
|
||||
findings.append({
|
||||
'severity': 'medium',
|
||||
'category': 'structure',
|
||||
'title': 'Missing recommended directories',
|
||||
'current_state': f'Missing: {", ".join([d.split("/")[0] for d in missing_recommended])}',
|
||||
'target_state': 'Bulletproof React recommends these directories for shared code',
|
||||
'migration_steps': [
|
||||
'Create missing directories as needed',
|
||||
'Move shared code to appropriate directories',
|
||||
'Ensure proper separation between shared and feature-specific code'
|
||||
],
|
||||
'effort': 'low',
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def check_cross_feature_imports(src_dir: Path) -> List[Dict]:
|
||||
"""Detect cross-feature imports (architectural violation)."""
|
||||
findings = []
|
||||
features_dir = src_dir / 'features'
|
||||
|
||||
if not features_dir.exists():
|
||||
return findings
|
||||
|
||||
# Get all feature directories
|
||||
feature_dirs = [d for d in features_dir.iterdir() if d.is_dir() and not d.name.startswith('.')]
|
||||
|
||||
violations = []
|
||||
for feature_dir in feature_dirs:
|
||||
# Find all TypeScript/JavaScript files in this feature
|
||||
for file_path in feature_dir.rglob('*.{ts,tsx,js,jsx}'):
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check for imports from other features
|
||||
import_pattern = re.compile(r'from\s+[\'"]([^\'\"]+)[\'"]')
|
||||
imports = import_pattern.findall(content)
|
||||
|
||||
for imp in imports:
|
||||
# Check if importing from another feature
|
||||
if imp.startswith('../') or imp.startswith('@/features/'):
|
||||
# Extract feature name from import path
|
||||
if '@/features/' in imp:
|
||||
imported_feature = imp.split('@/features/')[1].split('/')[0]
|
||||
elif '../' in imp:
|
||||
# Handle relative imports
|
||||
parts = imp.split('/')
|
||||
if 'features' in parts:
|
||||
idx = parts.index('features')
|
||||
if idx + 1 < len(parts):
|
||||
imported_feature = parts[idx + 1]
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
|
||||
# Check if importing from different feature
|
||||
current_feature = feature_dir.name
|
||||
if imported_feature != current_feature and imported_feature in [f.name for f in feature_dirs]:
|
||||
violations.append({
|
||||
'file': str(file_path.relative_to(src_dir)),
|
||||
'from_feature': current_feature,
|
||||
'to_feature': imported_feature,
|
||||
'import': imp
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
if violations:
|
||||
# Group violations by feature
|
||||
grouped = {}
|
||||
for v in violations:
|
||||
key = f"{v['from_feature']} → {v['to_feature']}"
|
||||
if key not in grouped:
|
||||
grouped[key] = []
|
||||
grouped[key].append(v['file'])
|
||||
|
||||
for import_path, files in grouped.items():
|
||||
findings.append({
|
||||
'severity': 'high',
|
||||
'category': 'structure',
|
||||
'title': f'Cross-feature import: {import_path}',
|
||||
'current_state': f'{len(files)} file(s) import from another feature',
|
||||
'target_state': 'Features should be independent. Shared code belongs in src/components/, src/hooks/, or src/utils/',
|
||||
'migration_steps': [
|
||||
'Identify what code is being shared between features',
|
||||
'Move truly shared code to src/components/, src/hooks/, or src/utils/',
|
||||
'If code is feature-specific, duplicate it or refactor feature boundaries',
|
||||
'Update imports to use shared code location'
|
||||
],
|
||||
'effort': 'medium',
|
||||
'affected_files': files[:5], # Show first 5 files
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def analyze_features_directory(src_dir: Path) -> List[Dict]:
|
||||
"""Analyze features/ directory structure."""
|
||||
findings = []
|
||||
features_dir = src_dir / 'features'
|
||||
|
||||
if not features_dir.exists():
|
||||
return findings
|
||||
|
||||
feature_dirs = [d for d in features_dir.iterdir() if d.is_dir() and not d.name.startswith('.')]
|
||||
|
||||
if len(feature_dirs) == 0:
|
||||
findings.append({
|
||||
'severity': 'high',
|
||||
'category': 'structure',
|
||||
'title': 'Empty features/ directory',
|
||||
'current_state': 'features/ directory exists but contains no features',
|
||||
'target_state': '80%+ of application code should be organized in feature modules',
|
||||
'migration_steps': [
|
||||
'Identify distinct features in your application',
|
||||
'Create a directory for each feature in src/features/',
|
||||
'Move feature-specific code to appropriate feature directories',
|
||||
'Organize each feature with api/, components/, hooks/, stores/, types/, utils/ as needed'
|
||||
],
|
||||
'effort': 'high',
|
||||
})
|
||||
return findings
|
||||
|
||||
# Check each feature for proper internal structure
|
||||
for feature_dir in feature_dirs:
|
||||
feature_name = feature_dir.name
|
||||
|
||||
# Recommended feature subdirectories
|
||||
feature_subdirs = ['api', 'components', 'hooks', 'stores', 'types', 'utils']
|
||||
has_subdirs = any((feature_dir / subdir).exists() for subdir in feature_subdirs)
|
||||
|
||||
# Count files in feature root
|
||||
root_files = [f for f in feature_dir.iterdir() if f.is_file() and f.suffix in {'.ts', '.tsx', '.js', '.jsx'}]
|
||||
|
||||
if len(root_files) > 5 and not has_subdirs:
|
||||
findings.append({
|
||||
'severity': 'medium',
|
||||
'category': 'structure',
|
||||
'title': f'Feature "{feature_name}" lacks internal organization',
|
||||
'current_state': f'{len(root_files)} files in feature root without subdirectories',
|
||||
'target_state': 'Features should be organized with api/, components/, hooks/, stores/, types/, utils/ subdirectories',
|
||||
'migration_steps': [
|
||||
f'Create subdirectories in src/features/{feature_name}/',
|
||||
'Move API calls to api/',
|
||||
'Move components to components/',
|
||||
'Move hooks to hooks/',
|
||||
'Move types to types/',
|
||||
'Move utilities to utils/'
|
||||
],
|
||||
'effort': 'low',
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def check_shared_code_organization(src_dir: Path) -> List[Dict]:
|
||||
"""Check if shared code is properly organized."""
|
||||
findings = []
|
||||
|
||||
components_dir = src_dir / 'components'
|
||||
features_dir = src_dir / 'features'
|
||||
|
||||
if not components_dir.exists():
|
||||
return findings
|
||||
|
||||
# Count components
|
||||
shared_components = list(components_dir.rglob('*.{tsx,jsx}'))
|
||||
shared_count = len(shared_components)
|
||||
|
||||
# Count feature components
|
||||
feature_count = 0
|
||||
if features_dir.exists():
|
||||
feature_count = len(list(features_dir.rglob('**/components/**/*.{tsx,jsx}')))
|
||||
|
||||
total_components = shared_count + feature_count
|
||||
|
||||
if total_components > 0:
|
||||
shared_percentage = (shared_count / total_components) * 100
|
||||
|
||||
# Bulletproof React recommends 80%+ code in features
|
||||
if shared_percentage > 40:
|
||||
findings.append({
|
||||
'severity': 'medium',
|
||||
'category': 'structure',
|
||||
'title': 'Too many shared components',
|
||||
'current_state': f'{shared_percentage:.1f}% of components are in src/components/ (shared)',
|
||||
'target_state': 'Most components should be feature-specific. Only truly shared components belong in src/components/',
|
||||
'migration_steps': [
|
||||
'Review each component in src/components/',
|
||||
'Identify components used by only one feature',
|
||||
'Move feature-specific components to their feature directories',
|
||||
'Keep only truly shared components in src/components/'
|
||||
],
|
||||
'effort': 'medium',
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def check_architectural_violations(src_dir: Path) -> List[Dict]:
|
||||
"""Check for common architectural violations."""
|
||||
findings = []
|
||||
|
||||
# Check for business logic in components/
|
||||
components_dir = src_dir / 'components'
|
||||
if components_dir.exists():
|
||||
large_components = []
|
||||
for component_file in components_dir.rglob('*.{tsx,jsx}'):
|
||||
try:
|
||||
with open(component_file, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
lines = len(f.readlines())
|
||||
if lines > 200:
|
||||
large_components.append((str(component_file.relative_to(src_dir)), lines))
|
||||
except:
|
||||
pass
|
||||
|
||||
if large_components:
|
||||
findings.append({
|
||||
'severity': 'medium',
|
||||
'category': 'structure',
|
||||
'title': 'Large components in shared components/',
|
||||
'current_state': f'{len(large_components)} component(s) > 200 lines in src/components/',
|
||||
'target_state': 'Shared components should be simple and reusable. Complex components likely belong in features/',
|
||||
'migration_steps': [
|
||||
'Review large shared components',
|
||||
'Extract business logic to feature-specific hooks or utilities',
|
||||
'Consider moving complex components to features/ if feature-specific',
|
||||
'Keep shared components simple and focused'
|
||||
],
|
||||
'effort': 'medium',
|
||||
'affected_files': [f[0] for f in large_components[:5]],
|
||||
})
|
||||
|
||||
# Check for proper app/ structure
|
||||
app_dir = src_dir / 'app'
|
||||
if app_dir.exists():
|
||||
expected_app_files = ['app.tsx', 'provider.tsx', 'router.tsx']
|
||||
has_routing = any((app_dir / f).exists() or (app_dir / 'routes').exists() for f in ['router.tsx', 'routes.tsx'])
|
||||
|
||||
if not has_routing:
|
||||
findings.append({
|
||||
'severity': 'low',
|
||||
'category': 'structure',
|
||||
'title': 'Missing routing configuration in app/',
|
||||
'current_state': 'No router.tsx or routes/ found in src/app/',
|
||||
'target_state': 'Bulletproof React recommends centralizing routing in src/app/router.tsx or src/app/routes/',
|
||||
'migration_steps': [
|
||||
'Create src/app/router.tsx or src/app/routes/',
|
||||
'Define all application routes in one place',
|
||||
'Use code splitting for route-level lazy loading'
|
||||
],
|
||||
'effort': 'low',
|
||||
})
|
||||
|
||||
return findings
|
||||
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Security Practices Analyzer
|
||||
|
||||
Analyzes React security patterns:
|
||||
- JWT with HttpOnly cookies
|
||||
- Input sanitization
|
||||
- XSS prevention
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
import re
|
||||
|
||||
|
||||
def analyze(codebase_path: Path, metadata: Dict) -> List[Dict]:
|
||||
"""Analyze security practices."""
|
||||
findings = []
|
||||
src_dir = codebase_path / 'src'
|
||||
|
||||
if not src_dir.exists():
|
||||
return findings
|
||||
|
||||
# Check for localStorage token storage (security risk)
|
||||
localstorage_auth = []
|
||||
for file in src_dir.rglob('*.{ts,tsx,js,jsx}'):
|
||||
try:
|
||||
with open(file, 'r') as f:
|
||||
content = f.read()
|
||||
if re.search(r'localStorage\.(get|set)Item\s*\(\s*[\'"].*token.*[\'"]\s*\)', content, re.IGNORECASE):
|
||||
localstorage_auth.append(str(file.relative_to(src_dir)))
|
||||
except:
|
||||
pass
|
||||
|
||||
if localstorage_auth:
|
||||
findings.append({
|
||||
'severity': 'high',
|
||||
'category': 'security',
|
||||
'title': f'Tokens stored in localStorage ({len(localstorage_auth)} files)',
|
||||
'current_state': 'Authentication tokens in localStorage (XSS vulnerable)',
|
||||
'target_state': 'Use HttpOnly cookies for JWT storage',
|
||||
'migration_steps': [
|
||||
'Configure API to set tokens in HttpOnly cookies',
|
||||
'Remove localStorage token storage',
|
||||
'Use credentials: "include" in fetch requests',
|
||||
'Implement CSRF protection'
|
||||
],
|
||||
'effort': 'medium',
|
||||
'affected_files': localstorage_auth[:3],
|
||||
})
|
||||
|
||||
# Check for dangerouslySetInnerHTML
|
||||
dangerous_html = []
|
||||
for file in src_dir.rglob('*.{tsx,jsx}'):
|
||||
try:
|
||||
with open(file, 'r') as f:
|
||||
content = f.read()
|
||||
if 'dangerouslySetInnerHTML' in content:
|
||||
dangerous_html.append(str(file.relative_to(src_dir)))
|
||||
except:
|
||||
pass
|
||||
|
||||
if dangerous_html:
|
||||
findings.append({
|
||||
'severity': 'high',
|
||||
'category': 'security',
|
||||
'title': f'dangerouslySetInnerHTML usage ({len(dangerous_html)} files)',
|
||||
'current_state': 'Using dangerouslySetInnerHTML (XSS risk)',
|
||||
'target_state': 'Sanitize HTML input with DOMPurify',
|
||||
'migration_steps': [
|
||||
'Install dompurify',
|
||||
'Sanitize HTML before rendering',
|
||||
'Prefer safe alternatives when possible',
|
||||
'Add security review for HTML rendering'
|
||||
],
|
||||
'effort': 'low',
|
||||
'affected_files': dangerous_html[:3],
|
||||
})
|
||||
|
||||
return findings
|
||||
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
Standards Compliance Analyzer
|
||||
|
||||
Analyzes project standards:
|
||||
- ESLint configuration
|
||||
- TypeScript strict mode
|
||||
- Prettier setup
|
||||
- Git hooks (Husky)
|
||||
- Naming conventions
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
import json
|
||||
|
||||
|
||||
def analyze(codebase_path: Path, metadata: Dict) -> List[Dict]:
|
||||
"""Analyze standards compliance."""
|
||||
findings = []
|
||||
tech_stack = metadata.get('tech_stack', {})
|
||||
|
||||
# Check ESLint
|
||||
eslint_config = any([
|
||||
(codebase_path / '.eslintrc.js').exists(),
|
||||
(codebase_path / '.eslintrc.json').exists(),
|
||||
(codebase_path / 'eslint.config.js').exists(),
|
||||
])
|
||||
|
||||
if not eslint_config:
|
||||
findings.append({
|
||||
'severity': 'high',
|
||||
'category': 'standards',
|
||||
'title': 'No ESLint configuration',
|
||||
'current_state': 'No .eslintrc or eslint.config found',
|
||||
'target_state': 'Configure ESLint with React and TypeScript rules',
|
||||
'migration_steps': [
|
||||
'Install eslint and plugins',
|
||||
'Create .eslintrc.js configuration',
|
||||
'Add recommended rules for React and TS',
|
||||
'Add lint script to package.json',
|
||||
'Fix existing violations'
|
||||
],
|
||||
'effort': 'low',
|
||||
})
|
||||
|
||||
# Check TypeScript strict mode
|
||||
tsconfig = codebase_path / 'tsconfig.json'
|
||||
if tsconfig.exists():
|
||||
try:
|
||||
with open(tsconfig, 'r') as f:
|
||||
config = json.load(f)
|
||||
strict = config.get('compilerOptions', {}).get('strict', False)
|
||||
if not strict:
|
||||
findings.append({
|
||||
'severity': 'high',
|
||||
'category': 'standards',
|
||||
'title': 'TypeScript strict mode disabled',
|
||||
'current_state': 'strict: false in tsconfig.json',
|
||||
'target_state': 'Enable strict mode for better type safety',
|
||||
'migration_steps': [
|
||||
'Set "strict": true in compilerOptions',
|
||||
'Fix type errors incrementally',
|
||||
'Add explicit return types',
|
||||
'Remove any types'
|
||||
],
|
||||
'effort': 'high',
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
# Check Prettier
|
||||
if not tech_stack.get('prettier'):
|
||||
findings.append({
|
||||
'severity': 'low',
|
||||
'category': 'standards',
|
||||
'title': 'No Prettier detected',
|
||||
'current_state': 'Prettier not in dependencies',
|
||||
'target_state': 'Use Prettier for consistent code formatting',
|
||||
'migration_steps': [
|
||||
'Install prettier',
|
||||
'Create .prettierrc configuration',
|
||||
'Enable "format on save" in IDE',
|
||||
'Run prettier on all files'
|
||||
],
|
||||
'effort': 'low',
|
||||
})
|
||||
|
||||
# Check Husky
|
||||
if not tech_stack.get('husky'):
|
||||
findings.append({
|
||||
'severity': 'low',
|
||||
'category': 'standards',
|
||||
'title': 'No git hooks (Husky) detected',
|
||||
'current_state': 'No pre-commit hooks',
|
||||
'target_state': 'Use Husky for pre-commit linting and testing',
|
||||
'migration_steps': [
|
||||
'Install husky and lint-staged',
|
||||
'Set up pre-commit hook',
|
||||
'Run lint and type-check before commits',
|
||||
'Prevent bad code from entering repo'
|
||||
],
|
||||
'effort': 'low',
|
||||
})
|
||||
|
||||
return findings
|
||||
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
State Management Analyzer
|
||||
|
||||
Analyzes React state management against Bulletproof React principles:
|
||||
- Appropriate tool for each state type (component, app, server, form, URL)
|
||||
- State localized when possible
|
||||
- Server cache separated (React Query/SWR)
|
||||
- No global state overuse
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
def analyze(codebase_path: Path, metadata: Dict) -> List[Dict]:
|
||||
"""
|
||||
Analyze state management patterns.
|
||||
|
||||
Args:
|
||||
codebase_path: Path to React codebase
|
||||
metadata: Project metadata from discovery phase
|
||||
|
||||
Returns:
|
||||
List of findings with severity and migration guidance
|
||||
"""
|
||||
findings = []
|
||||
|
||||
tech_stack = metadata.get('tech_stack', {})
|
||||
src_dir = codebase_path / 'src'
|
||||
|
||||
if not src_dir.exists():
|
||||
return findings
|
||||
|
||||
# Check for appropriate state management tools
|
||||
findings.extend(check_state_management_tools(tech_stack))
|
||||
|
||||
# Check for data fetching library (server cache state)
|
||||
findings.extend(check_data_fetching_library(tech_stack))
|
||||
|
||||
# Check for form state management
|
||||
findings.extend(check_form_state_management(src_dir, tech_stack))
|
||||
|
||||
# Check for potential state management issues
|
||||
findings.extend(check_state_patterns(src_dir))
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def check_state_management_tools(tech_stack: Dict) -> List[Dict]:
|
||||
"""Check for presence of appropriate state management tools."""
|
||||
findings = []
|
||||
|
||||
# Check if any global state management is present
|
||||
has_state_mgmt = any([
|
||||
tech_stack.get('redux'),
|
||||
tech_stack.get('zustand'),
|
||||
tech_stack.get('jotai'),
|
||||
tech_stack.get('mobx')
|
||||
])
|
||||
|
||||
# If app has many features but no state management, might need it
|
||||
# (This is a heuristic - could be Context-based which is fine)
|
||||
if not has_state_mgmt:
|
||||
findings.append({
|
||||
'severity': 'low',
|
||||
'category': 'state',
|
||||
'title': 'No explicit global state management detected',
|
||||
'current_state': 'No Redux, Zustand, Jotai, or MobX found',
|
||||
'target_state': 'Consider Zustand or Jotai for global state if Context becomes complex. Start with Context + hooks.',
|
||||
'migration_steps': [
|
||||
'Evaluate if Context API is sufficient for your needs',
|
||||
'If Context becomes complex, consider Zustand (simple) or Jotai (atomic)',
|
||||
'Avoid Redux unless you need its ecosystem (Redux Toolkit simplifies it)',
|
||||
'Keep state as local as possible before going global'
|
||||
],
|
||||
'effort': 'low',
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def check_data_fetching_library(tech_stack: Dict) -> List[Dict]:
|
||||
"""Check for React Query, SWR, or similar for server state."""
|
||||
findings = []
|
||||
|
||||
has_data_fetching = any([
|
||||
tech_stack.get('react-query'),
|
||||
tech_stack.get('swr'),
|
||||
tech_stack.get('apollo'),
|
||||
tech_stack.get('rtk-query')
|
||||
])
|
||||
|
||||
if not has_data_fetching:
|
||||
findings.append({
|
||||
'severity': 'high',
|
||||
'category': 'state',
|
||||
'title': 'No data fetching library detected',
|
||||
'current_state': 'No React Query, SWR, Apollo Client, or RTK Query found',
|
||||
'target_state': 'Use React Query or SWR for server state management (caching, refetching, optimistic updates)',
|
||||
'migration_steps': [
|
||||
'Install React Query (@tanstack/react-query) or SWR',
|
||||
'Wrap app with QueryClientProvider (React Query) or SWRConfig (SWR)',
|
||||
'Convert fetch calls to useQuery hooks',
|
||||
'Replace manual loading/error states with library patterns',
|
||||
'Add staleTime, cacheTime configurations as needed'
|
||||
],
|
||||
'effort': 'medium',
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def check_form_state_management(src_dir: Path, tech_stack: Dict) -> List[Dict]:
|
||||
"""Check for form state management."""
|
||||
findings = []
|
||||
|
||||
has_form_lib = any([
|
||||
tech_stack.get('react-hook-form'),
|
||||
tech_stack.get('formik')
|
||||
])
|
||||
|
||||
# Look for form components without form library
|
||||
if not has_form_lib:
|
||||
form_files = []
|
||||
for file_path in src_dir.rglob('*.{tsx,jsx}'):
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read()
|
||||
# Look for <form> tags
|
||||
if re.search(r'<form[>\s]', content, re.IGNORECASE):
|
||||
form_files.append(str(file_path.relative_to(src_dir)))
|
||||
except:
|
||||
pass
|
||||
|
||||
if len(form_files) > 3: # More than 3 forms suggests need for form library
|
||||
findings.append({
|
||||
'severity': 'medium',
|
||||
'category': 'state',
|
||||
'title': f'No form library but {len(form_files)} forms detected',
|
||||
'current_state': f'{len(form_files)} form components without React Hook Form or Formik',
|
||||
'target_state': 'Use React Hook Form for performant form state management',
|
||||
'migration_steps': [
|
||||
'Install react-hook-form',
|
||||
'Replace controlled form state with useForm() hook',
|
||||
'Use register() for input registration',
|
||||
'Handle validation with yup or zod schemas',
|
||||
'Reduce re-renders with uncontrolled inputs'
|
||||
],
|
||||
'effort': 'medium',
|
||||
'affected_files': form_files[:5],
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def check_state_patterns(src_dir: Path) -> List[Dict]:
|
||||
"""Check for common state management anti-patterns."""
|
||||
findings = []
|
||||
|
||||
# Look for large Context providers (potential performance issue)
|
||||
large_contexts = []
|
||||
for file_path in src_dir.rglob('*.{tsx,jsx}'):
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read()
|
||||
|
||||
# Look for Context creation with many values
|
||||
if 'createContext' in content:
|
||||
# Count useState hooks in the provider
|
||||
state_count = len(re.findall(r'useState\s*\(', content))
|
||||
if state_count > 5:
|
||||
large_contexts.append({
|
||||
'file': str(file_path.relative_to(src_dir)),
|
||||
'state_count': state_count
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
if large_contexts:
|
||||
for ctx in large_contexts:
|
||||
findings.append({
|
||||
'severity': 'medium',
|
||||
'category': 'state',
|
||||
'title': f'Large Context with {ctx["state_count"]} state values',
|
||||
'current_state': f'{ctx["file"]} has many state values in one Context',
|
||||
'target_state': 'Split large Contexts into smaller, focused Contexts to prevent unnecessary re-renders',
|
||||
'migration_steps': [
|
||||
'Identify which state values change together',
|
||||
'Create separate Contexts for independent state',
|
||||
'Use Context composition for related state',
|
||||
'Consider Zustand/Jotai for complex global state'
|
||||
],
|
||||
'effort': 'medium',
|
||||
'file': ctx['file'],
|
||||
})
|
||||
|
||||
return findings
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Styling Patterns Analyzer
|
||||
|
||||
Analyzes styling approach against Bulletproof React:
|
||||
- Consistent styling method
|
||||
- Component library usage
|
||||
- Colocated styles
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
def analyze(codebase_path: Path, metadata: Dict) -> List[Dict]:
|
||||
"""Analyze styling patterns."""
|
||||
findings = []
|
||||
tech_stack = metadata.get('tech_stack', {})
|
||||
|
||||
# Check for styling approach
|
||||
styling_tools = []
|
||||
if tech_stack.get('tailwind'): styling_tools.append('Tailwind CSS')
|
||||
if tech_stack.get('styled-components'): styling_tools.append('styled-components')
|
||||
if tech_stack.get('emotion'): styling_tools.append('Emotion')
|
||||
if tech_stack.get('chakra-ui'): styling_tools.append('Chakra UI')
|
||||
if tech_stack.get('mui'): styling_tools.append('Material UI')
|
||||
if tech_stack.get('radix-ui'): styling_tools.append('Radix UI')
|
||||
|
||||
if not styling_tools:
|
||||
findings.append({
|
||||
'severity': 'low',
|
||||
'category': 'styling',
|
||||
'title': 'No component library or utility CSS detected',
|
||||
'current_state': 'No Tailwind, Chakra UI, Radix UI, or other styling system',
|
||||
'target_state': 'Use component library (Chakra, Radix) or utility CSS (Tailwind)',
|
||||
'migration_steps': [
|
||||
'Choose styling approach based on needs',
|
||||
'Install Tailwind CSS (utility-first) or Chakra UI (component library)',
|
||||
'Configure theme and design tokens',
|
||||
'Migrate components gradually'
|
||||
],
|
||||
'effort': 'medium',
|
||||
})
|
||||
elif len(styling_tools) > 2:
|
||||
findings.append({
|
||||
'severity': 'medium',
|
||||
'category': 'styling',
|
||||
'title': f'Multiple styling approaches ({len(styling_tools)})',
|
||||
'current_state': f'Using: {", ".join(styling_tools)}',
|
||||
'target_state': 'Standardize on single styling approach',
|
||||
'migration_steps': [
|
||||
'Choose primary styling system',
|
||||
'Create migration plan',
|
||||
'Update style guide',
|
||||
'Refactor components incrementally'
|
||||
],
|
||||
'effort': 'high',
|
||||
})
|
||||
|
||||
return findings
|
||||
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
Testing Strategy Analyzer
|
||||
|
||||
Analyzes React testing against Bulletproof React and Connor's standards:
|
||||
- Testing trophy distribution (70% integration, 20% unit, 10% E2E)
|
||||
- 80%+ coverage requirement
|
||||
- Semantic queries (getByRole preferred)
|
||||
- User behavior testing (not implementation details)
|
||||
- Test naming ("should X when Y")
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
def analyze(codebase_path: Path, metadata: Dict) -> List[Dict]:
|
||||
"""
|
||||
Analyze testing strategy and quality.
|
||||
|
||||
Args:
|
||||
codebase_path: Path to React codebase
|
||||
metadata: Project metadata from discovery phase
|
||||
|
||||
Returns:
|
||||
List of findings with severity and migration guidance
|
||||
"""
|
||||
findings = []
|
||||
|
||||
tech_stack = metadata.get('tech_stack', {})
|
||||
src_dir = codebase_path / 'src'
|
||||
|
||||
if not src_dir.exists():
|
||||
return findings
|
||||
|
||||
# Check for testing framework
|
||||
findings.extend(check_testing_framework(tech_stack))
|
||||
|
||||
# Check test coverage
|
||||
findings.extend(check_test_coverage(codebase_path))
|
||||
|
||||
# Analyze test distribution (unit vs integration vs E2E)
|
||||
findings.extend(analyze_test_distribution(codebase_path))
|
||||
|
||||
# Check test quality patterns
|
||||
findings.extend(check_test_quality(codebase_path))
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def check_testing_framework(tech_stack: Dict) -> List[Dict]:
|
||||
"""Check for modern testing setup."""
|
||||
findings = []
|
||||
|
||||
has_test_framework = tech_stack.get('vitest') or tech_stack.get('jest')
|
||||
has_testing_library = tech_stack.get('testing-library')
|
||||
|
||||
if not has_test_framework:
|
||||
findings.append({
|
||||
'severity': 'critical',
|
||||
'category': 'testing',
|
||||
'title': 'No testing framework detected',
|
||||
'current_state': 'No Vitest or Jest found',
|
||||
'target_state': 'Use Vitest (modern, fast) or Jest for testing',
|
||||
'migration_steps': [
|
||||
'Install Vitest (recommended for Vite) or Jest',
|
||||
'Install @testing-library/react',
|
||||
'Configure test setup file',
|
||||
'Add test scripts to package.json',
|
||||
'Set up coverage reporting'
|
||||
],
|
||||
'effort': 'medium',
|
||||
})
|
||||
|
||||
if not has_testing_library:
|
||||
findings.append({
|
||||
'severity': 'high',
|
||||
'category': 'testing',
|
||||
'title': 'Testing Library not found',
|
||||
'current_state': 'No @testing-library/react detected',
|
||||
'target_state': 'Use Testing Library for user-centric testing',
|
||||
'migration_steps': [
|
||||
'Install @testing-library/react',
|
||||
'Install @testing-library/jest-dom for assertions',
|
||||
'Use render() and semantic queries (getByRole)',
|
||||
'Follow testing-library principles (test behavior, not implementation)'
|
||||
],
|
||||
'effort': 'low',
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def check_test_coverage(codebase_path: Path) -> List[Dict]:
|
||||
"""Check test coverage if available."""
|
||||
findings = []
|
||||
|
||||
# Look for coverage reports
|
||||
coverage_file = codebase_path / 'coverage' / 'coverage-summary.json'
|
||||
|
||||
if coverage_file.exists():
|
||||
try:
|
||||
with open(coverage_file, 'r') as f:
|
||||
coverage_data = json.load(f)
|
||||
total_coverage = coverage_data.get('total', {})
|
||||
line_coverage = total_coverage.get('lines', {}).get('pct', 0)
|
||||
branch_coverage = total_coverage.get('branches', {}).get('pct', 0)
|
||||
|
||||
if line_coverage < 80:
|
||||
findings.append({
|
||||
'severity': 'high',
|
||||
'category': 'testing',
|
||||
'title': f'Test coverage below 80% ({line_coverage:.1f}%)',
|
||||
'current_state': f'Line coverage: {line_coverage:.1f}%, Branch coverage: {branch_coverage:.1f}%',
|
||||
'target_state': 'Maintain 80%+ test coverage on all code',
|
||||
'migration_steps': [
|
||||
'Identify untested files and functions',
|
||||
'Prioritize testing critical paths (authentication, payment, data processing)',
|
||||
'Write integration tests first (70% of tests)',
|
||||
'Add unit tests for complex business logic',
|
||||
'Configure coverage thresholds in test config'
|
||||
],
|
||||
'effort': 'high',
|
||||
})
|
||||
elif line_coverage < 90:
|
||||
findings.append({
|
||||
'severity': 'medium',
|
||||
'category': 'testing',
|
||||
'title': f'Test coverage at {line_coverage:.1f}%',
|
||||
'current_state': f'Coverage is good but could be excellent (current: {line_coverage:.1f}%)',
|
||||
'target_state': 'Aim for 90%+ coverage for production-ready code',
|
||||
'migration_steps': [
|
||||
'Identify remaining untested code paths',
|
||||
'Focus on edge cases and error handling',
|
||||
'Ensure all critical features have 100% coverage'
|
||||
],
|
||||
'effort': 'medium',
|
||||
})
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
findings.append({
|
||||
'severity': 'high',
|
||||
'category': 'testing',
|
||||
'title': 'No coverage report found',
|
||||
'current_state': 'Cannot find coverage/coverage-summary.json',
|
||||
'target_state': 'Generate coverage reports to track test coverage',
|
||||
'migration_steps': [
|
||||
'Configure coverage in vitest.config.ts or jest.config.js',
|
||||
'Add --coverage flag to test script',
|
||||
'Set coverage thresholds (lines: 80, branches: 75)',
|
||||
'Add coverage/ to .gitignore',
|
||||
'Review coverage reports regularly'
|
||||
],
|
||||
'effort': 'low',
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def analyze_test_distribution(codebase_path: Path) -> List[Dict]:
|
||||
"""Analyze testing trophy distribution."""
|
||||
findings = []
|
||||
|
||||
# Count test files by type
|
||||
unit_tests = 0
|
||||
integration_tests = 0
|
||||
e2e_tests = 0
|
||||
|
||||
test_patterns = {
|
||||
'e2e': ['e2e/', '.e2e.test.', '.e2e.spec.', 'playwright/', 'cypress/'],
|
||||
'integration': ['.test.tsx', '.test.jsx', '.spec.tsx', '.spec.jsx'], # Component tests
|
||||
'unit': ['.test.ts', '.test.js', '.spec.ts', '.spec.js'], # Logic tests
|
||||
}
|
||||
|
||||
for test_file in codebase_path.rglob('*.{test,spec}.{ts,tsx,js,jsx}'):
|
||||
test_path_str = str(test_file)
|
||||
|
||||
# E2E tests
|
||||
if any(pattern in test_path_str for pattern in test_patterns['e2e']):
|
||||
e2e_tests += 1
|
||||
# Integration tests (component tests with TSX/JSX)
|
||||
elif any(pattern in test_path_str for pattern in test_patterns['integration']):
|
||||
integration_tests += 1
|
||||
# Unit tests (pure logic, no JSX)
|
||||
else:
|
||||
unit_tests += 1
|
||||
|
||||
total_tests = unit_tests + integration_tests + e2e_tests
|
||||
|
||||
if total_tests > 0:
|
||||
int_pct = (integration_tests / total_tests) * 100
|
||||
unit_pct = (unit_tests / total_tests) * 100
|
||||
e2e_pct = (e2e_tests / total_tests) * 100
|
||||
|
||||
# Testing Trophy: 70% integration, 20% unit, 10% E2E
|
||||
if int_pct < 50: # Should be ~70%
|
||||
findings.append({
|
||||
'severity': 'medium',
|
||||
'category': 'testing',
|
||||
'title': 'Testing pyramid instead of testing trophy',
|
||||
'current_state': f'Distribution: {int_pct:.0f}% integration, {unit_pct:.0f}% unit, {e2e_pct:.0f}% E2E',
|
||||
'target_state': 'Testing Trophy: 70% integration, 20% unit, 10% E2E',
|
||||
'migration_steps': [
|
||||
'Write more integration tests (component + hooks + context)',
|
||||
'Test user workflows, not implementation details',
|
||||
'Reduce excessive unit tests of simple functions',
|
||||
'Keep E2E tests for critical user journeys only',
|
||||
'Use Testing Library for integration tests'
|
||||
],
|
||||
'effort': 'medium',
|
||||
})
|
||||
|
||||
if unit_pct > 40: # Should be ~20%
|
||||
findings.append({
|
||||
'severity': 'low',
|
||||
'category': 'testing',
|
||||
'title': 'Too many unit tests',
|
||||
'current_state': f'{unit_pct:.0f}% unit tests (target: ~20%)',
|
||||
'target_state': 'Focus on integration tests that provide more confidence',
|
||||
'migration_steps': [
|
||||
'Review unit tests - many could be integration tests',
|
||||
'Combine related unit tests into integration tests',
|
||||
'Keep unit tests only for complex business logic',
|
||||
'Test components with their hooks and context'
|
||||
],
|
||||
'effort': 'low',
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def check_test_quality(codebase_path: Path) -> List[Dict]:
|
||||
"""Check for test quality anti-patterns."""
|
||||
findings = []
|
||||
|
||||
brittle_test_patterns = []
|
||||
bad_query_usage = []
|
||||
bad_naming = []
|
||||
|
||||
for test_file in codebase_path.rglob('*.{test,spec}.{ts,tsx,js,jsx}'):
|
||||
try:
|
||||
with open(test_file, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check for brittle tests (testing implementation)
|
||||
if 'getByTestId' in content:
|
||||
bad_query_usage.append(str(test_file))
|
||||
|
||||
# Check for testing exact counts (brittle)
|
||||
if re.search(r'expect\([^)]+\)\.toHaveLength\(\d+\)', content):
|
||||
brittle_test_patterns.append(str(test_file))
|
||||
|
||||
# Check test naming ("should X when Y")
|
||||
test_names = re.findall(r'(?:it|test)\s*\(\s*[\'"]([^\'"]+)[\'"]', content)
|
||||
for name in test_names:
|
||||
if not (name.startswith('should ') or 'when' in name.lower()):
|
||||
bad_naming.append((str(test_file), name))
|
||||
except:
|
||||
pass
|
||||
|
||||
if bad_query_usage:
|
||||
findings.append({
|
||||
'severity': 'medium',
|
||||
'category': 'testing',
|
||||
'title': f'Using getByTestId in {len(bad_query_usage)} test files',
|
||||
'current_state': 'Tests use getByTestId instead of semantic queries',
|
||||
'target_state': 'Use semantic queries: getByRole, getByLabelText, getByText',
|
||||
'migration_steps': [
|
||||
'Replace getByTestId with getByRole (most preferred)',
|
||||
'Use getByLabelText for form inputs',
|
||||
'Use getByText for user-visible content',
|
||||
'Only use getByTestId as last resort',
|
||||
'Add eslint-plugin-testing-library for enforcement'
|
||||
],
|
||||
'effort': 'medium',
|
||||
'affected_files': bad_query_usage[:5],
|
||||
})
|
||||
|
||||
if brittle_test_patterns:
|
||||
findings.append({
|
||||
'severity': 'low',
|
||||
'category': 'testing',
|
||||
'title': f'Brittle test patterns in {len(brittle_test_patterns)} files',
|
||||
'current_state': 'Tests check exact counts and DOM structure',
|
||||
'target_state': 'Test user behavior and outcomes, not exact DOM structure',
|
||||
'migration_steps': [
|
||||
'Avoid testing exact element counts',
|
||||
'Focus on user-visible behavior',
|
||||
'Test functionality, not implementation',
|
||||
'Allow flexibility in DOM structure'
|
||||
],
|
||||
'effort': 'low',
|
||||
})
|
||||
|
||||
if len(bad_naming) > 5: # More than 5 tests with poor naming
|
||||
findings.append({
|
||||
'severity': 'low',
|
||||
'category': 'testing',
|
||||
'title': f'{len(bad_naming)} tests with unclear naming',
|
||||
'current_state': 'Test names don\'t follow "should X when Y" pattern',
|
||||
'target_state': 'Use descriptive names: "should display error when API fails"',
|
||||
'migration_steps': [
|
||||
'Rename tests to describe expected behavior',
|
||||
'Use pattern: "should [expected behavior] when [condition]"',
|
||||
'Make tests self-documenting',
|
||||
'Tests should read like requirements'
|
||||
],
|
||||
'effort': 'low',
|
||||
})
|
||||
|
||||
return findings
|
||||
503
skills/bulletproof-react-auditor/scripts/audit_engine.py
Normal file
503
skills/bulletproof-react-auditor/scripts/audit_engine.py
Normal file
@@ -0,0 +1,503 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Bulletproof React Audit Engine
|
||||
|
||||
Orchestrates comprehensive React/TypeScript codebase analysis against Bulletproof
|
||||
React architecture principles. Generates detailed audit reports and migration plans.
|
||||
|
||||
Usage:
|
||||
python audit_engine.py /path/to/react-app --output report.md
|
||||
python audit_engine.py /path/to/react-app --format json --output report.json
|
||||
python audit_engine.py /path/to/react-app --migration-plan --output migration.md
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
import importlib.util
|
||||
|
||||
# Bulletproof React specific analyzers
|
||||
ANALYZERS = {
|
||||
'structure': 'analyzers.project_structure',
|
||||
'components': 'analyzers.component_architecture',
|
||||
'state': 'analyzers.state_management',
|
||||
'api': 'analyzers.api_layer',
|
||||
'testing': 'analyzers.testing_strategy',
|
||||
'styling': 'analyzers.styling_patterns',
|
||||
'errors': 'analyzers.error_handling',
|
||||
'performance': 'analyzers.performance_patterns',
|
||||
'security': 'analyzers.security_practices',
|
||||
'standards': 'analyzers.standards_compliance',
|
||||
}
|
||||
|
||||
|
||||
class BulletproofAuditEngine:
|
||||
"""
|
||||
Core audit engine for Bulletproof React compliance analysis.
|
||||
|
||||
Uses progressive disclosure: loads only necessary analyzers based on scope.
|
||||
"""
|
||||
|
||||
def __init__(self, codebase_path: Path, scope: Optional[List[str]] = None):
|
||||
"""
|
||||
Initialize Bulletproof React audit engine.
|
||||
|
||||
Args:
|
||||
codebase_path: Path to the React codebase to audit
|
||||
scope: Optional list of analysis categories to run
|
||||
If None, runs all analyzers.
|
||||
"""
|
||||
self.codebase_path = Path(codebase_path).resolve()
|
||||
self.scope = scope or list(ANALYZERS.keys())
|
||||
self.findings: Dict[str, List[Dict]] = {}
|
||||
self.metadata: Dict = {}
|
||||
|
||||
if not self.codebase_path.exists():
|
||||
raise FileNotFoundError(f"Codebase path does not exist: {self.codebase_path}")
|
||||
|
||||
def discover_project(self) -> Dict:
|
||||
"""
|
||||
Phase 1: Initial React project discovery (lightweight scan).
|
||||
|
||||
Returns:
|
||||
Dictionary containing React project metadata
|
||||
"""
|
||||
print("🔍 Phase 1: Discovering React project structure...")
|
||||
|
||||
metadata = {
|
||||
'path': str(self.codebase_path),
|
||||
'scan_time': datetime.now().isoformat(),
|
||||
'is_react': self._detect_react(),
|
||||
'tech_stack': self._detect_tech_stack(),
|
||||
'structure_type': self._detect_structure_type(),
|
||||
'total_files': self._count_files(),
|
||||
'total_lines': self._count_lines(),
|
||||
'git_info': self._get_git_info(),
|
||||
}
|
||||
|
||||
if not metadata['is_react']:
|
||||
print("⚠️ Warning: This does not appear to be a React project!")
|
||||
print(" Bulletproof React audit is designed for React applications.")
|
||||
|
||||
self.metadata = metadata
|
||||
return metadata
|
||||
|
||||
def _detect_react(self) -> bool:
|
||||
"""Check if this is a React project."""
|
||||
pkg_json = self.codebase_path / 'package.json'
|
||||
if not pkg_json.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(pkg_json, 'r') as f:
|
||||
pkg = json.load(f)
|
||||
deps = {**pkg.get('dependencies', {}), **pkg.get('devDependencies', {})}
|
||||
return 'react' in deps or 'react-dom' in deps
|
||||
except:
|
||||
return False
|
||||
|
||||
def _detect_tech_stack(self) -> Dict[str, bool]:
|
||||
"""Detect React ecosystem tools and libraries."""
|
||||
pkg_json = self.codebase_path / 'package.json'
|
||||
tech_stack = {}
|
||||
|
||||
if pkg_json.exists():
|
||||
try:
|
||||
with open(pkg_json, 'r') as f:
|
||||
pkg = json.load(f)
|
||||
deps = {**pkg.get('dependencies', {}), **pkg.get('devDependencies', {})}
|
||||
|
||||
# Core
|
||||
tech_stack['react'] = 'react' in deps
|
||||
tech_stack['typescript'] = 'typescript' in deps or (self.codebase_path / 'tsconfig.json').exists()
|
||||
|
||||
# Build tools
|
||||
tech_stack['vite'] = 'vite' in deps
|
||||
tech_stack['create-react-app'] = 'react-scripts' in deps
|
||||
tech_stack['next'] = 'next' in deps
|
||||
|
||||
# State management
|
||||
tech_stack['redux'] = 'redux' in deps or '@reduxjs/toolkit' in deps
|
||||
tech_stack['zustand'] = 'zustand' in deps
|
||||
tech_stack['jotai'] = 'jotai' in deps
|
||||
tech_stack['mobx'] = 'mobx' in deps
|
||||
|
||||
# Data fetching
|
||||
tech_stack['react-query'] = '@tanstack/react-query' in deps or 'react-query' in deps
|
||||
tech_stack['swr'] = 'swr' in deps
|
||||
tech_stack['apollo'] = '@apollo/client' in deps
|
||||
tech_stack['rtk-query'] = '@reduxjs/toolkit' in deps
|
||||
|
||||
# Forms
|
||||
tech_stack['react-hook-form'] = 'react-hook-form' in deps
|
||||
tech_stack['formik'] = 'formik' in deps
|
||||
|
||||
# Styling
|
||||
tech_stack['tailwind'] = 'tailwindcss' in deps or (self.codebase_path / 'tailwind.config.js').exists()
|
||||
tech_stack['styled-components'] = 'styled-components' in deps
|
||||
tech_stack['emotion'] = '@emotion/react' in deps
|
||||
tech_stack['chakra-ui'] = '@chakra-ui/react' in deps
|
||||
tech_stack['mui'] = '@mui/material' in deps
|
||||
tech_stack['radix-ui'] = any('@radix-ui' in dep for dep in deps.keys())
|
||||
|
||||
# Testing
|
||||
tech_stack['vitest'] = 'vitest' in deps
|
||||
tech_stack['jest'] = 'jest' in deps
|
||||
tech_stack['testing-library'] = '@testing-library/react' in deps
|
||||
tech_stack['playwright'] = '@playwright/test' in deps
|
||||
tech_stack['cypress'] = 'cypress' in deps
|
||||
|
||||
# Routing
|
||||
tech_stack['react-router'] = 'react-router-dom' in deps
|
||||
|
||||
# Error tracking
|
||||
tech_stack['sentry'] = '@sentry/react' in deps
|
||||
|
||||
# Code quality
|
||||
tech_stack['eslint'] = 'eslint' in deps
|
||||
tech_stack['prettier'] = 'prettier' in deps
|
||||
tech_stack['husky'] = 'husky' in deps
|
||||
|
||||
except:
|
||||
pass
|
||||
|
||||
return {k: v for k, v in tech_stack.items() if v}
|
||||
|
||||
def _detect_structure_type(self) -> str:
|
||||
"""Determine project structure pattern (feature-based vs flat)."""
|
||||
src_dir = self.codebase_path / 'src'
|
||||
if not src_dir.exists():
|
||||
return 'no_src_directory'
|
||||
|
||||
features_dir = src_dir / 'features'
|
||||
components_dir = src_dir / 'components'
|
||||
app_dir = src_dir / 'app'
|
||||
|
||||
# Count files in different locations
|
||||
features_files = len(list(features_dir.rglob('*.{js,jsx,ts,tsx}'))) if features_dir.exists() else 0
|
||||
components_files = len(list(components_dir.rglob('*.{js,jsx,ts,tsx}'))) if components_dir.exists() else 0
|
||||
|
||||
if features_dir.exists() and app_dir.exists():
|
||||
if features_files > components_files * 2:
|
||||
return 'feature_based'
|
||||
else:
|
||||
return 'mixed'
|
||||
elif features_dir.exists():
|
||||
return 'partial_feature_based'
|
||||
else:
|
||||
return 'flat'
|
||||
|
||||
def _count_files(self) -> int:
|
||||
"""Count total files in React codebase."""
|
||||
exclude_dirs = {'.git', 'node_modules', 'dist', 'build', '.next', 'out', 'coverage'}
|
||||
count = 0
|
||||
|
||||
for path in self.codebase_path.rglob('*'):
|
||||
if path.is_file() and not any(excluded in path.parts for excluded in exclude_dirs):
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
def _count_lines(self) -> int:
|
||||
"""Count total lines of code in React files."""
|
||||
exclude_dirs = {'.git', 'node_modules', 'dist', 'build', '.next', 'out', 'coverage'}
|
||||
code_extensions = {'.js', '.jsx', '.ts', '.tsx'}
|
||||
total_lines = 0
|
||||
|
||||
for path in self.codebase_path.rglob('*'):
|
||||
if (path.is_file() and
|
||||
path.suffix in code_extensions and
|
||||
not any(excluded in path.parts for excluded in exclude_dirs)):
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
total_lines += sum(1 for line in f if line.strip() and not line.strip().startswith(('//', '#', '/*', '*')))
|
||||
except:
|
||||
pass
|
||||
|
||||
return total_lines
|
||||
|
||||
def _get_git_info(self) -> Optional[Dict]:
|
||||
"""Get git repository information."""
|
||||
git_dir = self.codebase_path / '.git'
|
||||
if not git_dir.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
['git', '-C', str(self.codebase_path), 'log', '--oneline', '-10'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
commit_count = subprocess.run(
|
||||
['git', '-C', str(self.codebase_path), 'rev-list', '--count', 'HEAD'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
return {
|
||||
'is_git_repo': True,
|
||||
'recent_commits': result.stdout.strip().split('\n') if result.returncode == 0 else [],
|
||||
'total_commits': int(commit_count.stdout.strip()) if commit_count.returncode == 0 else 0,
|
||||
}
|
||||
except:
|
||||
return {'is_git_repo': True, 'error': 'Could not read git info'}
|
||||
|
||||
def run_analysis(self, phase: str = 'full') -> Dict:
|
||||
"""
|
||||
Phase 2: Deep Bulletproof React analysis using specialized analyzers.
|
||||
|
||||
Args:
|
||||
phase: 'quick' for lightweight scan, 'full' for comprehensive analysis
|
||||
|
||||
Returns:
|
||||
Dictionary containing all findings
|
||||
"""
|
||||
print(f"🔬 Phase 2: Running {phase} Bulletproof React analysis...")
|
||||
|
||||
for category in self.scope:
|
||||
if category not in ANALYZERS:
|
||||
print(f"⚠️ Unknown analyzer category: {category}, skipping...")
|
||||
continue
|
||||
|
||||
print(f" Analyzing {category}...")
|
||||
analyzer_findings = self._run_analyzer(category)
|
||||
if analyzer_findings:
|
||||
self.findings[category] = analyzer_findings
|
||||
|
||||
return self.findings
|
||||
|
||||
def _run_analyzer(self, category: str) -> List[Dict]:
|
||||
"""
|
||||
Run a specific Bulletproof React analyzer module.
|
||||
|
||||
Args:
|
||||
category: Analyzer category name
|
||||
|
||||
Returns:
|
||||
List of findings from the analyzer
|
||||
"""
|
||||
module_path = ANALYZERS.get(category)
|
||||
if not module_path:
|
||||
return []
|
||||
|
||||
try:
|
||||
# Import analyzer module dynamically
|
||||
analyzer_file = Path(__file__).parent / f"{module_path.replace('.', '/')}.py"
|
||||
|
||||
if not analyzer_file.exists():
|
||||
print(f" ⚠️ Analyzer not yet implemented: {category}")
|
||||
return []
|
||||
|
||||
spec = importlib.util.spec_from_file_location(module_path, analyzer_file)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# Each analyzer should have an analyze() function
|
||||
if hasattr(module, 'analyze'):
|
||||
return module.analyze(self.codebase_path, self.metadata)
|
||||
else:
|
||||
print(f" ⚠️ Analyzer missing analyze() function: {category}")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ Error running analyzer {category}: {e}")
|
||||
return []
|
||||
|
||||
def calculate_scores(self) -> Dict[str, float]:
|
||||
"""
|
||||
Calculate Bulletproof React compliance scores for each category.
|
||||
|
||||
Returns:
|
||||
Dictionary of scores (0-100 scale)
|
||||
"""
|
||||
scores = {}
|
||||
|
||||
# Calculate score for each category based on findings severity
|
||||
for category, findings in self.findings.items():
|
||||
if not findings:
|
||||
scores[category] = 100.0
|
||||
continue
|
||||
|
||||
# Weighted scoring based on severity
|
||||
severity_weights = {'critical': 15, 'high': 8, 'medium': 3, 'low': 1}
|
||||
total_weight = sum(severity_weights.get(f.get('severity', 'low'), 1) for f in findings)
|
||||
|
||||
# Score decreases based on weighted issues
|
||||
penalty = min(total_weight * 2, 100) # Each point = 2% penalty
|
||||
scores[category] = max(0, 100 - penalty)
|
||||
|
||||
# Overall score is weighted average
|
||||
if scores:
|
||||
scores['overall'] = sum(scores.values()) / len(scores)
|
||||
else:
|
||||
scores['overall'] = 100.0
|
||||
|
||||
return scores
|
||||
|
||||
def calculate_grade(self, score: float) -> str:
|
||||
"""Convert score to letter grade."""
|
||||
if score >= 90: return 'A'
|
||||
if score >= 80: return 'B'
|
||||
if score >= 70: return 'C'
|
||||
if score >= 60: return 'D'
|
||||
return 'F'
|
||||
|
||||
def generate_summary(self) -> Dict:
|
||||
"""
|
||||
Generate executive summary of Bulletproof React audit results.
|
||||
|
||||
Returns:
|
||||
Summary dictionary
|
||||
"""
|
||||
critical_count = sum(
|
||||
1 for findings in self.findings.values()
|
||||
for f in findings
|
||||
if f.get('severity') == 'critical'
|
||||
)
|
||||
|
||||
high_count = sum(
|
||||
1 for findings in self.findings.values()
|
||||
for f in findings
|
||||
if f.get('severity') == 'high'
|
||||
)
|
||||
|
||||
scores = self.calculate_scores()
|
||||
overall_score = scores.get('overall', 0)
|
||||
|
||||
# Estimate migration effort in person-days
|
||||
effort_map = {'low': 0.5, 'medium': 2, 'high': 5}
|
||||
total_effort = sum(
|
||||
effort_map.get(f.get('effort', 'medium'), 2)
|
||||
for findings in self.findings.values()
|
||||
for f in findings
|
||||
)
|
||||
|
||||
return {
|
||||
'compliance_score': round(overall_score, 1),
|
||||
'grade': self.calculate_grade(overall_score),
|
||||
'category_scores': {k: round(v, 1) for k, v in scores.items() if k != 'overall'},
|
||||
'critical_issues': critical_count,
|
||||
'high_issues': high_count,
|
||||
'total_issues': sum(len(findings) for findings in self.findings.values()),
|
||||
'migration_effort_days': round(total_effort, 1),
|
||||
'structure_type': self.metadata.get('structure_type', 'unknown'),
|
||||
'metadata': self.metadata,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for CLI usage."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Bulletproof React audit tool for React/TypeScript applications',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'codebase',
|
||||
type=str,
|
||||
help='Path to the React codebase to audit'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--scope',
|
||||
type=str,
|
||||
help='Comma-separated list of analysis categories (structure,components,state,api,testing,styling,errors,performance,security,standards)',
|
||||
default=None
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--phase',
|
||||
type=str,
|
||||
choices=['quick', 'full'],
|
||||
default='full',
|
||||
help='Analysis depth: quick (Phase 1 only) or full (Phase 1 + 2)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--format',
|
||||
type=str,
|
||||
choices=['markdown', 'json', 'html'],
|
||||
default='markdown',
|
||||
help='Output format for the report'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--output',
|
||||
type=str,
|
||||
help='Output file path (default: stdout)',
|
||||
default=None
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--migration-plan',
|
||||
action='store_true',
|
||||
help='Generate migration plan in addition to audit report'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Parse scope
|
||||
scope = args.scope.split(',') if args.scope else None
|
||||
|
||||
# Initialize engine
|
||||
try:
|
||||
engine = BulletproofAuditEngine(args.codebase, scope=scope)
|
||||
except FileNotFoundError as e:
|
||||
print(f"❌ Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Run audit
|
||||
print("🚀 Starting Bulletproof React audit...")
|
||||
print(f" Codebase: {args.codebase}")
|
||||
print(f" Scope: {scope or 'all'}")
|
||||
print(f" Phase: {args.phase}")
|
||||
print()
|
||||
|
||||
# Phase 1: Discovery
|
||||
metadata = engine.discover_project()
|
||||
if metadata['is_react']:
|
||||
print(f" React detected: ✅")
|
||||
print(f" TypeScript: {'✅' if metadata['tech_stack'].get('typescript') else '❌'}")
|
||||
print(f" Structure type: {metadata['structure_type']}")
|
||||
print(f" Files: {metadata['total_files']}")
|
||||
print(f" Lines of code: {metadata['total_lines']:,}")
|
||||
else:
|
||||
print(f" React detected: ❌")
|
||||
print(" Continuing audit anyway...")
|
||||
print()
|
||||
|
||||
# Phase 2: Analysis (if not quick mode)
|
||||
if args.phase == 'full':
|
||||
findings = engine.run_analysis()
|
||||
|
||||
# Generate summary
|
||||
summary = engine.generate_summary()
|
||||
|
||||
# Output results
|
||||
print()
|
||||
print("📊 Bulletproof React Audit Complete!")
|
||||
print(f" Compliance score: {summary['compliance_score']}/100 (Grade: {summary['grade']})")
|
||||
print(f" Critical issues: {summary['critical_issues']}")
|
||||
print(f" High issues: {summary['high_issues']}")
|
||||
print(f" Total issues: {summary['total_issues']}")
|
||||
print(f" Estimated migration effort: {summary['migration_effort_days']} person-days")
|
||||
print()
|
||||
|
||||
# Generate report (to be implemented in report_generator.py)
|
||||
if args.output:
|
||||
print(f"📝 Report generation will be implemented in report_generator.py")
|
||||
print(f" Format: {args.format}")
|
||||
print(f" Output: {args.output}")
|
||||
if args.migration_plan:
|
||||
print(f" Migration plan: {args.output.replace('.md', '_migration.md')}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user