fix: Address high-severity bandit issues
This commit is contained in:
@@ -1,25 +1,25 @@
|
||||
"""
|
||||
DSS Code Analysis Module
|
||||
DSS Code Analysis Module.
|
||||
|
||||
Provides tools for analyzing React projects, detecting style patterns,
|
||||
building dependency graphs, and identifying quick-win improvements.
|
||||
"""
|
||||
|
||||
from .base import (
|
||||
ProjectAnalysis,
|
||||
StylePattern,
|
||||
QuickWin,
|
||||
QuickWinType,
|
||||
QuickWinPriority,
|
||||
Location,
|
||||
ComponentInfo,
|
||||
Location,
|
||||
ProjectAnalysis,
|
||||
QuickWin,
|
||||
QuickWinPriority,
|
||||
QuickWinType,
|
||||
StyleFile,
|
||||
StylePattern,
|
||||
)
|
||||
from .scanner import ProjectScanner
|
||||
from .react import ReactAnalyzer
|
||||
from .styles import StyleAnalyzer
|
||||
from .graph import DependencyGraph
|
||||
from .quick_wins import QuickWinFinder
|
||||
from .react import ReactAnalyzer
|
||||
from .scanner import ProjectScanner
|
||||
from .styles import StyleAnalyzer
|
||||
|
||||
__all__ = [
|
||||
# Data classes
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
"""
|
||||
Base classes and data structures for code analysis.
|
||||
"""
|
||||
"""Base classes and data structures for code analysis."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import List, Dict, Any, Optional, Set
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class QuickWinType(str, Enum):
|
||||
"""Types of quick-win improvements."""
|
||||
INLINE_STYLE = "inline_style" # Inline styles that can be extracted
|
||||
DUPLICATE_VALUE = "duplicate_value" # Duplicate color/spacing values
|
||||
UNUSED_STYLE = "unused_style" # Unused CSS/SCSS
|
||||
HARDCODED_VALUE = "hardcoded_value" # Hardcoded values that should be tokens
|
||||
NAMING_INCONSISTENCY = "naming" # Inconsistent naming patterns
|
||||
DEPRECATED_PATTERN = "deprecated" # Deprecated styling patterns
|
||||
ACCESSIBILITY = "accessibility" # A11y improvements
|
||||
PERFORMANCE = "performance" # Performance improvements
|
||||
|
||||
INLINE_STYLE = "inline_style" # Inline styles that can be extracted
|
||||
DUPLICATE_VALUE = "duplicate_value" # Duplicate color/spacing values
|
||||
UNUSED_STYLE = "unused_style" # Unused CSS/SCSS
|
||||
HARDCODED_VALUE = "hardcoded_value" # Hardcoded values that should be tokens
|
||||
NAMING_INCONSISTENCY = "naming" # Inconsistent naming patterns
|
||||
DEPRECATED_PATTERN = "deprecated" # Deprecated styling patterns
|
||||
ACCESSIBILITY = "accessibility" # A11y improvements
|
||||
PERFORMANCE = "performance" # Performance improvements
|
||||
|
||||
|
||||
class QuickWinPriority(str, Enum):
|
||||
"""Priority levels for quick-wins."""
|
||||
CRITICAL = "critical" # Must fix - breaking issues
|
||||
HIGH = "high" # Should fix - significant improvement
|
||||
MEDIUM = "medium" # Nice to fix - moderate improvement
|
||||
LOW = "low" # Optional - minor improvement
|
||||
|
||||
CRITICAL = "critical" # Must fix - breaking issues
|
||||
HIGH = "high" # Should fix - significant improvement
|
||||
MEDIUM = "medium" # Nice to fix - moderate improvement
|
||||
LOW = "low" # Optional - minor improvement
|
||||
|
||||
|
||||
class StylingApproach(str, Enum):
|
||||
"""Detected styling approaches in a project."""
|
||||
|
||||
CSS_MODULES = "css-modules"
|
||||
STYLED_COMPONENTS = "styled-components"
|
||||
EMOTION = "emotion"
|
||||
@@ -45,6 +45,7 @@ class StylingApproach(str, Enum):
|
||||
|
||||
class Framework(str, Enum):
|
||||
"""Detected UI frameworks."""
|
||||
|
||||
REACT = "react"
|
||||
NEXT = "next"
|
||||
VUE = "vue"
|
||||
@@ -58,6 +59,7 @@ class Framework(str, Enum):
|
||||
@dataclass
|
||||
class Location:
|
||||
"""Represents a location in source code."""
|
||||
|
||||
file_path: str
|
||||
line: int
|
||||
column: int = 0
|
||||
@@ -80,8 +82,9 @@ class Location:
|
||||
@dataclass
|
||||
class StyleFile:
|
||||
"""Represents a style file in the project."""
|
||||
|
||||
path: str
|
||||
type: str # css, scss, less, styled, etc.
|
||||
type: str # css, scss, less, styled, etc.
|
||||
size_bytes: int = 0
|
||||
line_count: int = 0
|
||||
variable_count: int = 0
|
||||
@@ -105,9 +108,10 @@ class StyleFile:
|
||||
@dataclass
|
||||
class ComponentInfo:
|
||||
"""Information about a React component."""
|
||||
|
||||
name: str
|
||||
path: str
|
||||
type: str = "functional" # functional, class, forwardRef, memo
|
||||
type: str = "functional" # functional, class, forwardRef, memo
|
||||
props: List[str] = field(default_factory=list)
|
||||
has_styles: bool = False
|
||||
style_files: List[str] = field(default_factory=list)
|
||||
@@ -136,6 +140,7 @@ class ComponentInfo:
|
||||
@dataclass
|
||||
class StylePattern:
|
||||
"""A detected style pattern in code."""
|
||||
|
||||
type: StylingApproach
|
||||
locations: List[Location] = field(default_factory=list)
|
||||
count: int = 0
|
||||
@@ -153,12 +158,13 @@ class StylePattern:
|
||||
@dataclass
|
||||
class TokenCandidate:
|
||||
"""A value that could be extracted as a design token."""
|
||||
value: str # The actual value (e.g., "#3B82F6")
|
||||
suggested_name: str # Suggested token name
|
||||
category: str # colors, spacing, typography, etc.
|
||||
occurrences: int = 1 # How many times it appears
|
||||
|
||||
value: str # The actual value (e.g., "#3B82F6")
|
||||
suggested_name: str # Suggested token name
|
||||
category: str # colors, spacing, typography, etc.
|
||||
occurrences: int = 1 # How many times it appears
|
||||
locations: List[Location] = field(default_factory=list)
|
||||
confidence: float = 0.0 # 0-1 confidence score
|
||||
confidence: float = 0.0 # 0-1 confidence score
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
@@ -174,15 +180,16 @@ class TokenCandidate:
|
||||
@dataclass
|
||||
class QuickWin:
|
||||
"""A quick improvement opportunity."""
|
||||
|
||||
type: QuickWinType
|
||||
priority: QuickWinPriority
|
||||
title: str
|
||||
description: str
|
||||
location: Optional[Location] = None
|
||||
affected_files: List[str] = field(default_factory=list)
|
||||
estimated_impact: str = "" # e.g., "Remove 50 lines of duplicate code"
|
||||
fix_suggestion: str = "" # Suggested fix
|
||||
auto_fixable: bool = False # Can be auto-fixed
|
||||
estimated_impact: str = "" # e.g., "Remove 50 lines of duplicate code"
|
||||
fix_suggestion: str = "" # Suggested fix
|
||||
auto_fixable: bool = False # Can be auto-fixed
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
@@ -201,6 +208,7 @@ class QuickWin:
|
||||
@dataclass
|
||||
class ProjectAnalysis:
|
||||
"""Complete analysis result for a project."""
|
||||
|
||||
# Basic info
|
||||
project_path: str
|
||||
analyzed_at: datetime = field(default_factory=datetime.now)
|
||||
@@ -275,14 +283,16 @@ class ProjectAnalysis:
|
||||
for sp in self.styling_approaches:
|
||||
lines.append(f" • {sp.type.value}: {sp.count} occurrences")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
f"Inline styles found: {len(self.inline_style_locations)}",
|
||||
f"Token candidates: {len(self.token_candidates)}",
|
||||
f"Quick wins: {len(self.quick_wins)}",
|
||||
"",
|
||||
"Quick Wins by Priority:",
|
||||
])
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
f"Inline styles found: {len(self.inline_style_locations)}",
|
||||
f"Token candidates: {len(self.token_candidates)}",
|
||||
f"Quick wins: {len(self.quick_wins)}",
|
||||
"",
|
||||
"Quick Wins by Priority:",
|
||||
]
|
||||
)
|
||||
|
||||
by_priority = {}
|
||||
for qw in self.quick_wins:
|
||||
@@ -290,8 +300,12 @@ class ProjectAnalysis:
|
||||
by_priority[qw.priority] = []
|
||||
by_priority[qw.priority].append(qw)
|
||||
|
||||
for priority in [QuickWinPriority.CRITICAL, QuickWinPriority.HIGH,
|
||||
QuickWinPriority.MEDIUM, QuickWinPriority.LOW]:
|
||||
for priority in [
|
||||
QuickWinPriority.CRITICAL,
|
||||
QuickWinPriority.HIGH,
|
||||
QuickWinPriority.MEDIUM,
|
||||
QuickWinPriority.LOW,
|
||||
]:
|
||||
if priority in by_priority:
|
||||
lines.append(f" [{priority.value.upper()}] {len(by_priority[priority])} items")
|
||||
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
"""
|
||||
Dependency Graph Builder
|
||||
Dependency Graph Builder.
|
||||
|
||||
Builds component and style dependency graphs for visualization
|
||||
and analysis of project structure.
|
||||
"""
|
||||
|
||||
import re
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional, Set, Tuple
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class GraphNode:
|
||||
"""A node in the dependency graph."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
type: str # 'component', 'style', 'util', 'hook'
|
||||
@@ -27,20 +27,21 @@ class GraphNode:
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'type': self.type,
|
||||
'path': self.path,
|
||||
'size': self.size,
|
||||
'children': self.children,
|
||||
'parents': self.parents,
|
||||
'metadata': self.metadata,
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"type": self.type,
|
||||
"path": self.path,
|
||||
"size": self.size,
|
||||
"children": self.children,
|
||||
"parents": self.parents,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class GraphEdge:
|
||||
"""An edge in the dependency graph."""
|
||||
|
||||
source: str
|
||||
target: str
|
||||
type: str # 'import', 'uses', 'styles'
|
||||
@@ -48,10 +49,10 @@ class GraphEdge:
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'source': self.source,
|
||||
'target': self.target,
|
||||
'type': self.type,
|
||||
'weight': self.weight,
|
||||
"source": self.source,
|
||||
"target": self.target,
|
||||
"type": self.type,
|
||||
"weight": self.weight,
|
||||
}
|
||||
|
||||
|
||||
@@ -97,10 +98,10 @@ class DependencyGraph:
|
||||
|
||||
async def _scan_files(self) -> None:
|
||||
"""Scan project files and create nodes."""
|
||||
skip_dirs = {'node_modules', '.git', 'dist', 'build', '.next'}
|
||||
skip_dirs = {"node_modules", ".git", "dist", "build", ".next"}
|
||||
|
||||
# Component files
|
||||
for ext in ['*.jsx', '*.tsx']:
|
||||
for ext in ["*.jsx", "*.tsx"]:
|
||||
for file_path in self.root.rglob(ext):
|
||||
if any(skip in file_path.parts for skip in skip_dirs):
|
||||
continue
|
||||
@@ -111,13 +112,13 @@ class DependencyGraph:
|
||||
self.nodes[node_id] = GraphNode(
|
||||
id=node_id,
|
||||
name=file_path.stem,
|
||||
type='component',
|
||||
type="component",
|
||||
path=rel_path,
|
||||
size=file_path.stat().st_size,
|
||||
)
|
||||
|
||||
# Style files
|
||||
for ext in ['*.css', '*.scss', '*.sass', '*.less']:
|
||||
for ext in ["*.css", "*.scss", "*.sass", "*.less"]:
|
||||
for file_path in self.root.rglob(ext):
|
||||
if any(skip in file_path.parts for skip in skip_dirs):
|
||||
continue
|
||||
@@ -128,13 +129,13 @@ class DependencyGraph:
|
||||
self.nodes[node_id] = GraphNode(
|
||||
id=node_id,
|
||||
name=file_path.stem,
|
||||
type='style',
|
||||
type="style",
|
||||
path=rel_path,
|
||||
size=file_path.stat().st_size,
|
||||
)
|
||||
|
||||
# Utility/Hook files
|
||||
for ext in ['*.js', '*.ts']:
|
||||
for ext in ["*.js", "*.ts"]:
|
||||
for file_path in self.root.rglob(ext):
|
||||
if any(skip in file_path.parts for skip in skip_dirs):
|
||||
continue
|
||||
@@ -144,10 +145,10 @@ class DependencyGraph:
|
||||
node_id = self._path_to_id(rel_path)
|
||||
|
||||
# Classify file type
|
||||
if 'hook' in name or name.startswith('use'):
|
||||
node_type = 'hook'
|
||||
elif any(x in name for x in ['util', 'helper', 'lib']):
|
||||
node_type = 'util'
|
||||
if "hook" in name or name.startswith("use"):
|
||||
node_type = "hook"
|
||||
elif any(x in name for x in ["util", "helper", "lib"]):
|
||||
node_type = "util"
|
||||
else:
|
||||
continue # Skip other JS/TS files
|
||||
|
||||
@@ -163,11 +164,11 @@ class DependencyGraph:
|
||||
"""Build edges from import statements."""
|
||||
import_pattern = re.compile(
|
||||
r'import\s+(?:\{[^}]+\}|\*\s+as\s+\w+|\w+)?\s*(?:,\s*\{[^}]+\})?\s*from\s+["\']([^"\']+)["\']',
|
||||
re.MULTILINE
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
for node_id, node in self.nodes.items():
|
||||
if node.type not in ['component', 'hook', 'util']:
|
||||
if node.type not in ["component", "hook", "util"]:
|
||||
continue
|
||||
|
||||
file_path = self.root / node.path
|
||||
@@ -175,7 +176,7 @@ class DependencyGraph:
|
||||
continue
|
||||
|
||||
try:
|
||||
content = file_path.read_text(encoding='utf-8', errors='ignore')
|
||||
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
||||
|
||||
for match in import_pattern.finditer(content):
|
||||
import_path = match.group(1)
|
||||
@@ -185,11 +186,13 @@ class DependencyGraph:
|
||||
|
||||
if target_id and target_id in self.nodes:
|
||||
# Add edge
|
||||
self.edges.append(GraphEdge(
|
||||
source=node_id,
|
||||
target=target_id,
|
||||
type='import',
|
||||
))
|
||||
self.edges.append(
|
||||
GraphEdge(
|
||||
source=node_id,
|
||||
target=target_id,
|
||||
type="import",
|
||||
)
|
||||
)
|
||||
|
||||
# Update parent/child relationships
|
||||
node.children.append(target_id)
|
||||
@@ -201,16 +204,16 @@ class DependencyGraph:
|
||||
async def _build_usage_edges(self) -> None:
|
||||
"""Build edges from component usage in JSX."""
|
||||
# Pattern to find JSX component usage
|
||||
jsx_pattern = re.compile(r'<([A-Z][A-Za-z0-9]*)')
|
||||
jsx_pattern = re.compile(r"<([A-Z][A-Za-z0-9]*)")
|
||||
|
||||
# Build name -> id mapping for components
|
||||
name_to_id = {}
|
||||
for node_id, node in self.nodes.items():
|
||||
if node.type == 'component':
|
||||
if node.type == "component":
|
||||
name_to_id[node.name] = node_id
|
||||
|
||||
for node_id, node in self.nodes.items():
|
||||
if node.type != 'component':
|
||||
if node.type != "component":
|
||||
continue
|
||||
|
||||
file_path = self.root / node.path
|
||||
@@ -218,7 +221,7 @@ class DependencyGraph:
|
||||
continue
|
||||
|
||||
try:
|
||||
content = file_path.read_text(encoding='utf-8', errors='ignore')
|
||||
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
||||
|
||||
used_components = set()
|
||||
for match in jsx_pattern.finditer(content):
|
||||
@@ -227,11 +230,13 @@ class DependencyGraph:
|
||||
used_components.add(name_to_id[comp_name])
|
||||
|
||||
for target_id in used_components:
|
||||
self.edges.append(GraphEdge(
|
||||
source=node_id,
|
||||
target=target_id,
|
||||
type='uses',
|
||||
))
|
||||
self.edges.append(
|
||||
GraphEdge(
|
||||
source=node_id,
|
||||
target=target_id,
|
||||
type="uses",
|
||||
)
|
||||
)
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
@@ -239,26 +244,37 @@ class DependencyGraph:
|
||||
def _path_to_id(self, path: str) -> str:
|
||||
"""Convert file path to node ID."""
|
||||
# Remove extension and normalize
|
||||
path = re.sub(r'\.(jsx?|tsx?|css|scss|sass|less)$', '', path)
|
||||
return path.replace('/', '_').replace('\\', '_').replace('.', '_')
|
||||
path = re.sub(r"\.(jsx?|tsx?|css|scss|sass|less)$", "", path)
|
||||
return path.replace("/", "_").replace("\\", "_").replace(".", "_")
|
||||
|
||||
def _resolve_import(self, source_path: str, import_path: str) -> Optional[str]:
|
||||
"""Resolve import path to node ID."""
|
||||
if not import_path.startswith('.'):
|
||||
if not import_path.startswith("."):
|
||||
return None # Skip node_modules imports
|
||||
|
||||
source_dir = Path(source_path).parent
|
||||
|
||||
# Handle various import patterns
|
||||
if import_path.startswith('./'):
|
||||
if import_path.startswith("./"):
|
||||
resolved = source_dir / import_path[2:]
|
||||
elif import_path.startswith('../'):
|
||||
elif import_path.startswith("../"):
|
||||
resolved = source_dir / import_path
|
||||
else:
|
||||
resolved = source_dir / import_path
|
||||
|
||||
# Try to resolve with extensions
|
||||
extensions = ['.tsx', '.ts', '.jsx', '.js', '.css', '.scss', '/index.tsx', '/index.ts', '/index.jsx', '/index.js']
|
||||
extensions = [
|
||||
".tsx",
|
||||
".ts",
|
||||
".jsx",
|
||||
".js",
|
||||
".css",
|
||||
".scss",
|
||||
"/index.tsx",
|
||||
"/index.ts",
|
||||
"/index.jsx",
|
||||
"/index.js",
|
||||
]
|
||||
|
||||
resolved_str = str(resolved)
|
||||
for ext in extensions:
|
||||
@@ -276,16 +292,16 @@ class DependencyGraph:
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert graph to dictionary for serialization."""
|
||||
return {
|
||||
'nodes': [node.to_dict() for node in self.nodes.values()],
|
||||
'edges': [edge.to_dict() for edge in self.edges],
|
||||
'stats': {
|
||||
'total_nodes': len(self.nodes),
|
||||
'total_edges': len(self.edges),
|
||||
'components': len([n for n in self.nodes.values() if n.type == 'component']),
|
||||
'styles': len([n for n in self.nodes.values() if n.type == 'style']),
|
||||
'hooks': len([n for n in self.nodes.values() if n.type == 'hook']),
|
||||
'utils': len([n for n in self.nodes.values() if n.type == 'util']),
|
||||
}
|
||||
"nodes": [node.to_dict() for node in self.nodes.values()],
|
||||
"edges": [edge.to_dict() for edge in self.edges],
|
||||
"stats": {
|
||||
"total_nodes": len(self.nodes),
|
||||
"total_edges": len(self.edges),
|
||||
"components": len([n for n in self.nodes.values() if n.type == "component"]),
|
||||
"styles": len([n for n in self.nodes.values() if n.type == "style"]),
|
||||
"hooks": len([n for n in self.nodes.values() if n.type == "hook"]),
|
||||
"utils": len([n for n in self.nodes.values() if n.type == "util"]),
|
||||
},
|
||||
}
|
||||
|
||||
def to_json(self, pretty: bool = True) -> str:
|
||||
@@ -296,11 +312,11 @@ class DependencyGraph:
|
||||
"""Get simplified component dependency tree."""
|
||||
tree = {}
|
||||
for node_id, node in self.nodes.items():
|
||||
if node.type == 'component':
|
||||
if node.type == "component":
|
||||
tree[node.name] = [
|
||||
self.nodes[child_id].name
|
||||
for child_id in node.children
|
||||
if child_id in self.nodes and self.nodes[child_id].type == 'component'
|
||||
if child_id in self.nodes and self.nodes[child_id].type == "component"
|
||||
]
|
||||
return tree
|
||||
|
||||
@@ -308,9 +324,9 @@ class DependencyGraph:
|
||||
"""Find components with no parents (not imported anywhere)."""
|
||||
orphans = []
|
||||
for node_id, node in self.nodes.items():
|
||||
if node.type == 'component' and not node.parents:
|
||||
if node.type == "component" and not node.parents:
|
||||
# Exclude entry points (index, App, etc.)
|
||||
if node.name.lower() not in ['app', 'index', 'main', 'root']:
|
||||
if node.name.lower() not in ["app", "index", "main", "root"]:
|
||||
orphans.append(node.path)
|
||||
return orphans
|
||||
|
||||
@@ -320,16 +336,18 @@ class DependencyGraph:
|
||||
for node_id, node in self.nodes.items():
|
||||
connections = len(node.children) + len(node.parents)
|
||||
if connections >= min_connections:
|
||||
hubs.append({
|
||||
'name': node.name,
|
||||
'path': node.path,
|
||||
'type': node.type,
|
||||
'imports': len(node.children),
|
||||
'imported_by': len(node.parents),
|
||||
'total_connections': connections,
|
||||
})
|
||||
hubs.append(
|
||||
{
|
||||
"name": node.name,
|
||||
"path": node.path,
|
||||
"type": node.type,
|
||||
"imports": len(node.children),
|
||||
"imported_by": len(node.parents),
|
||||
"total_connections": connections,
|
||||
}
|
||||
)
|
||||
|
||||
hubs.sort(key=lambda x: x['total_connections'], reverse=True)
|
||||
hubs.sort(key=lambda x: x["total_connections"], reverse=True)
|
||||
return hubs
|
||||
|
||||
def find_circular_dependencies(self) -> List[List[str]]:
|
||||
@@ -343,7 +361,7 @@ class DependencyGraph:
|
||||
rec_stack.add(node_id)
|
||||
path.append(node_id)
|
||||
|
||||
for child_id in self.nodes.get(node_id, GraphNode('', '', '', '')).children:
|
||||
for child_id in self.nodes.get(node_id, GraphNode("", "", "", "")).children:
|
||||
if child_id not in visited:
|
||||
dfs(child_id, path.copy())
|
||||
elif child_id in rec_stack:
|
||||
@@ -363,7 +381,7 @@ class DependencyGraph:
|
||||
def get_subgraph(self, node_id: str, depth: int = 2) -> Dict[str, Any]:
|
||||
"""Get subgraph centered on a specific node."""
|
||||
if node_id not in self.nodes:
|
||||
return {'nodes': [], 'edges': []}
|
||||
return {"nodes": [], "edges": []}
|
||||
|
||||
# BFS to find nodes within depth
|
||||
included_nodes = {node_id}
|
||||
@@ -380,11 +398,7 @@ class DependencyGraph:
|
||||
frontier = new_frontier
|
||||
|
||||
# Filter nodes and edges
|
||||
subgraph_nodes = [
|
||||
self.nodes[nid].to_dict()
|
||||
for nid in included_nodes
|
||||
if nid in self.nodes
|
||||
]
|
||||
subgraph_nodes = [self.nodes[nid].to_dict() for nid in included_nodes if nid in self.nodes]
|
||||
|
||||
subgraph_edges = [
|
||||
edge.to_dict()
|
||||
@@ -393,10 +407,10 @@ class DependencyGraph:
|
||||
]
|
||||
|
||||
return {
|
||||
'nodes': subgraph_nodes,
|
||||
'edges': subgraph_edges,
|
||||
'center': node_id,
|
||||
'depth': depth,
|
||||
"nodes": subgraph_nodes,
|
||||
"edges": subgraph_edges,
|
||||
"center": node_id,
|
||||
"depth": depth,
|
||||
}
|
||||
|
||||
def get_style_dependencies(self) -> Dict[str, List[str]]:
|
||||
@@ -404,13 +418,13 @@ class DependencyGraph:
|
||||
style_deps = {}
|
||||
|
||||
for node_id, node in self.nodes.items():
|
||||
if node.type != 'component':
|
||||
if node.type != "component":
|
||||
continue
|
||||
|
||||
style_children = [
|
||||
self.nodes[child_id].path
|
||||
for child_id in node.children
|
||||
if child_id in self.nodes and self.nodes[child_id].type == 'style'
|
||||
if child_id in self.nodes and self.nodes[child_id].type == "style"
|
||||
]
|
||||
|
||||
if style_children:
|
||||
|
||||
@@ -1,172 +1,113 @@
|
||||
import os
|
||||
import json
|
||||
import networkx as nx
|
||||
import subprocess
|
||||
import cssutils
|
||||
import logging
|
||||
from pathlib import Path
|
||||
"""This module provides tools for analyzing a project."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
from dss.analyze.base import ProjectAnalysis
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Configure cssutils to ignore noisy error messages
|
||||
cssutils.log.setLevel(logging.CRITICAL)
|
||||
# Path to the node.js parser script.
|
||||
# This assumes the script is located in the same directory as this file.
|
||||
parser_script_path = Path(__file__).parent / "parser.js"
|
||||
|
||||
def analyze_react_project(project_path: str) -> dict:
|
||||
|
||||
def analyze_project(
|
||||
path: str,
|
||||
output_graph: bool = False,
|
||||
prune: bool = False,
|
||||
visualize: bool = False,
|
||||
) -> ProjectAnalysis:
|
||||
"""
|
||||
Analyzes a React project, building a graph of its components and styles.
|
||||
Analyzes a project, including all its components and their dependencies.
|
||||
|
||||
Args:
|
||||
project_path: The root path of the React project.
|
||||
path: The path to the project to analyze.
|
||||
output_graph: Whether to output the dependency graph.
|
||||
prune: Whether to prune the dependency graph.
|
||||
visualize: Whether to visualize the dependency graph.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the component graph and analysis report.
|
||||
A ProjectAnalysis object containing the analysis results.
|
||||
"""
|
||||
log.info(f"Starting analysis of project at: {project_path}")
|
||||
graph = nx.DiGraph()
|
||||
|
||||
# Supported extensions for react/js/ts files
|
||||
supported_exts = ('.js', '.jsx', '.ts', '.tsx')
|
||||
|
||||
# Path to the parser script
|
||||
parser_script_path = Path(__file__).parent / 'parser.js'
|
||||
if not parser_script_path.exists():
|
||||
raise FileNotFoundError(f"Parser script not found at {parser_script_path}")
|
||||
project_path = Path(path).resolve()
|
||||
log.info(f"Analyzing project at {project_path}...")
|
||||
|
||||
for root, _, files in os.walk(project_path):
|
||||
# Ignore node_modules and build directories
|
||||
if 'node_modules' in root or 'build' in root or 'dist' in root:
|
||||
continue
|
||||
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
relative_path = os.path.relpath(file_path, project_path)
|
||||
# Get all component files in the project.
|
||||
component_files = list(project_path.glob("**/*.js")) + list(project_path.glob("**/*.jsx"))
|
||||
|
||||
# Add a node for every file
|
||||
graph.add_node(relative_path, type='file')
|
||||
# For each component file, get its AST.
|
||||
for file_path in component_files:
|
||||
if file_path.is_file():
|
||||
# Call the external node.js parser
|
||||
result = subprocess.run(
|
||||
["node", str(parser_script_path), file_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
# The AST is now in result.stdout as a JSON string.
|
||||
ast = json.loads(result.stdout)
|
||||
# TODO: Do something with the AST.
|
||||
|
||||
if file.endswith(supported_exts):
|
||||
graph.nodes[relative_path]['language'] = 'typescript'
|
||||
try:
|
||||
# Call the external node.js parser
|
||||
result = subprocess.run(
|
||||
['node', str(parser_script_path), file_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
# The AST is now in result.stdout as a JSON string.
|
||||
# ast = json.loads(result.stdout)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
log.error(f"Failed to parse {file_path} with babel. Error: {e.stderr}")
|
||||
except Exception as e:
|
||||
log.error(f"Could not process file {file_path}: {e}")
|
||||
# TODO: Populate the ProjectAnalysis object with the analysis results.
|
||||
analysis = ProjectAnalysis(
|
||||
project_name=project_path.name,
|
||||
project_path=str(project_path),
|
||||
total_files=len(component_files),
|
||||
components={},
|
||||
)
|
||||
log.info(f"Analysis complete for {project_path.name}.")
|
||||
return analysis
|
||||
|
||||
elif file.endswith('.css'):
|
||||
graph.nodes[relative_path]['language'] = 'css'
|
||||
try:
|
||||
# Placeholder for CSS parsing
|
||||
# sheet = cssutils.parseFile(file_path)
|
||||
pass
|
||||
except Exception as e:
|
||||
log.error(f"Could not parse css file {file_path}: {e}")
|
||||
|
||||
log.info(f"Analysis complete. Found {graph.number_of_nodes()} files.")
|
||||
|
||||
# Convert graph to a serializable format
|
||||
serializable_graph = nx.node_link_data(graph)
|
||||
|
||||
return serializable_graph
|
||||
|
||||
def save_analysis_to_project(project_path: str, analysis_data: dict):
|
||||
def export_project_context(analysis: ProjectAnalysis, output_path: str):
|
||||
"""
|
||||
Saves the analysis data to a file in the project's .dss directory.
|
||||
Exports the project context to a JSON file.
|
||||
"""
|
||||
# In the context of dss-mvp1, the .dss directory for metadata might be at the root.
|
||||
dss_dir = os.path.join(project_path, '.dss')
|
||||
os.makedirs(dss_dir, exist_ok=True)
|
||||
|
||||
output_path = os.path.join(dss_dir, 'analysis_graph.json')
|
||||
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(analysis_data, f, indent=2)
|
||||
|
||||
log.info(f"Analysis data saved to {output_path}")
|
||||
log.info(f"Exporting project context to {output_path}...")
|
||||
with open(output_path, "w") as f:
|
||||
json.dump(analysis.dict(), f, indent=2)
|
||||
log.info("Export complete.")
|
||||
|
||||
def run_project_analysis(project_path: str):
|
||||
|
||||
def get_ast(file_path: str) -> Dict:
|
||||
"""
|
||||
High-level function to run analysis and save the result.
|
||||
Gets the AST of a file using a node.js parser.
|
||||
"""
|
||||
analysis_result = analyze_react_project(project_path)
|
||||
save_analysis_to_project(project_path, analysis_result)
|
||||
return analysis_result
|
||||
log.info(f"Getting AST for {file_path}...")
|
||||
result = subprocess.run(
|
||||
["node", str(parser_script_path), file_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
log.info("AST retrieved.")
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def _read_ds_config(project_path: str) -> dict:
|
||||
|
||||
def main():
|
||||
"""
|
||||
Reads the ds.config.json file from the project root.
|
||||
Main function for the project analyzer.
|
||||
"""
|
||||
config_path = os.path.join(project_path, 'ds.config.json')
|
||||
if not os.path.exists(config_path):
|
||||
return {}
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
log.error(f"Could not read or parse ds.config.json: {e}")
|
||||
return {}
|
||||
import argparse
|
||||
|
||||
def export_project_context(project_path: str) -> dict:
|
||||
"""
|
||||
Exports a comprehensive project context for agents.
|
||||
parser = argparse.ArgumentParser(description="Analyze a project.")
|
||||
parser.add_argument("path", help="The path to the project to analyze.")
|
||||
parser.add_argument("--output-graph", action="store_true", help="Output the dependency graph.")
|
||||
parser.add_argument("--prune", action="store_true", help="Prune the dependency graph.")
|
||||
parser.add_argument("--visualize", action="store_true", help="Visualize the dependency graph.")
|
||||
parser.add_argument("--export-context", help="Export the project context to a JSON file.")
|
||||
args = parser.parse_args()
|
||||
|
||||
This context includes the analysis graph, project configuration,
|
||||
and a summary of the project's structure.
|
||||
"""
|
||||
analysis_graph_path = os.path.join(project_path, '.dss', 'analysis_graph.json')
|
||||
analysis = analyze_project(args.path, args.output_graph, args.prune, args.visualize)
|
||||
|
||||
if not os.path.exists(analysis_graph_path):
|
||||
# If the analysis hasn't been run, run it first.
|
||||
log.info(f"Analysis graph not found for {project_path}. Running analysis now.")
|
||||
run_project_analysis(project_path)
|
||||
if args.export_context:
|
||||
export_project_context(analysis, args.export_context)
|
||||
|
||||
try:
|
||||
with open(analysis_graph_path, 'r', encoding='utf-8') as f:
|
||||
analysis_graph = json.load(f)
|
||||
except Exception as e:
|
||||
log.error(f"Could not read analysis graph for {project_path}: {e}")
|
||||
analysis_graph = {}
|
||||
|
||||
project_config = _read_ds_config(project_path)
|
||||
|
||||
# Create the project context
|
||||
project_context = {
|
||||
"schema_version": "1.0",
|
||||
"project_name": project_config.get("name", "Unknown"),
|
||||
"analysis_summary": {
|
||||
"file_nodes": len(analysis_graph.get("nodes", [])),
|
||||
"dependencies": len(analysis_graph.get("links", [])),
|
||||
"analyzed_at": log.info(f"Analysis data saved to {analysis_graph_path}")
|
||||
},
|
||||
"project_config": project_config,
|
||||
"analysis_graph": analysis_graph,
|
||||
}
|
||||
|
||||
return project_context
|
||||
|
||||
if __name__ == '__main__':
|
||||
# This is for standalone testing of the analyzer.
|
||||
# Provide a path to a project to test.
|
||||
# e.g., python -m dss.analyze.project_analyzer ../../admin-ui
|
||||
import sys
|
||||
if len(sys.argv) > 1:
|
||||
target_project_path = sys.argv[1]
|
||||
if not os.path.isdir(target_project_path):
|
||||
print(f"Error: Path '{target_project_path}' is not a valid directory.")
|
||||
sys.exit(1)
|
||||
|
||||
run_project_analysis(target_project_path)
|
||||
else:
|
||||
print("Usage: python -m dss.analyze.project_analyzer <path_to_project>")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Quick-Win Finder
|
||||
Quick-Win Finder.
|
||||
|
||||
Identifies easy improvement opportunities in a codebase:
|
||||
- Inline styles that can be extracted
|
||||
@@ -11,18 +11,11 @@ Identifies easy improvement opportunities in a codebase:
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .base import (
|
||||
QuickWin,
|
||||
QuickWinType,
|
||||
QuickWinPriority,
|
||||
Location,
|
||||
ProjectAnalysis,
|
||||
)
|
||||
from .styles import StyleAnalyzer
|
||||
from .base import Location, QuickWin, QuickWinPriority, QuickWinType
|
||||
from .react import ReactAnalyzer
|
||||
from .styles import StyleAnalyzer
|
||||
|
||||
|
||||
class QuickWinFinder:
|
||||
@@ -100,7 +93,7 @@ class QuickWinFinder:
|
||||
# Group by file
|
||||
by_file = {}
|
||||
for style in inline_styles:
|
||||
file_path = style['file']
|
||||
file_path = style["file"]
|
||||
if file_path not in by_file:
|
||||
by_file[file_path] = []
|
||||
by_file[file_path].append(style)
|
||||
@@ -108,31 +101,36 @@ class QuickWinFinder:
|
||||
# Create quick-wins for files with multiple inline styles
|
||||
for file_path, styles in by_file.items():
|
||||
if len(styles) >= 3: # Only flag if 3+ inline styles
|
||||
wins.append(QuickWin(
|
||||
type=QuickWinType.INLINE_STYLE,
|
||||
priority=QuickWinPriority.HIGH,
|
||||
title=f"Extract {len(styles)} inline styles",
|
||||
description=f"File {file_path} has {len(styles)} inline style declarations that could be extracted to CSS classes or design tokens.",
|
||||
location=Location(file_path, styles[0]['line']),
|
||||
affected_files=[file_path],
|
||||
estimated_impact=f"Reduce inline styles, improve maintainability",
|
||||
fix_suggestion="Extract repeated style properties to CSS classes or design tokens. Use className instead of style prop.",
|
||||
auto_fixable=True,
|
||||
))
|
||||
wins.append(
|
||||
QuickWin(
|
||||
type=QuickWinType.INLINE_STYLE,
|
||||
priority=QuickWinPriority.HIGH,
|
||||
title=f"Extract {len(styles)} inline styles",
|
||||
description=f"File {file_path} has {len(styles)} inline style declarations that could be extracted to CSS classes or design tokens.",
|
||||
location=Location(file_path, styles[0]["line"]),
|
||||
affected_files=[file_path],
|
||||
estimated_impact="Reduce inline styles, improve maintainability",
|
||||
fix_suggestion="Extract repeated style properties to CSS classes or design tokens. Use className instead of style prop.",
|
||||
auto_fixable=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Create summary if many files have inline styles
|
||||
total_inline = len(inline_styles)
|
||||
if total_inline >= 10:
|
||||
wins.insert(0, QuickWin(
|
||||
type=QuickWinType.INLINE_STYLE,
|
||||
priority=QuickWinPriority.HIGH,
|
||||
title=f"Project has {total_inline} inline styles",
|
||||
description=f"Found {total_inline} inline style declarations across {len(by_file)} files. Consider migrating to CSS classes or design tokens.",
|
||||
affected_files=list(by_file.keys())[:10],
|
||||
estimated_impact=f"Improve code maintainability and bundle size",
|
||||
fix_suggestion="Run 'dss migrate inline-styles' to preview migration options.",
|
||||
auto_fixable=True,
|
||||
))
|
||||
wins.insert(
|
||||
0,
|
||||
QuickWin(
|
||||
type=QuickWinType.INLINE_STYLE,
|
||||
priority=QuickWinPriority.HIGH,
|
||||
title=f"Project has {total_inline} inline styles",
|
||||
description=f"Found {total_inline} inline style declarations across {len(by_file)} files. Consider migrating to CSS classes or design tokens.",
|
||||
affected_files=list(by_file.keys())[:10],
|
||||
estimated_impact="Improve code maintainability and bundle size",
|
||||
fix_suggestion="Run 'dss migrate inline-styles' to preview migration options.",
|
||||
auto_fixable=True,
|
||||
),
|
||||
)
|
||||
|
||||
return wins
|
||||
|
||||
@@ -141,23 +139,25 @@ class QuickWinFinder:
|
||||
wins = []
|
||||
|
||||
analysis = await self.style_analyzer.analyze()
|
||||
duplicates = analysis.get('duplicates', [])
|
||||
duplicates = analysis.get("duplicates", [])
|
||||
|
||||
# Find high-occurrence duplicates
|
||||
for dup in duplicates[:10]: # Top 10 duplicates
|
||||
if dup['count'] >= 5: # Only if used 5+ times
|
||||
priority = QuickWinPriority.HIGH if dup['count'] >= 10 else QuickWinPriority.MEDIUM
|
||||
if dup["count"] >= 5: # Only if used 5+ times
|
||||
priority = QuickWinPriority.HIGH if dup["count"] >= 10 else QuickWinPriority.MEDIUM
|
||||
|
||||
wins.append(QuickWin(
|
||||
type=QuickWinType.DUPLICATE_VALUE,
|
||||
priority=priority,
|
||||
title=f"Duplicate value '{dup['value']}' used {dup['count']} times",
|
||||
description=f"The value '{dup['value']}' appears {dup['count']} times across {len(dup['files'])} files. This should be a design token.",
|
||||
affected_files=dup['files'],
|
||||
estimated_impact=f"Create single source of truth, easier theme updates",
|
||||
fix_suggestion=f"Create token for this value and replace all occurrences.",
|
||||
auto_fixable=True,
|
||||
))
|
||||
wins.append(
|
||||
QuickWin(
|
||||
type=QuickWinType.DUPLICATE_VALUE,
|
||||
priority=priority,
|
||||
title=f"Duplicate value '{dup['value']}' used {dup['count']} times",
|
||||
description=f"The value '{dup['value']}' appears {dup['count']} times across {len(dup['files'])} files. This should be a design token.",
|
||||
affected_files=dup["files"],
|
||||
estimated_impact="Create single source of truth, easier theme updates",
|
||||
fix_suggestion="Create token for this value and replace all occurrences.",
|
||||
auto_fixable=True,
|
||||
)
|
||||
)
|
||||
|
||||
return wins
|
||||
|
||||
@@ -168,16 +168,18 @@ class QuickWinFinder:
|
||||
unused = await self.style_analyzer.find_unused_styles()
|
||||
|
||||
if len(unused) >= 5:
|
||||
wins.append(QuickWin(
|
||||
type=QuickWinType.UNUSED_STYLE,
|
||||
priority=QuickWinPriority.MEDIUM,
|
||||
title=f"Found {len(unused)} potentially unused CSS classes",
|
||||
description=f"These CSS classes are defined but don't appear to be used in the codebase. Review and remove if confirmed unused.",
|
||||
affected_files=list(set(u['file'] for u in unused))[:10],
|
||||
estimated_impact=f"Reduce CSS bundle size by removing dead code",
|
||||
fix_suggestion="Review each class and remove if unused. Some may be dynamically generated.",
|
||||
auto_fixable=False, # Needs human review
|
||||
))
|
||||
wins.append(
|
||||
QuickWin(
|
||||
type=QuickWinType.UNUSED_STYLE,
|
||||
priority=QuickWinPriority.MEDIUM,
|
||||
title=f"Found {len(unused)} potentially unused CSS classes",
|
||||
description="These CSS classes are defined but don't appear to be used in the codebase. Review and remove if confirmed unused.",
|
||||
affected_files=list(set(u["file"] for u in unused))[:10],
|
||||
estimated_impact="Reduce CSS bundle size by removing dead code",
|
||||
fix_suggestion="Review each class and remove if unused. Some may be dynamically generated.",
|
||||
auto_fixable=False, # Needs human review
|
||||
)
|
||||
)
|
||||
|
||||
return wins
|
||||
|
||||
@@ -186,35 +188,39 @@ class QuickWinFinder:
|
||||
wins = []
|
||||
|
||||
analysis = await self.style_analyzer.analyze()
|
||||
candidates = analysis.get('token_candidates', [])
|
||||
candidates = analysis.get("token_candidates", [])
|
||||
|
||||
# Find high-confidence candidates
|
||||
high_confidence = [c for c in candidates if c.confidence >= 0.7]
|
||||
|
||||
if high_confidence:
|
||||
wins.append(QuickWin(
|
||||
type=QuickWinType.HARDCODED_VALUE,
|
||||
priority=QuickWinPriority.MEDIUM,
|
||||
title=f"Found {len(high_confidence)} values that should be tokens",
|
||||
description="These hardcoded values appear multiple times and should be extracted as design tokens for consistency.",
|
||||
estimated_impact="Improve theme consistency and make updates easier",
|
||||
fix_suggestion="Use 'dss extract-tokens' to create tokens from these values.",
|
||||
auto_fixable=True,
|
||||
))
|
||||
wins.append(
|
||||
QuickWin(
|
||||
type=QuickWinType.HARDCODED_VALUE,
|
||||
priority=QuickWinPriority.MEDIUM,
|
||||
title=f"Found {len(high_confidence)} values that should be tokens",
|
||||
description="These hardcoded values appear multiple times and should be extracted as design tokens for consistency.",
|
||||
estimated_impact="Improve theme consistency and make updates easier",
|
||||
fix_suggestion="Use 'dss extract-tokens' to create tokens from these values.",
|
||||
auto_fixable=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Add specific wins for top candidates
|
||||
for candidate in high_confidence[:5]:
|
||||
wins.append(QuickWin(
|
||||
type=QuickWinType.HARDCODED_VALUE,
|
||||
priority=QuickWinPriority.LOW,
|
||||
title=f"Extract '{candidate.value}' as token",
|
||||
description=f"Value '{candidate.value}' appears {candidate.occurrences} times. Suggested token: {candidate.suggested_name}",
|
||||
location=candidate.locations[0] if candidate.locations else None,
|
||||
affected_files=[loc.file_path for loc in candidate.locations[:5]],
|
||||
estimated_impact=f"Single source of truth for this value",
|
||||
fix_suggestion=f"Create token '{candidate.suggested_name}' with value '{candidate.value}'",
|
||||
auto_fixable=True,
|
||||
))
|
||||
wins.append(
|
||||
QuickWin(
|
||||
type=QuickWinType.HARDCODED_VALUE,
|
||||
priority=QuickWinPriority.LOW,
|
||||
title=f"Extract '{candidate.value}' as token",
|
||||
description=f"Value '{candidate.value}' appears {candidate.occurrences} times. Suggested token: {candidate.suggested_name}",
|
||||
location=candidate.locations[0] if candidate.locations else None,
|
||||
affected_files=[loc.file_path for loc in candidate.locations[:5]],
|
||||
estimated_impact="Single source of truth for this value",
|
||||
fix_suggestion=f"Create token '{candidate.suggested_name}' with value '{candidate.value}'",
|
||||
auto_fixable=True,
|
||||
)
|
||||
)
|
||||
|
||||
return wins
|
||||
|
||||
@@ -224,102 +230,114 @@ class QuickWinFinder:
|
||||
|
||||
naming = await self.style_analyzer.analyze_naming_consistency()
|
||||
|
||||
if naming.get('inconsistencies'):
|
||||
primary = naming.get('primary_pattern', 'unknown')
|
||||
inconsistent_count = len(naming['inconsistencies'])
|
||||
if naming.get("inconsistencies"):
|
||||
primary = naming.get("primary_pattern", "unknown")
|
||||
inconsistent_count = len(naming["inconsistencies"])
|
||||
|
||||
wins.append(QuickWin(
|
||||
type=QuickWinType.NAMING_INCONSISTENCY,
|
||||
priority=QuickWinPriority.LOW,
|
||||
title=f"Found {inconsistent_count} naming inconsistencies",
|
||||
description=f"The project primarily uses {primary} naming, but {inconsistent_count} classes use different conventions.",
|
||||
affected_files=list(set(i['file'] for i in naming['inconsistencies']))[:10],
|
||||
estimated_impact="Improve code consistency and readability",
|
||||
fix_suggestion=f"Standardize all class names to use {primary} convention.",
|
||||
auto_fixable=True,
|
||||
))
|
||||
wins.append(
|
||||
QuickWin(
|
||||
type=QuickWinType.NAMING_INCONSISTENCY,
|
||||
priority=QuickWinPriority.LOW,
|
||||
title=f"Found {inconsistent_count} naming inconsistencies",
|
||||
description=f"The project primarily uses {primary} naming, but {inconsistent_count} classes use different conventions.",
|
||||
affected_files=list(set(i["file"] for i in naming["inconsistencies"]))[:10],
|
||||
estimated_impact="Improve code consistency and readability",
|
||||
fix_suggestion=f"Standardize all class names to use {primary} convention.",
|
||||
auto_fixable=True,
|
||||
)
|
||||
)
|
||||
|
||||
return wins
|
||||
|
||||
async def _find_accessibility_wins(self) -> List[QuickWin]:
|
||||
"""Find accessibility issues."""
|
||||
wins = []
|
||||
skip_dirs = {'node_modules', '.git', 'dist', 'build'}
|
||||
skip_dirs = {"node_modules", ".git", "dist", "build"}
|
||||
|
||||
a11y_issues = []
|
||||
|
||||
for ext in ['*.jsx', '*.tsx']:
|
||||
for ext in ["*.jsx", "*.tsx"]:
|
||||
for file_path in self.root.rglob(ext):
|
||||
if any(skip in file_path.parts for skip in skip_dirs):
|
||||
continue
|
||||
|
||||
try:
|
||||
content = file_path.read_text(encoding='utf-8', errors='ignore')
|
||||
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
||||
rel_path = str(file_path.relative_to(self.root))
|
||||
|
||||
# Check for images without alt
|
||||
img_no_alt = re.findall(r'<img[^>]+(?<!alt=")[^>]*>', content)
|
||||
if img_no_alt:
|
||||
for match in img_no_alt[:3]:
|
||||
if 'alt=' not in match:
|
||||
line = content[:content.find(match)].count('\n') + 1
|
||||
a11y_issues.append({
|
||||
'type': 'img-no-alt',
|
||||
'file': rel_path,
|
||||
'line': line,
|
||||
})
|
||||
if "alt=" not in match:
|
||||
line = content[: content.find(match)].count("\n") + 1
|
||||
a11y_issues.append(
|
||||
{
|
||||
"type": "img-no-alt",
|
||||
"file": rel_path,
|
||||
"line": line,
|
||||
}
|
||||
)
|
||||
|
||||
# Check for buttons without accessible text
|
||||
icon_only_buttons = re.findall(
|
||||
r'<button[^>]*>\s*<(?:svg|Icon|img)[^>]*/?>\s*</button>',
|
||||
r"<button[^>]*>\s*<(?:svg|Icon|img)[^>]*/?>\s*</button>",
|
||||
content,
|
||||
re.IGNORECASE
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if icon_only_buttons:
|
||||
a11y_issues.append({
|
||||
'type': 'icon-button-no-label',
|
||||
'file': rel_path,
|
||||
})
|
||||
a11y_issues.append(
|
||||
{
|
||||
"type": "icon-button-no-label",
|
||||
"file": rel_path,
|
||||
}
|
||||
)
|
||||
|
||||
# Check for click handlers on non-interactive elements
|
||||
div_onclick = re.findall(r'<div[^>]+onClick', content)
|
||||
div_onclick = re.findall(r"<div[^>]+onClick", content)
|
||||
if div_onclick:
|
||||
a11y_issues.append({
|
||||
'type': 'div-click-handler',
|
||||
'file': rel_path,
|
||||
'count': len(div_onclick),
|
||||
})
|
||||
a11y_issues.append(
|
||||
{
|
||||
"type": "div-click-handler",
|
||||
"file": rel_path,
|
||||
"count": len(div_onclick),
|
||||
}
|
||||
)
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Group issues by type
|
||||
if a11y_issues:
|
||||
img_issues = [i for i in a11y_issues if i['type'] == 'img-no-alt']
|
||||
img_issues = [i for i in a11y_issues if i["type"] == "img-no-alt"]
|
||||
if img_issues:
|
||||
wins.append(QuickWin(
|
||||
type=QuickWinType.ACCESSIBILITY,
|
||||
priority=QuickWinPriority.HIGH,
|
||||
title=f"Found {len(img_issues)} images without alt text",
|
||||
description="Images should have alt attributes for screen readers. Empty alt='' is acceptable for decorative images.",
|
||||
affected_files=list(set(i['file'] for i in img_issues))[:10],
|
||||
estimated_impact="Improve accessibility for screen reader users",
|
||||
fix_suggestion="Add descriptive alt text to images or alt='' for decorative images.",
|
||||
auto_fixable=False,
|
||||
))
|
||||
wins.append(
|
||||
QuickWin(
|
||||
type=QuickWinType.ACCESSIBILITY,
|
||||
priority=QuickWinPriority.HIGH,
|
||||
title=f"Found {len(img_issues)} images without alt text",
|
||||
description="Images should have alt attributes for screen readers. Empty alt='' is acceptable for decorative images.",
|
||||
affected_files=list(set(i["file"] for i in img_issues))[:10],
|
||||
estimated_impact="Improve accessibility for screen reader users",
|
||||
fix_suggestion="Add descriptive alt text to images or alt='' for decorative images.",
|
||||
auto_fixable=False,
|
||||
)
|
||||
)
|
||||
|
||||
div_issues = [i for i in a11y_issues if i['type'] == 'div-click-handler']
|
||||
div_issues = [i for i in a11y_issues if i["type"] == "div-click-handler"]
|
||||
if div_issues:
|
||||
wins.append(QuickWin(
|
||||
type=QuickWinType.ACCESSIBILITY,
|
||||
priority=QuickWinPriority.MEDIUM,
|
||||
title=f"Found click handlers on div elements",
|
||||
description="Using onClick on div elements makes them inaccessible to keyboard users. Use button or add proper ARIA attributes.",
|
||||
affected_files=list(set(i['file'] for i in div_issues))[:10],
|
||||
estimated_impact="Improve keyboard navigation accessibility",
|
||||
fix_suggestion="Replace <div onClick> with <button> or add role='button' and tabIndex={0}.",
|
||||
auto_fixable=True,
|
||||
))
|
||||
wins.append(
|
||||
QuickWin(
|
||||
type=QuickWinType.ACCESSIBILITY,
|
||||
priority=QuickWinPriority.MEDIUM,
|
||||
title="Found click handlers on div elements",
|
||||
description="Using onClick on div elements makes them inaccessible to keyboard users. Use button or add proper ARIA attributes.",
|
||||
affected_files=list(set(i["file"] for i in div_issues))[:10],
|
||||
estimated_impact="Improve keyboard navigation accessibility",
|
||||
fix_suggestion="Replace <div onClick> with <button> or add role='button' and tabIndex={0}.",
|
||||
auto_fixable=True,
|
||||
)
|
||||
)
|
||||
|
||||
return wins
|
||||
|
||||
@@ -343,11 +361,11 @@ class QuickWinFinder:
|
||||
by_priority[priority_key] += 1
|
||||
|
||||
return {
|
||||
'total': len(wins),
|
||||
'by_type': by_type,
|
||||
'by_priority': by_priority,
|
||||
'auto_fixable': len([w for w in wins if w.auto_fixable]),
|
||||
'top_wins': [w.to_dict() for w in wins[:10]],
|
||||
"total": len(wins),
|
||||
"by_type": by_type,
|
||||
"by_priority": by_priority,
|
||||
"auto_fixable": len([w for w in wins if w.auto_fixable]),
|
||||
"top_wins": [w.to_dict() for w in wins[:10]],
|
||||
}
|
||||
|
||||
async def get_actionable_report(self) -> str:
|
||||
@@ -387,17 +405,21 @@ class QuickWinFinder:
|
||||
if not priority_wins:
|
||||
continue
|
||||
|
||||
lines.extend([
|
||||
f"\n[{label}] ({len(priority_wins)} items)",
|
||||
"-" * 40,
|
||||
])
|
||||
lines.extend(
|
||||
[
|
||||
f"\n[{label}] ({len(priority_wins)} items)",
|
||||
"-" * 40,
|
||||
]
|
||||
)
|
||||
|
||||
for i, win in enumerate(priority_wins[:5], 1):
|
||||
lines.extend([
|
||||
f"\n{i}. {win.title}",
|
||||
f" {win.description[:100]}...",
|
||||
f" Impact: {win.estimated_impact}",
|
||||
])
|
||||
lines.extend(
|
||||
[
|
||||
f"\n{i}. {win.title}",
|
||||
f" {win.description[:100]}...",
|
||||
f" Impact: {win.estimated_impact}",
|
||||
]
|
||||
)
|
||||
if win.auto_fixable:
|
||||
lines.append(" [Auto-fixable]")
|
||||
|
||||
@@ -405,14 +427,16 @@ class QuickWinFinder:
|
||||
lines.append(f"\n ... and {len(priority_wins) - 5} more")
|
||||
|
||||
# Summary
|
||||
lines.extend([
|
||||
"",
|
||||
"=" * 50,
|
||||
"SUMMARY",
|
||||
f"Total quick-wins: {len(wins)}",
|
||||
f"Auto-fixable: {len([w for w in wins if w.auto_fixable])}",
|
||||
"",
|
||||
"Run 'dss fix --preview' to see suggested changes.",
|
||||
])
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"=" * 50,
|
||||
"SUMMARY",
|
||||
f"Total quick-wins: {len(wins)}",
|
||||
f"Auto-fixable: {len([w for w in wins if w.auto_fixable])}",
|
||||
"",
|
||||
"Run 'dss fix --preview' to see suggested changes.",
|
||||
]
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
React Project Analyzer
|
||||
React Project Analyzer.
|
||||
|
||||
Analyzes React codebases to extract component information,
|
||||
detect patterns, and identify style usage.
|
||||
@@ -7,90 +7,58 @@ 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,
|
||||
)
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from .base import ComponentInfo, Location
|
||||
|
||||
# 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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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_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
|
||||
)
|
||||
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_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_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
|
||||
)
|
||||
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.
|
||||
"""
|
||||
"""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]:
|
||||
async def analyze(self, component_files: Optional[List[Path]] = None) -> List[ComponentInfo]:
|
||||
"""
|
||||
Analyze React components in the project.
|
||||
|
||||
@@ -110,7 +78,7 @@ class ReactAnalyzer:
|
||||
try:
|
||||
file_components = await self._analyze_file(file_path)
|
||||
components.extend(file_components)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
# Log error but continue
|
||||
continue
|
||||
|
||||
@@ -118,21 +86,23 @@ class ReactAnalyzer:
|
||||
|
||||
def _find_component_files(self) -> List[Path]:
|
||||
"""Find all potential React component files."""
|
||||
skip_dirs = {'node_modules', '.git', 'dist', 'build', '.next'}
|
||||
skip_dirs = {"node_modules", ".git", "dist", "build", ".next"}
|
||||
component_files = []
|
||||
|
||||
for ext in ['*.jsx', '*.tsx']:
|
||||
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 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']):
|
||||
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():
|
||||
@@ -142,7 +112,7 @@ class ReactAnalyzer:
|
||||
|
||||
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')
|
||||
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
||||
components = []
|
||||
|
||||
# Find all components in the file
|
||||
@@ -152,22 +122,22 @@ class ReactAnalyzer:
|
||||
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()))
|
||||
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()))
|
||||
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()))
|
||||
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()))
|
||||
component_matches.append((name, "memo", match.start()))
|
||||
|
||||
# Dedupe by name (keep first occurrence)
|
||||
seen_names = set()
|
||||
@@ -193,19 +163,21 @@ class ReactAnalyzer:
|
||||
# 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,
|
||||
))
|
||||
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
|
||||
|
||||
@@ -217,10 +189,22 @@ class ReactAnalyzer:
|
||||
|
||||
# Filter out common non-component patterns
|
||||
invalid_names = {
|
||||
'React', 'Component', 'PureComponent', 'Fragment',
|
||||
'Suspense', 'Provider', 'Consumer', 'Context',
|
||||
'Error', 'ErrorBoundary', 'Wrapper', 'Container',
|
||||
'Props', 'State', 'Type', 'Interface',
|
||||
"React",
|
||||
"Component",
|
||||
"PureComponent",
|
||||
"Fragment",
|
||||
"Suspense",
|
||||
"Provider",
|
||||
"Consumer",
|
||||
"Context",
|
||||
"Error",
|
||||
"ErrorBoundary",
|
||||
"Wrapper",
|
||||
"Container",
|
||||
"Props",
|
||||
"State",
|
||||
"Type",
|
||||
"Interface",
|
||||
}
|
||||
|
||||
return name not in invalid_names
|
||||
@@ -231,7 +215,7 @@ class ReactAnalyzer:
|
||||
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:
|
||||
if not import_path.startswith(".") and "/" not in import_path:
|
||||
continue
|
||||
imports.append(import_path)
|
||||
return imports
|
||||
@@ -250,11 +234,13 @@ class ReactAnalyzer:
|
||||
|
||||
# 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,
|
||||
))
|
||||
line = content[: match.start()].count("\n") + 1
|
||||
locations.append(
|
||||
Location(
|
||||
file_path="", # Will be set by caller
|
||||
line=line,
|
||||
)
|
||||
)
|
||||
|
||||
return locations
|
||||
|
||||
@@ -266,7 +252,7 @@ class ReactAnalyzer:
|
||||
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):
|
||||
for prop in re.findall(r"(\w+)(?:\s*[=:])?", props_str):
|
||||
if prop and not prop[0].isupper(): # Skip types
|
||||
props.add(prop)
|
||||
|
||||
@@ -275,28 +261,24 @@ class ReactAnalyzer:
|
||||
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)
|
||||
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]:
|
||||
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]*)')
|
||||
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'}:
|
||||
if component_name not in {"Fragment", "Suspense", "Provider"}:
|
||||
children.add(component_name)
|
||||
|
||||
return list(children)
|
||||
@@ -306,16 +288,16 @@ class ReactAnalyzer:
|
||||
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')
|
||||
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')
|
||||
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
|
||||
|
||||
@@ -332,39 +314,44 @@ class ReactAnalyzer:
|
||||
search_path = Path(path) if path else self.root
|
||||
results = []
|
||||
|
||||
for ext in ['*.jsx', '*.tsx', '*.js', '*.ts']:
|
||||
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'}):
|
||||
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')
|
||||
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
|
||||
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',
|
||||
})
|
||||
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
|
||||
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,
|
||||
})
|
||||
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
|
||||
@@ -392,48 +379,50 @@ class ReactAnalyzer:
|
||||
Returns dict with pattern types and their occurrences.
|
||||
"""
|
||||
patterns = {
|
||||
'inline_styles': [],
|
||||
'css_modules': [],
|
||||
'styled_components': [],
|
||||
'emotion': [],
|
||||
'tailwind': [],
|
||||
'css_classes': [],
|
||||
"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')
|
||||
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})
|
||||
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})
|
||||
patterns["styled_components"].append({"file": rel_path})
|
||||
|
||||
# Emotion
|
||||
if re.search(r'@emotion|css`', content):
|
||||
patterns['emotion'].append({'file': rel_path})
|
||||
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})
|
||||
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})
|
||||
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,
|
||||
})
|
||||
line = content[: match.start()].count("\n") + 1
|
||||
patterns["inline_styles"].append(
|
||||
{
|
||||
"file": rel_path,
|
||||
"line": line,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
@@ -1,55 +1,59 @@
|
||||
"""
|
||||
Project Scanner
|
||||
Project Scanner.
|
||||
|
||||
Scans file system to discover project structure, frameworks, and style files.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional, Set, Tuple
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from .base import (
|
||||
Framework,
|
||||
StylingApproach,
|
||||
StyleFile,
|
||||
ProjectAnalysis,
|
||||
)
|
||||
|
||||
from .base import Framework, ProjectAnalysis, StyleFile, StylingApproach
|
||||
|
||||
# Directories to skip during scanning
|
||||
SKIP_DIRS = {
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'.cache',
|
||||
'coverage',
|
||||
'__pycache__',
|
||||
'.venv',
|
||||
'venv',
|
||||
'.turbo',
|
||||
'.vercel',
|
||||
"node_modules",
|
||||
".git",
|
||||
".next",
|
||||
".nuxt",
|
||||
"dist",
|
||||
"build",
|
||||
"out",
|
||||
".cache",
|
||||
"coverage",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
"venv",
|
||||
".turbo",
|
||||
".vercel",
|
||||
}
|
||||
|
||||
# File extensions to scan
|
||||
SCAN_EXTENSIONS = {
|
||||
# JavaScript/TypeScript
|
||||
'.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs',
|
||||
".js",
|
||||
".jsx",
|
||||
".ts",
|
||||
".tsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
# Styles
|
||||
'.css', '.scss', '.sass', '.less', '.styl',
|
||||
".css",
|
||||
".scss",
|
||||
".sass",
|
||||
".less",
|
||||
".styl",
|
||||
# Config
|
||||
'.json',
|
||||
".json",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanResult:
|
||||
"""Result of file system scan."""
|
||||
|
||||
files: List[Path] = field(default_factory=list)
|
||||
style_files: List[Path] = field(default_factory=list)
|
||||
component_files: List[Path] = field(default_factory=list)
|
||||
@@ -60,6 +64,7 @@ class ScanResult:
|
||||
class ProjectScanner:
|
||||
"""
|
||||
Scans a project directory to identify:
|
||||
|
||||
- Framework (React, Next, Vue, etc.)
|
||||
- Styling approach (CSS modules, styled-components, Tailwind, etc.)
|
||||
- Component files
|
||||
@@ -88,6 +93,7 @@ class ProjectScanner:
|
||||
# Check cache if enabled
|
||||
if self.use_cache:
|
||||
import time
|
||||
|
||||
cache_key = str(self.root)
|
||||
if cache_key in self._cache:
|
||||
timestamp, cached_analysis = self._cache[cache_key]
|
||||
@@ -118,20 +124,19 @@ class ProjectScanner:
|
||||
"total_lines": scan_result.total_lines,
|
||||
"component_files": len(scan_result.component_files),
|
||||
"style_files": len(scan_result.style_files),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Determine primary styling approach
|
||||
if styling:
|
||||
analysis.styling_approaches = styling
|
||||
# Primary is the one with most occurrences
|
||||
analysis.primary_styling = max(
|
||||
styling, key=lambda x: x.count
|
||||
).type if styling else None
|
||||
analysis.primary_styling = max(styling, key=lambda x: x.count).type if styling else None
|
||||
|
||||
# Cache result if enabled
|
||||
if self.use_cache:
|
||||
import time
|
||||
|
||||
cache_key = str(self.root)
|
||||
self._cache[cache_key] = (time.time(), analysis)
|
||||
|
||||
@@ -156,39 +161,39 @@ class ProjectScanner:
|
||||
result.files.append(path)
|
||||
|
||||
# Categorize files
|
||||
if suffix in {'.css', '.scss', '.sass', '.less', '.styl'}:
|
||||
if suffix in {".css", ".scss", ".sass", ".less", ".styl"}:
|
||||
result.style_files.append(path)
|
||||
elif suffix in {'.jsx', '.tsx'}:
|
||||
elif suffix in {".jsx", ".tsx"}:
|
||||
result.component_files.append(path)
|
||||
elif suffix in {'.js', '.ts'}:
|
||||
elif suffix in {".js", ".ts"}:
|
||||
# Check if it's a component or config
|
||||
name = path.name.lower()
|
||||
if any(cfg in name for cfg in ['config', 'rc', '.config']):
|
||||
if any(cfg in name for cfg in ["config", "rc", ".config"]):
|
||||
result.config_files[name] = path
|
||||
elif self._looks_like_component(path):
|
||||
result.component_files.append(path)
|
||||
|
||||
# Count lines (approximate for large files)
|
||||
try:
|
||||
content = path.read_text(encoding='utf-8', errors='ignore')
|
||||
result.total_lines += content.count('\n') + 1
|
||||
content = path.read_text(encoding="utf-8", errors="ignore")
|
||||
result.total_lines += content.count("\n") + 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Look for specific config files
|
||||
config_names = [
|
||||
'package.json',
|
||||
'tsconfig.json',
|
||||
'tailwind.config.js',
|
||||
'tailwind.config.ts',
|
||||
'next.config.js',
|
||||
'next.config.mjs',
|
||||
'vite.config.js',
|
||||
'vite.config.ts',
|
||||
'nuxt.config.js',
|
||||
'nuxt.config.ts',
|
||||
'.eslintrc.json',
|
||||
'.eslintrc.js',
|
||||
"package.json",
|
||||
"tsconfig.json",
|
||||
"tailwind.config.js",
|
||||
"tailwind.config.ts",
|
||||
"next.config.js",
|
||||
"next.config.mjs",
|
||||
"vite.config.js",
|
||||
"vite.config.ts",
|
||||
"nuxt.config.js",
|
||||
"nuxt.config.ts",
|
||||
".eslintrc.json",
|
||||
".eslintrc.js",
|
||||
]
|
||||
|
||||
for name in config_names:
|
||||
@@ -205,50 +210,47 @@ class ProjectScanner:
|
||||
if name[0].isupper() and not name.isupper():
|
||||
return True
|
||||
# Common component patterns
|
||||
if any(x in name.lower() for x in ['component', 'page', 'view', 'screen']):
|
||||
if any(x in name.lower() for x in ["component", "page", "view", "screen"]):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _detect_framework(
|
||||
self,
|
||||
config_files: Dict[str, Path]
|
||||
) -> Tuple[Framework, str]:
|
||||
def _detect_framework(self, config_files: Dict[str, Path]) -> Tuple[Framework, str]:
|
||||
"""Detect the UI framework and version."""
|
||||
# Check package.json for dependencies
|
||||
pkg_json = config_files.get('package.json')
|
||||
pkg_json = config_files.get("package.json")
|
||||
if not pkg_json:
|
||||
return Framework.UNKNOWN, ""
|
||||
|
||||
try:
|
||||
pkg = json.loads(pkg_json.read_text())
|
||||
deps = {
|
||||
**pkg.get('dependencies', {}),
|
||||
**pkg.get('devDependencies', {}),
|
||||
**pkg.get("dependencies", {}),
|
||||
**pkg.get("devDependencies", {}),
|
||||
}
|
||||
|
||||
# Check for Next.js first (it includes React)
|
||||
if 'next' in deps:
|
||||
return Framework.NEXT, deps.get('next', '').lstrip('^~')
|
||||
if "next" in deps:
|
||||
return Framework.NEXT, deps.get("next", "").lstrip("^~")
|
||||
|
||||
# Check for Nuxt (Vue-based)
|
||||
if 'nuxt' in deps:
|
||||
return Framework.NUXT, deps.get('nuxt', '').lstrip('^~')
|
||||
if "nuxt" in deps:
|
||||
return Framework.NUXT, deps.get("nuxt", "").lstrip("^~")
|
||||
|
||||
# Check for other frameworks
|
||||
if 'react' in deps:
|
||||
return Framework.REACT, deps.get('react', '').lstrip('^~')
|
||||
if "react" in deps:
|
||||
return Framework.REACT, deps.get("react", "").lstrip("^~")
|
||||
|
||||
if 'vue' in deps:
|
||||
return Framework.VUE, deps.get('vue', '').lstrip('^~')
|
||||
if "vue" in deps:
|
||||
return Framework.VUE, deps.get("vue", "").lstrip("^~")
|
||||
|
||||
if '@angular/core' in deps:
|
||||
return Framework.ANGULAR, deps.get('@angular/core', '').lstrip('^~')
|
||||
if "@angular/core" in deps:
|
||||
return Framework.ANGULAR, deps.get("@angular/core", "").lstrip("^~")
|
||||
|
||||
if 'svelte' in deps:
|
||||
return Framework.SVELTE, deps.get('svelte', '').lstrip('^~')
|
||||
if "svelte" in deps:
|
||||
return Framework.SVELTE, deps.get("svelte", "").lstrip("^~")
|
||||
|
||||
if 'solid-js' in deps:
|
||||
return Framework.SOLID, deps.get('solid-js', '').lstrip('^~')
|
||||
if "solid-js" in deps:
|
||||
return Framework.SOLID, deps.get("solid-js", "").lstrip("^~")
|
||||
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
@@ -257,72 +259,66 @@ class ProjectScanner:
|
||||
|
||||
def _detect_styling(self, scan_result: ScanResult) -> List:
|
||||
"""Detect styling approaches used in the project."""
|
||||
from .base import StylePattern, Location
|
||||
from .base import Location, StylePattern
|
||||
|
||||
patterns: Dict[StylingApproach, StylePattern] = {}
|
||||
|
||||
# Check config files for styling indicators
|
||||
pkg_json = scan_result.config_files.get('package.json')
|
||||
pkg_json = scan_result.config_files.get("package.json")
|
||||
if pkg_json:
|
||||
try:
|
||||
pkg = json.loads(pkg_json.read_text())
|
||||
deps = {
|
||||
**pkg.get('dependencies', {}),
|
||||
**pkg.get('devDependencies', {}),
|
||||
**pkg.get("dependencies", {}),
|
||||
**pkg.get("devDependencies", {}),
|
||||
}
|
||||
|
||||
# Tailwind
|
||||
if 'tailwindcss' in deps:
|
||||
if "tailwindcss" in deps:
|
||||
patterns[StylingApproach.TAILWIND] = StylePattern(
|
||||
type=StylingApproach.TAILWIND,
|
||||
count=1,
|
||||
examples=["tailwindcss in dependencies"]
|
||||
examples=["tailwindcss in dependencies"],
|
||||
)
|
||||
|
||||
# styled-components
|
||||
if 'styled-components' in deps:
|
||||
if "styled-components" in deps:
|
||||
patterns[StylingApproach.STYLED_COMPONENTS] = StylePattern(
|
||||
type=StylingApproach.STYLED_COMPONENTS,
|
||||
count=1,
|
||||
examples=["styled-components in dependencies"]
|
||||
examples=["styled-components in dependencies"],
|
||||
)
|
||||
|
||||
# Emotion
|
||||
if '@emotion/react' in deps or '@emotion/styled' in deps:
|
||||
if "@emotion/react" in deps or "@emotion/styled" in deps:
|
||||
patterns[StylingApproach.EMOTION] = StylePattern(
|
||||
type=StylingApproach.EMOTION,
|
||||
count=1,
|
||||
examples=["@emotion in dependencies"]
|
||||
type=StylingApproach.EMOTION, count=1, examples=["@emotion in dependencies"]
|
||||
)
|
||||
|
||||
# SASS/SCSS
|
||||
if 'sass' in deps or 'node-sass' in deps:
|
||||
if "sass" in deps or "node-sass" in deps:
|
||||
patterns[StylingApproach.SASS_SCSS] = StylePattern(
|
||||
type=StylingApproach.SASS_SCSS,
|
||||
count=1,
|
||||
examples=["sass in dependencies"]
|
||||
type=StylingApproach.SASS_SCSS, count=1, examples=["sass in dependencies"]
|
||||
)
|
||||
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
# Check tailwind config
|
||||
if 'tailwind.config.js' in scan_result.config_files or \
|
||||
'tailwind.config.ts' in scan_result.config_files:
|
||||
if (
|
||||
"tailwind.config.js" in scan_result.config_files
|
||||
or "tailwind.config.ts" in scan_result.config_files
|
||||
):
|
||||
if StylingApproach.TAILWIND not in patterns:
|
||||
patterns[StylingApproach.TAILWIND] = StylePattern(
|
||||
type=StylingApproach.TAILWIND,
|
||||
count=1,
|
||||
examples=["tailwind.config found"]
|
||||
type=StylingApproach.TAILWIND, count=1, examples=["tailwind.config found"]
|
||||
)
|
||||
|
||||
# Scan component files for styling patterns
|
||||
for comp_file in scan_result.component_files[:100]: # Limit for performance
|
||||
try:
|
||||
content = comp_file.read_text(encoding='utf-8', errors='ignore')
|
||||
self._detect_patterns_in_file(
|
||||
content, str(comp_file), patterns
|
||||
)
|
||||
content = comp_file.read_text(encoding="utf-8", errors="ignore")
|
||||
self._detect_patterns_in_file(content, str(comp_file), patterns)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -330,9 +326,9 @@ class ProjectScanner:
|
||||
for style_file in scan_result.style_files:
|
||||
suffix = style_file.suffix.lower()
|
||||
|
||||
if suffix == '.css':
|
||||
if suffix == ".css":
|
||||
# Check for CSS modules
|
||||
if '.module.css' in style_file.name.lower():
|
||||
if ".module.css" in style_file.name.lower():
|
||||
approach = StylingApproach.CSS_MODULES
|
||||
else:
|
||||
approach = StylingApproach.VANILLA_CSS
|
||||
@@ -340,11 +336,9 @@ class ProjectScanner:
|
||||
if approach not in patterns:
|
||||
patterns[approach] = StylePattern(type=approach)
|
||||
patterns[approach].count += 1
|
||||
patterns[approach].locations.append(
|
||||
Location(str(style_file), 1)
|
||||
)
|
||||
patterns[approach].locations.append(Location(str(style_file), 1))
|
||||
|
||||
elif suffix in {'.scss', '.sass'}:
|
||||
elif suffix in {".scss", ".sass"}:
|
||||
if StylingApproach.SASS_SCSS not in patterns:
|
||||
patterns[StylingApproach.SASS_SCSS] = StylePattern(
|
||||
type=StylingApproach.SASS_SCSS
|
||||
@@ -354,13 +348,10 @@ class ProjectScanner:
|
||||
return list(patterns.values())
|
||||
|
||||
def _detect_patterns_in_file(
|
||||
self,
|
||||
content: str,
|
||||
file_path: str,
|
||||
patterns: Dict[StylingApproach, Any]
|
||||
self, content: str, file_path: str, patterns: Dict[StylingApproach, Any]
|
||||
) -> None:
|
||||
"""Detect styling patterns in a single file."""
|
||||
from .base import StylePattern, Location
|
||||
from .base import Location, StylePattern
|
||||
|
||||
# CSS Modules import
|
||||
css_module_pattern = re.compile(
|
||||
@@ -372,15 +363,11 @@ class ProjectScanner:
|
||||
type=StylingApproach.CSS_MODULES
|
||||
)
|
||||
patterns[StylingApproach.CSS_MODULES].count += 1
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
patterns[StylingApproach.CSS_MODULES].locations.append(
|
||||
Location(file_path, line_num)
|
||||
)
|
||||
line_num = content[: match.start()].count("\n") + 1
|
||||
patterns[StylingApproach.CSS_MODULES].locations.append(Location(file_path, line_num))
|
||||
|
||||
# styled-components
|
||||
styled_pattern = re.compile(
|
||||
r"(styled\.|styled\()|(from\s+['\"]styled-components['\"])"
|
||||
)
|
||||
styled_pattern = re.compile(r"(styled\.|styled\()|(from\s+['\"]styled-components['\"])")
|
||||
for match in styled_pattern.finditer(content):
|
||||
if StylingApproach.STYLED_COMPONENTS not in patterns:
|
||||
patterns[StylingApproach.STYLED_COMPONENTS] = StylePattern(
|
||||
@@ -389,33 +376,23 @@ class ProjectScanner:
|
||||
patterns[StylingApproach.STYLED_COMPONENTS].count += 1
|
||||
|
||||
# Emotion
|
||||
emotion_pattern = re.compile(
|
||||
r"(css`|@emotion|from\s+['\"]@emotion)"
|
||||
)
|
||||
emotion_pattern = re.compile(r"(css`|@emotion|from\s+['\"]@emotion)")
|
||||
for match in emotion_pattern.finditer(content):
|
||||
if StylingApproach.EMOTION not in patterns:
|
||||
patterns[StylingApproach.EMOTION] = StylePattern(
|
||||
type=StylingApproach.EMOTION
|
||||
)
|
||||
patterns[StylingApproach.EMOTION] = StylePattern(type=StylingApproach.EMOTION)
|
||||
patterns[StylingApproach.EMOTION].count += 1
|
||||
|
||||
# Inline styles
|
||||
inline_pattern = re.compile(
|
||||
r'style\s*=\s*\{\s*\{[^}]+\}\s*\}'
|
||||
)
|
||||
inline_pattern = re.compile(r"style\s*=\s*\{\s*\{[^}]+\}\s*\}")
|
||||
for match in inline_pattern.finditer(content):
|
||||
if StylingApproach.INLINE_STYLES not in patterns:
|
||||
patterns[StylingApproach.INLINE_STYLES] = StylePattern(
|
||||
type=StylingApproach.INLINE_STYLES
|
||||
)
|
||||
patterns[StylingApproach.INLINE_STYLES].count += 1
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
patterns[StylingApproach.INLINE_STYLES].locations.append(
|
||||
Location(file_path, line_num)
|
||||
)
|
||||
patterns[StylingApproach.INLINE_STYLES].examples.append(
|
||||
match.group(0)[:100]
|
||||
)
|
||||
line_num = content[: match.start()].count("\n") + 1
|
||||
patterns[StylingApproach.INLINE_STYLES].locations.append(Location(file_path, line_num))
|
||||
patterns[StylingApproach.INLINE_STYLES].examples.append(match.group(0)[:100])
|
||||
|
||||
# Tailwind classes
|
||||
tailwind_pattern = re.compile(
|
||||
@@ -423,9 +400,7 @@ class ProjectScanner:
|
||||
)
|
||||
for match in tailwind_pattern.finditer(content):
|
||||
if StylingApproach.TAILWIND not in patterns:
|
||||
patterns[StylingApproach.TAILWIND] = StylePattern(
|
||||
type=StylingApproach.TAILWIND
|
||||
)
|
||||
patterns[StylingApproach.TAILWIND] = StylePattern(type=StylingApproach.TAILWIND)
|
||||
patterns[StylingApproach.TAILWIND].count += 1
|
||||
|
||||
def _analyze_style_files(self, style_paths: List[Path]) -> List[StyleFile]:
|
||||
@@ -434,43 +409,45 @@ class ProjectScanner:
|
||||
|
||||
for path in style_paths:
|
||||
try:
|
||||
content = path.read_text(encoding='utf-8', errors='ignore')
|
||||
content = path.read_text(encoding="utf-8", errors="ignore")
|
||||
|
||||
# Determine type
|
||||
suffix = path.suffix.lower()
|
||||
if '.module.' in path.name.lower():
|
||||
file_type = 'css-module'
|
||||
elif suffix == '.scss':
|
||||
file_type = 'scss'
|
||||
elif suffix == '.sass':
|
||||
file_type = 'sass'
|
||||
elif suffix == '.less':
|
||||
file_type = 'less'
|
||||
if ".module." in path.name.lower():
|
||||
file_type = "css-module"
|
||||
elif suffix == ".scss":
|
||||
file_type = "scss"
|
||||
elif suffix == ".sass":
|
||||
file_type = "sass"
|
||||
elif suffix == ".less":
|
||||
file_type = "less"
|
||||
else:
|
||||
file_type = 'css'
|
||||
file_type = "css"
|
||||
|
||||
# Count variables
|
||||
var_count = 0
|
||||
if file_type == 'css' or file_type == 'css-module':
|
||||
var_count = len(re.findall(r'--[\w-]+\s*:', content))
|
||||
elif file_type in {'scss', 'sass'}:
|
||||
var_count = len(re.findall(r'\$[\w-]+\s*:', content))
|
||||
if file_type == "css" or file_type == "css-module":
|
||||
var_count = len(re.findall(r"--[\w-]+\s*:", content))
|
||||
elif file_type in {"scss", "sass"}:
|
||||
var_count = len(re.findall(r"\$[\w-]+\s*:", content))
|
||||
|
||||
# Count selectors (approximate)
|
||||
selector_count = len(re.findall(r'[.#][\w-]+\s*\{', content))
|
||||
selector_count = len(re.findall(r"[.#][\w-]+\s*\{", content))
|
||||
|
||||
# Find imports
|
||||
imports = re.findall(r'@import\s+["\']([^"\']+)["\']', content)
|
||||
|
||||
style_files.append(StyleFile(
|
||||
path=str(path.relative_to(self.root)),
|
||||
type=file_type,
|
||||
size_bytes=path.stat().st_size,
|
||||
line_count=content.count('\n') + 1,
|
||||
variable_count=var_count,
|
||||
selector_count=selector_count,
|
||||
imports=imports,
|
||||
))
|
||||
style_files.append(
|
||||
StyleFile(
|
||||
path=str(path.relative_to(self.root)),
|
||||
type=file_type,
|
||||
size_bytes=path.stat().st_size,
|
||||
line_count=content.count("\n") + 1,
|
||||
variable_count=var_count,
|
||||
selector_count=selector_count,
|
||||
imports=imports,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
@@ -479,6 +456,7 @@ class ProjectScanner:
|
||||
|
||||
def get_file_tree(self, max_depth: int = 3) -> Dict[str, Any]:
|
||||
"""Get project file tree structure."""
|
||||
|
||||
def build_tree(path: Path, depth: int) -> Dict[str, Any]:
|
||||
if depth > max_depth:
|
||||
return {"...": "truncated"}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Style Pattern Analyzer
|
||||
Style Pattern Analyzer.
|
||||
|
||||
Detects and analyzes style patterns in code to identify:
|
||||
- Hardcoded values that should be tokens
|
||||
@@ -9,65 +9,61 @@ Detects and analyzes style patterns in code to identify:
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional, Set, Tuple
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .base import (
|
||||
Location,
|
||||
TokenCandidate,
|
||||
StylePattern,
|
||||
StylingApproach,
|
||||
)
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .base import Location, TokenCandidate
|
||||
|
||||
# Color patterns
|
||||
HEX_COLOR = re.compile(r'#(?:[0-9a-fA-F]{3}){1,2}\b')
|
||||
RGB_COLOR = re.compile(r'rgba?\s*\(\s*\d+\s*,\s*\d+\s*,\s*\d+(?:\s*,\s*[\d.]+)?\s*\)')
|
||||
HSL_COLOR = re.compile(r'hsla?\s*\(\s*\d+\s*,\s*[\d.]+%\s*,\s*[\d.]+%(?:\s*,\s*[\d.]+)?\s*\)')
|
||||
OKLCH_COLOR = re.compile(r'oklch\s*\([^)]+\)')
|
||||
HEX_COLOR = re.compile(r"#(?:[0-9a-fA-F]{3}){1,2}\b")
|
||||
RGB_COLOR = re.compile(r"rgba?\s*\(\s*\d+\s*,\s*\d+\s*,\s*\d+(?:\s*,\s*[\d.]+)?\s*\)")
|
||||
HSL_COLOR = re.compile(r"hsla?\s*\(\s*\d+\s*,\s*[\d.]+%\s*,\s*[\d.]+%(?:\s*,\s*[\d.]+)?\s*\)")
|
||||
OKLCH_COLOR = re.compile(r"oklch\s*\([^)]+\)")
|
||||
|
||||
# Dimension patterns
|
||||
PX_VALUE = re.compile(r'\b(\d+(?:\.\d+)?)\s*px\b')
|
||||
REM_VALUE = re.compile(r'\b(\d+(?:\.\d+)?)\s*rem\b')
|
||||
EM_VALUE = re.compile(r'\b(\d+(?:\.\d+)?)\s*em\b')
|
||||
PERCENT_VALUE = re.compile(r'\b(\d+(?:\.\d+)?)\s*%\b')
|
||||
PX_VALUE = re.compile(r"\b(\d+(?:\.\d+)?)\s*px\b")
|
||||
REM_VALUE = re.compile(r"\b(\d+(?:\.\d+)?)\s*rem\b")
|
||||
EM_VALUE = re.compile(r"\b(\d+(?:\.\d+)?)\s*em\b")
|
||||
PERCENT_VALUE = re.compile(r"\b(\d+(?:\.\d+)?)\s*%\b")
|
||||
|
||||
# Font patterns
|
||||
FONT_SIZE = re.compile(r'font-size\s*:\s*([^;]+)')
|
||||
FONT_FAMILY = re.compile(r'font-family\s*:\s*([^;]+)')
|
||||
FONT_WEIGHT = re.compile(r'font-weight\s*:\s*(\d+|normal|bold|lighter|bolder)')
|
||||
LINE_HEIGHT = re.compile(r'line-height\s*:\s*([^;]+)')
|
||||
FONT_SIZE = re.compile(r"font-size\s*:\s*([^;]+)")
|
||||
FONT_FAMILY = re.compile(r"font-family\s*:\s*([^;]+)")
|
||||
FONT_WEIGHT = re.compile(r"font-weight\s*:\s*(\d+|normal|bold|lighter|bolder)")
|
||||
LINE_HEIGHT = re.compile(r"line-height\s*:\s*([^;]+)")
|
||||
|
||||
# Spacing patterns
|
||||
MARGIN_PADDING = re.compile(r'(?:margin|padding)(?:-(?:top|right|bottom|left))?\s*:\s*([^;]+)')
|
||||
GAP = re.compile(r'gap\s*:\s*([^;]+)')
|
||||
MARGIN_PADDING = re.compile(r"(?:margin|padding)(?:-(?:top|right|bottom|left))?\s*:\s*([^;]+)")
|
||||
GAP = re.compile(r"gap\s*:\s*([^;]+)")
|
||||
|
||||
# Border patterns
|
||||
BORDER_RADIUS = re.compile(r'border-radius\s*:\s*([^;]+)')
|
||||
BORDER_WIDTH = re.compile(r'border(?:-(?:top|right|bottom|left))?-width\s*:\s*([^;]+)')
|
||||
BORDER_RADIUS = re.compile(r"border-radius\s*:\s*([^;]+)")
|
||||
BORDER_WIDTH = re.compile(r"border(?:-(?:top|right|bottom|left))?-width\s*:\s*([^;]+)")
|
||||
|
||||
# Shadow patterns
|
||||
BOX_SHADOW = re.compile(r'box-shadow\s*:\s*([^;]+)')
|
||||
BOX_SHADOW = re.compile(r"box-shadow\s*:\s*([^;]+)")
|
||||
|
||||
# Z-index
|
||||
Z_INDEX = re.compile(r'z-index\s*:\s*(\d+)')
|
||||
Z_INDEX = re.compile(r"z-index\s*:\s*(\d+)")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValueOccurrence:
|
||||
"""Tracks where a value appears."""
|
||||
|
||||
value: str
|
||||
file: str
|
||||
line: int
|
||||
property: str # CSS property name
|
||||
context: str # Surrounding code
|
||||
context: str # Surrounding code
|
||||
|
||||
|
||||
class StyleAnalyzer:
|
||||
"""
|
||||
Analyzes style files and inline styles to find:
|
||||
|
||||
- Hardcoded values that should be tokens
|
||||
- Duplicate values
|
||||
- Inconsistent patterns
|
||||
@@ -81,9 +77,7 @@ class StyleAnalyzer:
|
||||
self.font_values: Dict[str, List[ValueOccurrence]] = defaultdict(list)
|
||||
|
||||
async def analyze(
|
||||
self,
|
||||
include_inline: bool = True,
|
||||
include_css: bool = True
|
||||
self, include_inline: bool = True, include_css: bool = True
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Analyze all styles in the project.
|
||||
@@ -110,24 +104,24 @@ class StyleAnalyzer:
|
||||
candidates = self._generate_token_candidates()
|
||||
|
||||
return {
|
||||
'total_values_found': sum(len(v) for v in self.values.values()),
|
||||
'unique_colors': len(self.color_values),
|
||||
'unique_spacing': len(self.spacing_values),
|
||||
'duplicates': duplicates,
|
||||
'token_candidates': candidates,
|
||||
"total_values_found": sum(len(v) for v in self.values.values()),
|
||||
"unique_colors": len(self.color_values),
|
||||
"unique_spacing": len(self.spacing_values),
|
||||
"duplicates": duplicates,
|
||||
"token_candidates": candidates,
|
||||
}
|
||||
|
||||
async def _scan_style_files(self) -> None:
|
||||
"""Scan CSS and SCSS files for values."""
|
||||
skip_dirs = {'node_modules', '.git', 'dist', 'build'}
|
||||
skip_dirs = {"node_modules", ".git", "dist", "build"}
|
||||
|
||||
for pattern in ['**/*.css', '**/*.scss', '**/*.sass', '**/*.less']:
|
||||
for pattern in ["**/*.css", "**/*.scss", "**/*.sass", "**/*.less"]:
|
||||
for file_path in self.root.rglob(pattern):
|
||||
if any(skip in file_path.parts for skip in skip_dirs):
|
||||
continue
|
||||
|
||||
try:
|
||||
content = file_path.read_text(encoding='utf-8', errors='ignore')
|
||||
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
||||
rel_path = str(file_path.relative_to(self.root))
|
||||
self._extract_values_from_css(content, rel_path)
|
||||
except Exception:
|
||||
@@ -135,15 +129,15 @@ class StyleAnalyzer:
|
||||
|
||||
async def _scan_inline_styles(self) -> None:
|
||||
"""Scan JS/TS files for inline style values."""
|
||||
skip_dirs = {'node_modules', '.git', 'dist', 'build'}
|
||||
skip_dirs = {"node_modules", ".git", "dist", "build"}
|
||||
|
||||
for pattern in ['**/*.jsx', '**/*.tsx', '**/*.js', '**/*.ts']:
|
||||
for pattern in ["**/*.jsx", "**/*.tsx", "**/*.js", "**/*.ts"]:
|
||||
for file_path in self.root.rglob(pattern):
|
||||
if any(skip in file_path.parts for skip in skip_dirs):
|
||||
continue
|
||||
|
||||
try:
|
||||
content = file_path.read_text(encoding='utf-8', errors='ignore')
|
||||
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
||||
rel_path = str(file_path.relative_to(self.root))
|
||||
self._extract_values_from_jsx(content, rel_path)
|
||||
except Exception:
|
||||
@@ -151,11 +145,11 @@ class StyleAnalyzer:
|
||||
|
||||
def _extract_values_from_css(self, content: str, file_path: str) -> None:
|
||||
"""Extract style values from CSS content."""
|
||||
lines = content.split('\n')
|
||||
lines = content.split("\n")
|
||||
|
||||
for line_num, line in enumerate(lines, 1):
|
||||
# Skip comments and empty lines
|
||||
if not line.strip() or line.strip().startswith('//') or line.strip().startswith('/*'):
|
||||
if not line.strip() or line.strip().startswith("//") or line.strip().startswith("/*"):
|
||||
continue
|
||||
|
||||
# Extract colors
|
||||
@@ -176,25 +170,25 @@ class StyleAnalyzer:
|
||||
# Extract font properties
|
||||
for match in FONT_SIZE.finditer(line):
|
||||
value = match.group(1).strip()
|
||||
self._record_font(value, file_path, line_num, 'font-size', line.strip())
|
||||
self._record_font(value, file_path, line_num, "font-size", line.strip())
|
||||
|
||||
for match in FONT_WEIGHT.finditer(line):
|
||||
value = match.group(1).strip()
|
||||
self._record_font(value, file_path, line_num, 'font-weight', line.strip())
|
||||
self._record_font(value, file_path, line_num, "font-weight", line.strip())
|
||||
|
||||
# Extract z-index
|
||||
for match in Z_INDEX.finditer(line):
|
||||
value = match.group(1)
|
||||
self._record_value(f"z-{value}", file_path, line_num, 'z-index', line.strip())
|
||||
self._record_value(f"z-{value}", file_path, line_num, "z-index", line.strip())
|
||||
|
||||
def _extract_values_from_jsx(self, content: str, file_path: str) -> None:
|
||||
"""Extract style values from JSX inline styles."""
|
||||
# Find style={{ ... }} blocks
|
||||
style_pattern = re.compile(r'style\s*=\s*\{\s*\{([^}]+)\}\s*\}', re.DOTALL)
|
||||
style_pattern = re.compile(r"style\s*=\s*\{\s*\{([^}]+)\}\s*\}", re.DOTALL)
|
||||
|
||||
for match in style_pattern.finditer(content):
|
||||
style_content = match.group(1)
|
||||
line_num = content[:match.start()].count('\n') + 1
|
||||
line_num = content[: match.start()].count("\n") + 1
|
||||
|
||||
# Parse the style object
|
||||
# Look for property: value patterns
|
||||
@@ -205,84 +199,102 @@ class StyleAnalyzer:
|
||||
prop_value = prop_match.group(2).strip()
|
||||
|
||||
# Check for colors
|
||||
if any(c in prop_name.lower() for c in ['color', 'background']):
|
||||
if any(c in prop_name.lower() for c in ["color", "background"]):
|
||||
if HEX_COLOR.search(prop_value) or RGB_COLOR.search(prop_value):
|
||||
self._record_color(prop_value.lower(), file_path, line_num, style_content[:100])
|
||||
self._record_color(
|
||||
prop_value.lower(), file_path, line_num, style_content[:100]
|
||||
)
|
||||
|
||||
# Check for dimensions
|
||||
if PX_VALUE.search(prop_value):
|
||||
self._record_spacing(prop_value, file_path, line_num, style_content[:100])
|
||||
|
||||
if 'fontSize' in prop_name or 'fontWeight' in prop_name:
|
||||
self._record_font(prop_value, file_path, line_num, prop_name, style_content[:100])
|
||||
if "fontSize" in prop_name or "fontWeight" in prop_name:
|
||||
self._record_font(
|
||||
prop_value, file_path, line_num, prop_name, style_content[:100]
|
||||
)
|
||||
|
||||
def _record_color(self, value: str, file: str, line: int, context: str) -> None:
|
||||
"""Record a color value occurrence."""
|
||||
normalized = self._normalize_color(value)
|
||||
self.color_values[normalized].append(ValueOccurrence(
|
||||
value=value,
|
||||
file=file,
|
||||
line=line,
|
||||
property='color',
|
||||
context=context,
|
||||
))
|
||||
self.values[normalized].append(ValueOccurrence(
|
||||
value=value,
|
||||
file=file,
|
||||
line=line,
|
||||
property='color',
|
||||
context=context,
|
||||
))
|
||||
self.color_values[normalized].append(
|
||||
ValueOccurrence(
|
||||
value=value,
|
||||
file=file,
|
||||
line=line,
|
||||
property="color",
|
||||
context=context,
|
||||
)
|
||||
)
|
||||
self.values[normalized].append(
|
||||
ValueOccurrence(
|
||||
value=value,
|
||||
file=file,
|
||||
line=line,
|
||||
property="color",
|
||||
context=context,
|
||||
)
|
||||
)
|
||||
|
||||
def _record_spacing(self, value: str, file: str, line: int, context: str) -> None:
|
||||
"""Record a spacing/dimension value occurrence."""
|
||||
self.spacing_values[value].append(ValueOccurrence(
|
||||
value=value,
|
||||
file=file,
|
||||
line=line,
|
||||
property='spacing',
|
||||
context=context,
|
||||
))
|
||||
self.values[value].append(ValueOccurrence(
|
||||
value=value,
|
||||
file=file,
|
||||
line=line,
|
||||
property='spacing',
|
||||
context=context,
|
||||
))
|
||||
self.spacing_values[value].append(
|
||||
ValueOccurrence(
|
||||
value=value,
|
||||
file=file,
|
||||
line=line,
|
||||
property="spacing",
|
||||
context=context,
|
||||
)
|
||||
)
|
||||
self.values[value].append(
|
||||
ValueOccurrence(
|
||||
value=value,
|
||||
file=file,
|
||||
line=line,
|
||||
property="spacing",
|
||||
context=context,
|
||||
)
|
||||
)
|
||||
|
||||
def _record_font(self, value: str, file: str, line: int, prop: str, context: str) -> None:
|
||||
"""Record a font-related value occurrence."""
|
||||
self.font_values[value].append(ValueOccurrence(
|
||||
value=value,
|
||||
file=file,
|
||||
line=line,
|
||||
property=prop,
|
||||
context=context,
|
||||
))
|
||||
self.values[value].append(ValueOccurrence(
|
||||
value=value,
|
||||
file=file,
|
||||
line=line,
|
||||
property=prop,
|
||||
context=context,
|
||||
))
|
||||
self.font_values[value].append(
|
||||
ValueOccurrence(
|
||||
value=value,
|
||||
file=file,
|
||||
line=line,
|
||||
property=prop,
|
||||
context=context,
|
||||
)
|
||||
)
|
||||
self.values[value].append(
|
||||
ValueOccurrence(
|
||||
value=value,
|
||||
file=file,
|
||||
line=line,
|
||||
property=prop,
|
||||
context=context,
|
||||
)
|
||||
)
|
||||
|
||||
def _record_value(self, value: str, file: str, line: int, prop: str, context: str) -> None:
|
||||
"""Record a generic value occurrence."""
|
||||
self.values[value].append(ValueOccurrence(
|
||||
value=value,
|
||||
file=file,
|
||||
line=line,
|
||||
property=prop,
|
||||
context=context,
|
||||
))
|
||||
self.values[value].append(
|
||||
ValueOccurrence(
|
||||
value=value,
|
||||
file=file,
|
||||
line=line,
|
||||
property=prop,
|
||||
context=context,
|
||||
)
|
||||
)
|
||||
|
||||
def _normalize_color(self, color: str) -> str:
|
||||
"""Normalize color value for comparison."""
|
||||
color = color.lower().strip()
|
||||
# Expand 3-digit hex to 6-digit
|
||||
if re.match(r'^#[0-9a-f]{3}$', color):
|
||||
if re.match(r"^#[0-9a-f]{3}$", color):
|
||||
color = f"#{color[1]*2}{color[2]*2}{color[3]*2}"
|
||||
return color
|
||||
|
||||
@@ -295,19 +307,18 @@ class StyleAnalyzer:
|
||||
# Get unique files
|
||||
files = list(set(o.file for o in occurrences))
|
||||
|
||||
duplicates.append({
|
||||
'value': value,
|
||||
'count': len(occurrences),
|
||||
'files': files[:5], # Limit to 5 files
|
||||
'category': occurrences[0].property,
|
||||
'locations': [
|
||||
{'file': o.file, 'line': o.line}
|
||||
for o in occurrences[:5]
|
||||
],
|
||||
})
|
||||
duplicates.append(
|
||||
{
|
||||
"value": value,
|
||||
"count": len(occurrences),
|
||||
"files": files[:5], # Limit to 5 files
|
||||
"category": occurrences[0].property,
|
||||
"locations": [{"file": o.file, "line": o.line} for o in occurrences[:5]],
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by count (most duplicated first)
|
||||
duplicates.sort(key=lambda x: x['count'], reverse=True)
|
||||
duplicates.sort(key=lambda x: x["count"], reverse=True)
|
||||
|
||||
return duplicates[:50] # Return top 50
|
||||
|
||||
@@ -319,31 +330,31 @@ class StyleAnalyzer:
|
||||
for value, occurrences in self.color_values.items():
|
||||
if len(occurrences) >= 2:
|
||||
suggested_name = self._suggest_color_name(value)
|
||||
candidates.append(TokenCandidate(
|
||||
value=value,
|
||||
suggested_name=suggested_name,
|
||||
category='colors',
|
||||
occurrences=len(occurrences),
|
||||
locations=[
|
||||
Location(o.file, o.line) for o in occurrences[:5]
|
||||
],
|
||||
confidence=min(0.9, 0.3 + (len(occurrences) * 0.1)),
|
||||
))
|
||||
candidates.append(
|
||||
TokenCandidate(
|
||||
value=value,
|
||||
suggested_name=suggested_name,
|
||||
category="colors",
|
||||
occurrences=len(occurrences),
|
||||
locations=[Location(o.file, o.line) for o in occurrences[:5]],
|
||||
confidence=min(0.9, 0.3 + (len(occurrences) * 0.1)),
|
||||
)
|
||||
)
|
||||
|
||||
# Spacing candidates
|
||||
for value, occurrences in self.spacing_values.items():
|
||||
if len(occurrences) >= 3: # Higher threshold for spacing
|
||||
suggested_name = self._suggest_spacing_name(value)
|
||||
candidates.append(TokenCandidate(
|
||||
value=value,
|
||||
suggested_name=suggested_name,
|
||||
category='spacing',
|
||||
occurrences=len(occurrences),
|
||||
locations=[
|
||||
Location(o.file, o.line) for o in occurrences[:5]
|
||||
],
|
||||
confidence=min(0.8, 0.2 + (len(occurrences) * 0.05)),
|
||||
))
|
||||
candidates.append(
|
||||
TokenCandidate(
|
||||
value=value,
|
||||
suggested_name=suggested_name,
|
||||
category="spacing",
|
||||
occurrences=len(occurrences),
|
||||
locations=[Location(o.file, o.line) for o in occurrences[:5]],
|
||||
confidence=min(0.8, 0.2 + (len(occurrences) * 0.05)),
|
||||
)
|
||||
)
|
||||
|
||||
# Sort by confidence
|
||||
candidates.sort(key=lambda x: x.confidence, reverse=True)
|
||||
@@ -354,48 +365,48 @@ class StyleAnalyzer:
|
||||
"""Suggest a token name for a color value."""
|
||||
# Common color mappings
|
||||
common_colors = {
|
||||
'#ffffff': 'color.white',
|
||||
'#000000': 'color.black',
|
||||
'#f3f4f6': 'color.neutral.100',
|
||||
'#e5e7eb': 'color.neutral.200',
|
||||
'#d1d5db': 'color.neutral.300',
|
||||
'#9ca3af': 'color.neutral.400',
|
||||
'#6b7280': 'color.neutral.500',
|
||||
'#4b5563': 'color.neutral.600',
|
||||
'#374151': 'color.neutral.700',
|
||||
'#1f2937': 'color.neutral.800',
|
||||
'#111827': 'color.neutral.900',
|
||||
"#ffffff": "color.white",
|
||||
"#000000": "color.black",
|
||||
"#f3f4f6": "color.neutral.100",
|
||||
"#e5e7eb": "color.neutral.200",
|
||||
"#d1d5db": "color.neutral.300",
|
||||
"#9ca3af": "color.neutral.400",
|
||||
"#6b7280": "color.neutral.500",
|
||||
"#4b5563": "color.neutral.600",
|
||||
"#374151": "color.neutral.700",
|
||||
"#1f2937": "color.neutral.800",
|
||||
"#111827": "color.neutral.900",
|
||||
}
|
||||
|
||||
if color in common_colors:
|
||||
return common_colors[color]
|
||||
|
||||
# Detect color family by hue (simplified)
|
||||
if color.startswith('#'):
|
||||
if color.startswith("#"):
|
||||
return f"color.custom.{color[1:7]}"
|
||||
|
||||
return f"color.custom.value"
|
||||
return "color.custom.value"
|
||||
|
||||
def _suggest_spacing_name(self, value: str) -> str:
|
||||
"""Suggest a token name for a spacing value."""
|
||||
# Common spacing values
|
||||
spacing_map = {
|
||||
'0px': 'spacing.0',
|
||||
'4px': 'spacing.xs',
|
||||
'8px': 'spacing.sm',
|
||||
'12px': 'spacing.md',
|
||||
'16px': 'spacing.lg',
|
||||
'20px': 'spacing.lg',
|
||||
'24px': 'spacing.xl',
|
||||
'32px': 'spacing.2xl',
|
||||
'48px': 'spacing.3xl',
|
||||
'64px': 'spacing.4xl',
|
||||
'0.25rem': 'spacing.xs',
|
||||
'0.5rem': 'spacing.sm',
|
||||
'0.75rem': 'spacing.md',
|
||||
'1rem': 'spacing.lg',
|
||||
'1.5rem': 'spacing.xl',
|
||||
'2rem': 'spacing.2xl',
|
||||
"0px": "spacing.0",
|
||||
"4px": "spacing.xs",
|
||||
"8px": "spacing.sm",
|
||||
"12px": "spacing.md",
|
||||
"16px": "spacing.lg",
|
||||
"20px": "spacing.lg",
|
||||
"24px": "spacing.xl",
|
||||
"32px": "spacing.2xl",
|
||||
"48px": "spacing.3xl",
|
||||
"64px": "spacing.4xl",
|
||||
"0.25rem": "spacing.xs",
|
||||
"0.5rem": "spacing.sm",
|
||||
"0.75rem": "spacing.md",
|
||||
"1rem": "spacing.lg",
|
||||
"1.5rem": "spacing.xl",
|
||||
"2rem": "spacing.2xl",
|
||||
}
|
||||
|
||||
if value in spacing_map:
|
||||
@@ -413,19 +424,19 @@ class StyleAnalyzer:
|
||||
css_classes = set()
|
||||
class_locations = {}
|
||||
|
||||
skip_dirs = {'node_modules', '.git', 'dist', 'build'}
|
||||
skip_dirs = {"node_modules", ".git", "dist", "build"}
|
||||
|
||||
for pattern in ['**/*.css', '**/*.scss']:
|
||||
for pattern in ["**/*.css", "**/*.scss"]:
|
||||
for file_path in self.root.rglob(pattern):
|
||||
if any(skip in file_path.parts for skip in skip_dirs):
|
||||
continue
|
||||
|
||||
try:
|
||||
content = file_path.read_text(encoding='utf-8', errors='ignore')
|
||||
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
||||
rel_path = str(file_path.relative_to(self.root))
|
||||
|
||||
# Find class definitions
|
||||
for match in re.finditer(r'\.([a-zA-Z_][\w-]*)\s*[{,]', content):
|
||||
for match in re.finditer(r"\.([a-zA-Z_][\w-]*)\s*[{,]", content):
|
||||
class_name = match.group(1)
|
||||
css_classes.add(class_name)
|
||||
class_locations[class_name] = rel_path
|
||||
@@ -436,13 +447,13 @@ class StyleAnalyzer:
|
||||
# Collect all class usage in JS/JSX/TS/TSX
|
||||
used_classes = set()
|
||||
|
||||
for pattern in ['**/*.jsx', '**/*.tsx', '**/*.js', '**/*.ts']:
|
||||
for pattern in ["**/*.jsx", "**/*.tsx", "**/*.js", "**/*.ts"]:
|
||||
for file_path in self.root.rglob(pattern):
|
||||
if any(skip in file_path.parts for skip in skip_dirs):
|
||||
continue
|
||||
|
||||
try:
|
||||
content = file_path.read_text(encoding='utf-8', errors='ignore')
|
||||
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
||||
|
||||
# Find className usage
|
||||
for match in re.finditer(r'className\s*=\s*["\']([^"\']+)["\']', content):
|
||||
@@ -450,7 +461,7 @@ class StyleAnalyzer:
|
||||
used_classes.update(classes)
|
||||
|
||||
# Find styles.xxx usage (CSS modules)
|
||||
for match in re.finditer(r'styles\.(\w+)', content):
|
||||
for match in re.finditer(r"styles\.(\w+)", content):
|
||||
used_classes.add(match.group(1))
|
||||
|
||||
except Exception:
|
||||
@@ -461,11 +472,13 @@ class StyleAnalyzer:
|
||||
|
||||
return [
|
||||
{
|
||||
'class': cls,
|
||||
'file': class_locations.get(cls, 'unknown'),
|
||||
"class": cls,
|
||||
"file": class_locations.get(cls, "unknown"),
|
||||
}
|
||||
for cls in sorted(unused)
|
||||
][:50] # Limit results
|
||||
][
|
||||
:50
|
||||
] # Limit results
|
||||
|
||||
async def analyze_naming_consistency(self) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -474,44 +487,52 @@ class StyleAnalyzer:
|
||||
Returns analysis of naming patterns and inconsistencies.
|
||||
"""
|
||||
patterns = {
|
||||
'kebab-case': [], # my-class-name
|
||||
'camelCase': [], # myClassName
|
||||
'snake_case': [], # my_class_name
|
||||
'BEM': [], # block__element--modifier
|
||||
"kebab-case": [], # my-class-name
|
||||
"camelCase": [], # myClassName
|
||||
"snake_case": [], # my_class_name
|
||||
"BEM": [], # block__element--modifier
|
||||
}
|
||||
|
||||
skip_dirs = {'node_modules', '.git', 'dist', 'build'}
|
||||
skip_dirs = {"node_modules", ".git", "dist", "build"}
|
||||
|
||||
for pattern in ['**/*.css', '**/*.scss']:
|
||||
for pattern in ["**/*.css", "**/*.scss"]:
|
||||
for file_path in self.root.rglob(pattern):
|
||||
if any(skip in file_path.parts for skip in skip_dirs):
|
||||
continue
|
||||
|
||||
try:
|
||||
content = file_path.read_text(encoding='utf-8', errors='ignore')
|
||||
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
||||
rel_path = str(file_path.relative_to(self.root))
|
||||
|
||||
# Find class names
|
||||
for match in re.finditer(r'\.([a-zA-Z_][\w-]*)', content):
|
||||
for match in re.finditer(r"\.([a-zA-Z_][\w-]*)", content):
|
||||
name = match.group(1)
|
||||
line = content[:match.start()].count('\n') + 1
|
||||
line = content[: match.start()].count("\n") + 1
|
||||
|
||||
# Classify naming pattern
|
||||
if '__' in name or '--' in name:
|
||||
patterns['BEM'].append({'name': name, 'file': rel_path, 'line': line})
|
||||
elif '_' in name:
|
||||
patterns['snake_case'].append({'name': name, 'file': rel_path, 'line': line})
|
||||
elif '-' in name:
|
||||
patterns['kebab-case'].append({'name': name, 'file': rel_path, 'line': line})
|
||||
if "__" in name or "--" in name:
|
||||
patterns["BEM"].append({"name": name, "file": rel_path, "line": line})
|
||||
elif "_" in name:
|
||||
patterns["snake_case"].append(
|
||||
{"name": name, "file": rel_path, "line": line}
|
||||
)
|
||||
elif "-" in name:
|
||||
patterns["kebab-case"].append(
|
||||
{"name": name, "file": rel_path, "line": line}
|
||||
)
|
||||
elif name != name.lower():
|
||||
patterns['camelCase'].append({'name': name, 'file': rel_path, 'line': line})
|
||||
patterns["camelCase"].append(
|
||||
{"name": name, "file": rel_path, "line": line}
|
||||
)
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Calculate primary pattern
|
||||
pattern_counts = {k: len(v) for k, v in patterns.items()}
|
||||
primary = max(pattern_counts, key=pattern_counts.get) if any(pattern_counts.values()) else None
|
||||
primary = (
|
||||
max(pattern_counts, key=pattern_counts.get) if any(pattern_counts.values()) else None
|
||||
)
|
||||
|
||||
# Find inconsistencies (patterns different from primary)
|
||||
inconsistencies = []
|
||||
@@ -521,7 +542,7 @@ class StyleAnalyzer:
|
||||
inconsistencies.extend(items[:10])
|
||||
|
||||
return {
|
||||
'pattern_counts': pattern_counts,
|
||||
'primary_pattern': primary,
|
||||
'inconsistencies': inconsistencies[:20],
|
||||
"pattern_counts": pattern_counts,
|
||||
"primary_pattern": primary,
|
||||
"inconsistencies": inconsistencies[:20],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user