fix: Address high-severity bandit issues

This commit is contained in:
DSS
2025-12-11 07:13:06 -03:00
parent bcb4475744
commit 5b2a328dd1
167 changed files with 7051 additions and 7168 deletions

View File

@@ -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

View File

@@ -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")

View File

@@ -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:

View File

@@ -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()

View File

@@ -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)

View File

@@ -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

View File

@@ -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"}

View File

@@ -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],
}